Generate Fern TypeSscript SDK (#3785)
This commit is contained in:
committed by
GitHub
parent
d55b9637c4
commit
2062adac66
255
skyvern-ts/client/tests/unit/fetcher/Fetcher.test.ts
Normal file
255
skyvern-ts/client/tests/unit/fetcher/Fetcher.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
import stream from "stream";
|
||||
import type { BinaryResponse } from "../../../src/core";
|
||||
import { type Fetcher, fetcherImpl } from "../../../src/core/fetcher/Fetcher";
|
||||
|
||||
describe("Test fetcherImpl", () => {
|
||||
it("should handle successful request", async () => {
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url: "https://httpbin.org/post",
|
||||
method: "POST",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
body: { data: "test" },
|
||||
contentType: "application/json",
|
||||
requestType: "json",
|
||||
responseType: "json",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: "test" }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.body).toEqual({ data: "test" });
|
||||
}
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://httpbin.org/post",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should send octet stream", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "POST",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
contentType: "application/octet-stream",
|
||||
requestType: "bytes",
|
||||
responseType: "json",
|
||||
body: fs.createReadStream(join(__dirname, "test-file.txt")),
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: "test" }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
body: expect.any(fs.ReadStream),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.body).toEqual({ data: "test" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as stream", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.stream).toBe("function");
|
||||
const stream = body.stream();
|
||||
expect(stream).toBeInstanceOf(ReadableStream);
|
||||
const reader = stream.getReader();
|
||||
const { value } = await reader.read();
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(value);
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as blob", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.blob).toBe("function");
|
||||
const blob = await body.blob();
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
const reader = blob.stream().getReader();
|
||||
const { value } = await reader.read();
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(value);
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as arraybuffer", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.arrayBuffer).toBe("function");
|
||||
const arrayBuffer = await body.arrayBuffer();
|
||||
expect(arrayBuffer).toBeInstanceOf(ArrayBuffer);
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(new Uint8Array(arrayBuffer));
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as bytes", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.bytes).toBe("function");
|
||||
if (!body.bytes) {
|
||||
return;
|
||||
}
|
||||
const bytes = await body.bytes();
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(bytes);
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
143
skyvern-ts/client/tests/unit/fetcher/HttpResponsePromise.test.ts
Normal file
143
skyvern-ts/client/tests/unit/fetcher/HttpResponsePromise.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HttpResponsePromise } from "../../../src/core/fetcher/HttpResponsePromise";
|
||||
import type { RawResponse, WithRawResponse } from "../../../src/core/fetcher/RawResponse";
|
||||
|
||||
describe("HttpResponsePromise", () => {
|
||||
const mockRawResponse: RawResponse = {
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
type: "basic" as ResponseType,
|
||||
url: "https://example.com",
|
||||
};
|
||||
const mockData = { id: "123", name: "test" };
|
||||
const mockWithRawResponse: WithRawResponse<typeof mockData> = {
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
};
|
||||
|
||||
describe("fromFunction", () => {
|
||||
it("should create an HttpResponsePromise from a function", async () => {
|
||||
const mockFn = vi
|
||||
.fn<(arg1: string, arg2: string) => Promise<WithRawResponse<typeof mockData>>>()
|
||||
.mockResolvedValue(mockWithRawResponse);
|
||||
|
||||
const responsePromise = HttpResponsePromise.fromFunction(mockFn, "arg1", "arg2");
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
expect(mockFn).toHaveBeenCalledWith("arg1", "arg2");
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it("should create an HttpResponsePromise from a promise", async () => {
|
||||
const promise = Promise.resolve(mockWithRawResponse);
|
||||
|
||||
const responsePromise = HttpResponsePromise.fromPromise(promise);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromExecutor", () => {
|
||||
it("should create an HttpResponsePromise from an executor function", async () => {
|
||||
const responsePromise = HttpResponsePromise.fromExecutor((resolve) => {
|
||||
resolve(mockWithRawResponse);
|
||||
});
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromResult", () => {
|
||||
it("should create an HttpResponsePromise from a result", async () => {
|
||||
const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Promise methods", () => {
|
||||
let responsePromise: HttpResponsePromise<typeof mockData>;
|
||||
|
||||
beforeEach(() => {
|
||||
responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
|
||||
});
|
||||
|
||||
it("should support then() method", async () => {
|
||||
const result = await responsePromise.then((data) => ({
|
||||
...data,
|
||||
modified: true,
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
...mockData,
|
||||
modified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should support catch() method", async () => {
|
||||
const errorResponsePromise = HttpResponsePromise.fromExecutor((_, reject) => {
|
||||
reject(new Error("Test error"));
|
||||
});
|
||||
|
||||
const catchSpy = vi.fn();
|
||||
await errorResponsePromise.catch(catchSpy);
|
||||
|
||||
expect(catchSpy).toHaveBeenCalled();
|
||||
const error = catchSpy.mock.calls[0]?.[0];
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("Test error");
|
||||
});
|
||||
|
||||
it("should support finally() method", async () => {
|
||||
const finallySpy = vi.fn();
|
||||
await responsePromise.finally(finallySpy);
|
||||
|
||||
expect(finallySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRawResponse", () => {
|
||||
it("should return both data and raw response", async () => {
|
||||
const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
|
||||
|
||||
const result = await responsePromise.withRawResponse();
|
||||
|
||||
expect(result).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
34
skyvern-ts/client/tests/unit/fetcher/RawResponse.test.ts
Normal file
34
skyvern-ts/client/tests/unit/fetcher/RawResponse.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toRawResponse } from "../../../src/core/fetcher/RawResponse";
|
||||
|
||||
describe("RawResponse", () => {
|
||||
describe("toRawResponse", () => {
|
||||
it("should convert Response to RawResponse by removing body, bodyUsed, and ok properties", () => {
|
||||
const mockHeaders = new Headers({ "content-type": "application/json" });
|
||||
const mockResponse = {
|
||||
body: "test body",
|
||||
bodyUsed: false,
|
||||
ok: true,
|
||||
headers: mockHeaders,
|
||||
redirected: false,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
type: "basic" as ResponseType,
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
const result = toRawResponse(mockResponse as unknown as Response);
|
||||
|
||||
expect("body" in result).toBe(false);
|
||||
expect("bodyUsed" in result).toBe(false);
|
||||
expect("ok" in result).toBe(false);
|
||||
expect(result.headers).toBe(mockHeaders);
|
||||
expect(result.redirected).toBe(false);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.statusText).toBe("OK");
|
||||
expect(result.type).toBe("basic");
|
||||
expect(result.url).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
160
skyvern-ts/client/tests/unit/fetcher/createRequestUrl.test.ts
Normal file
160
skyvern-ts/client/tests/unit/fetcher/createRequestUrl.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { createRequestUrl } from "../../../src/core/fetcher/createRequestUrl";
|
||||
|
||||
describe("Test createRequestUrl", () => {
|
||||
it("should return the base URL when no query parameters are provided", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
expect(createRequestUrl(baseUrl)).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should append simple query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { key: "value", another: "param" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?key=value&another=param");
|
||||
});
|
||||
|
||||
it("should handle array query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { items: ["a", "b", "c"] };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?items=a&items=b&items=c");
|
||||
});
|
||||
|
||||
it("should handle object query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { filter: { name: "John", age: 30 } };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?filter%5Bname%5D=John&filter%5Bage%5D=30",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed types of query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
simple: "value",
|
||||
array: ["x", "y"],
|
||||
object: { key: "value" },
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty query parameters object", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
expect(createRequestUrl(baseUrl, {})).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should encode special characters in query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { special: "a&b=c d" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?special=a%26b%3Dc%20d");
|
||||
});
|
||||
|
||||
// Additional tests for edge cases and different value types
|
||||
it("should handle numeric values", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { count: 42, price: 19.99, active: 1, inactive: 0 };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?count=42&price=19.99&active=1&inactive=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle boolean values", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { enabled: true, disabled: false };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?enabled=true&disabled=false");
|
||||
});
|
||||
|
||||
it("should handle null and undefined values", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
valid: "value",
|
||||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
emptyString: "",
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?valid=value&nullValue=&emptyString=",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle deeply nested objects", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
user: {
|
||||
profile: {
|
||||
name: "John",
|
||||
settings: { theme: "dark" },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle arrays of objects", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
users: [
|
||||
{ name: "John", age: 30 },
|
||||
{ name: "Jane", age: 25 },
|
||||
],
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?users%5Bname%5D=John&users%5Bage%5D=30&users%5Bname%5D=Jane&users%5Bage%5D=25",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed arrays", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
mixed: ["string", 42, true, { key: "value" }],
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?mixed=string&mixed=42&mixed=true&mixed%5Bkey%5D=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { emptyArray: [] };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should handle empty objects", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { emptyObject: {} };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should handle special characters in keys", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { "key with spaces": "value", "key[with]brackets": "value" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?key%20with%20spaces=value&key%5Bwith%5Dbrackets=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle URL with existing query parameters", () => {
|
||||
const baseUrl = "https://api.example.com?existing=param";
|
||||
const queryParams = { new: "value" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?existing=param?new=value");
|
||||
});
|
||||
|
||||
it("should handle complex nested structures", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
filters: {
|
||||
status: ["active", "pending"],
|
||||
category: {
|
||||
type: "electronics",
|
||||
subcategories: ["phones", "laptops"],
|
||||
},
|
||||
},
|
||||
sort: { field: "name", direction: "asc" },
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
|
||||
);
|
||||
});
|
||||
});
|
||||
65
skyvern-ts/client/tests/unit/fetcher/getRequestBody.test.ts
Normal file
65
skyvern-ts/client/tests/unit/fetcher/getRequestBody.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getRequestBody } from "../../../src/core/fetcher/getRequestBody";
|
||||
import { RUNTIME } from "../../../src/core/runtime";
|
||||
|
||||
describe("Test getRequestBody", () => {
|
||||
it("should stringify body if not FormData in Node environment", async () => {
|
||||
if (RUNTIME.type === "node") {
|
||||
const body = { key: "value" };
|
||||
const result = await getRequestBody({
|
||||
body,
|
||||
type: "json",
|
||||
});
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
}
|
||||
});
|
||||
|
||||
it("should return FormData in browser environment", async () => {
|
||||
if (RUNTIME.type === "browser") {
|
||||
const formData = new FormData();
|
||||
formData.append("key", "value");
|
||||
const result = await getRequestBody({
|
||||
body: formData,
|
||||
type: "file",
|
||||
});
|
||||
expect(result).toBe(formData);
|
||||
}
|
||||
});
|
||||
|
||||
it("should stringify body if not FormData in browser environment", async () => {
|
||||
if (RUNTIME.type === "browser") {
|
||||
const body = { key: "value" };
|
||||
const result = await getRequestBody({
|
||||
body,
|
||||
type: "json",
|
||||
});
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
}
|
||||
});
|
||||
|
||||
it("should return the Uint8Array", async () => {
|
||||
const input = new Uint8Array([1, 2, 3]);
|
||||
const result = await getRequestBody({
|
||||
body: input,
|
||||
type: "bytes",
|
||||
});
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("should return the input for content-type 'application/x-www-form-urlencoded'", async () => {
|
||||
const input = "key=value&another=param";
|
||||
const result = await getRequestBody({
|
||||
body: input,
|
||||
type: "other",
|
||||
});
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("should JSON stringify objects", async () => {
|
||||
const input = { key: "value" };
|
||||
const result = await getRequestBody({
|
||||
body: input,
|
||||
type: "json",
|
||||
});
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
});
|
||||
77
skyvern-ts/client/tests/unit/fetcher/getResponseBody.test.ts
Normal file
77
skyvern-ts/client/tests/unit/fetcher/getResponseBody.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { getResponseBody } from "../../../src/core/fetcher/getResponseBody";
|
||||
import { RUNTIME } from "../../../src/core/runtime";
|
||||
|
||||
describe("Test getResponseBody", () => {
|
||||
it("should handle blob response type", async () => {
|
||||
const mockBlob = new Blob(["test"], { type: "text/plain" });
|
||||
const mockResponse = new Response(mockBlob);
|
||||
const result = await getResponseBody(mockResponse, "blob");
|
||||
// @ts-expect-error
|
||||
expect(result.constructor.name).toBe("Blob");
|
||||
});
|
||||
|
||||
it("should handle sse response type", async () => {
|
||||
if (RUNTIME.type === "node") {
|
||||
const mockStream = new ReadableStream();
|
||||
const mockResponse = new Response(mockStream);
|
||||
const result = await getResponseBody(mockResponse, "sse");
|
||||
expect(result).toBe(mockStream);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle streaming response type", async () => {
|
||||
// Create a ReadableStream with some test data
|
||||
const encoder = new TextEncoder();
|
||||
const testData = "test stream data";
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(testData));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = new Response(mockStream);
|
||||
const result = (await getResponseBody(mockResponse, "streaming")) as ReadableStream;
|
||||
|
||||
expect(result).toBeInstanceOf(ReadableStream);
|
||||
|
||||
// Read and verify the stream content
|
||||
const reader = result.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const { value } = await reader.read();
|
||||
const streamContent = decoder.decode(value);
|
||||
expect(streamContent).toBe(testData);
|
||||
});
|
||||
|
||||
it("should handle text response type", async () => {
|
||||
const mockResponse = new Response("test text");
|
||||
const result = await getResponseBody(mockResponse, "text");
|
||||
expect(result).toBe("test text");
|
||||
});
|
||||
|
||||
it("should handle JSON response", async () => {
|
||||
const mockJson = { key: "value" };
|
||||
const mockResponse = new Response(JSON.stringify(mockJson));
|
||||
const result = await getResponseBody(mockResponse);
|
||||
expect(result).toEqual(mockJson);
|
||||
});
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
const mockResponse = new Response("");
|
||||
const result = await getResponseBody(mockResponse);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle non-JSON response", async () => {
|
||||
const mockResponse = new Response("invalid json");
|
||||
const result = await getResponseBody(mockResponse);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "non-json",
|
||||
statusCode: 200,
|
||||
rawBody: "invalid json",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
53
skyvern-ts/client/tests/unit/fetcher/makeRequest.test.ts
Normal file
53
skyvern-ts/client/tests/unit/fetcher/makeRequest.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { makeRequest } from "../../../src/core/fetcher/makeRequest";
|
||||
|
||||
describe("Test makeRequest", () => {
|
||||
const mockPostUrl = "https://httpbin.org/post";
|
||||
const mockGetUrl = "https://httpbin.org/get";
|
||||
const mockHeaders = { "Content-Type": "application/json" };
|
||||
const mockBody = JSON.stringify({ key: "value" });
|
||||
|
||||
let mockFetch: import("vitest").Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ test: "successful" }), { status: 200 }));
|
||||
});
|
||||
|
||||
it("should handle POST request correctly", async () => {
|
||||
const response = await makeRequest(mockFetch, mockPostUrl, "POST", mockHeaders, mockBody);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody).toEqual({ test: "successful" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
|
||||
expect(calledUrl).toBe(mockPostUrl);
|
||||
expect(calledOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: mockHeaders,
|
||||
body: mockBody,
|
||||
credentials: undefined,
|
||||
}),
|
||||
);
|
||||
expect(calledOptions.signal).toBeDefined();
|
||||
expect(calledOptions.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("should handle GET request correctly", async () => {
|
||||
const response = await makeRequest(mockFetch, mockGetUrl, "GET", mockHeaders, undefined);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody).toEqual({ test: "successful" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
|
||||
expect(calledUrl).toBe(mockGetUrl);
|
||||
expect(calledOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: mockHeaders,
|
||||
body: undefined,
|
||||
credentials: undefined,
|
||||
}),
|
||||
);
|
||||
expect(calledOptions.signal).toBeDefined();
|
||||
expect(calledOptions.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
250
skyvern-ts/client/tests/unit/fetcher/requestWithRetries.test.ts
Normal file
250
skyvern-ts/client/tests/unit/fetcher/requestWithRetries.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries";
|
||||
|
||||
describe("requestWithRetries", () => {
|
||||
let mockFetch: import("vitest").Mock;
|
||||
let originalMathRandom: typeof Math.random;
|
||||
let setTimeoutSpy: import("vitest").MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
originalMathRandom = Math.random;
|
||||
|
||||
// Mock Math.random for consistent jitter
|
||||
Math.random = vi.fn(() => 0.5);
|
||||
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
"setTimeout",
|
||||
"clearTimeout",
|
||||
"setInterval",
|
||||
"clearInterval",
|
||||
"setImmediate",
|
||||
"clearImmediate",
|
||||
"Date",
|
||||
"performance",
|
||||
"requestAnimationFrame",
|
||||
"cancelAnimationFrame",
|
||||
"requestIdleCallback",
|
||||
"cancelIdleCallback",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Math.random = originalMathRandom;
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("should retry on retryable status codes", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const retryableStatuses = [408, 429, 500, 502];
|
||||
let callCount = 0;
|
||||
|
||||
mockFetch.mockImplementation(async () => {
|
||||
if (callCount < retryableStatuses.length) {
|
||||
return new Response("", { status: retryableStatuses[callCount++] });
|
||||
}
|
||||
return new Response("", { status: 200 });
|
||||
});
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), retryableStatuses.length);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(retryableStatuses.length + 1);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect maxRetries limit", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const maxRetries = 2;
|
||||
mockFetch.mockResolvedValue(new Response("", { status: 500 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should not retry on success status codes", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const successStatuses = [200, 201, 202];
|
||||
|
||||
for (const status of successStatuses) {
|
||||
mockFetch.mockReset();
|
||||
setTimeoutSpy.mockClear();
|
||||
mockFetch.mockResolvedValueOnce(new Response("", { status }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 3);
|
||||
await vi.runAllTimersAsync();
|
||||
await responsePromise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("should apply correct exponential backoff with jitter", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValue(new Response("", { status: 500 }));
|
||||
const maxRetries = 3;
|
||||
const expectedDelays = [1000, 2000, 4000];
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
|
||||
await vi.runAllTimersAsync();
|
||||
await responsePromise;
|
||||
|
||||
// Verify setTimeout calls
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(expectedDelays.length);
|
||||
|
||||
expectedDelays.forEach((delay, index) => {
|
||||
expect(setTimeoutSpy).toHaveBeenNthCalledWith(index + 1, expect.any(Function), delay);
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
|
||||
});
|
||||
|
||||
it("should handle concurrent retries independently", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const promise1 = requestWithRetries(() => mockFetch(), 1);
|
||||
const promise2 = requestWithRetries(() => mockFetch(), 1);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const [response1, response2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect retry-after header with seconds value", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "retry-after": "5" }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); // 5 seconds = 5000ms
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect retry-after header with HTTP date value", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const futureDate = new Date(Date.now() + 3000); // 3 seconds from now
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "retry-after": futureDate.toUTCString() }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Should use the date-based delay (approximately 3000ms, but with jitter)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
|
||||
const actualDelay = setTimeoutSpy.mock.calls[0][1];
|
||||
expect(actualDelay).toBeGreaterThan(2000);
|
||||
expect(actualDelay).toBeLessThan(4000);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect x-ratelimit-reset header", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const resetTime = Math.floor((Date.now() + 4000) / 1000); // 4 seconds from now in Unix timestamp
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "x-ratelimit-reset": resetTime.toString() }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Should use the x-ratelimit-reset delay (approximately 4000ms, but with positive jitter)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
|
||||
const actualDelay = setTimeoutSpy.mock.calls[0][1];
|
||||
expect(actualDelay).toBeGreaterThan(3000);
|
||||
expect(actualDelay).toBeLessThan(6000);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should cap delay at MAX_RETRY_DELAY for large header values", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "retry-after": "120" }), // 120 seconds = 120000ms > MAX_RETRY_DELAY (60000ms)
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Should be capped at MAX_RETRY_DELAY (60000ms) with jitter applied
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000); // Exactly MAX_RETRY_DELAY since jitter with 0.5 random keeps it at 60000
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
69
skyvern-ts/client/tests/unit/fetcher/signals.test.ts
Normal file
69
skyvern-ts/client/tests/unit/fetcher/signals.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { anySignal, getTimeoutSignal } from "../../../src/core/fetcher/signals";
|
||||
|
||||
describe("Test getTimeoutSignal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return an object with signal and abortId", () => {
|
||||
const { signal, abortId } = getTimeoutSignal(1000);
|
||||
|
||||
expect(signal).toBeDefined();
|
||||
expect(abortId).toBeDefined();
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("should create a signal that aborts after the specified timeout", () => {
|
||||
const timeoutMs = 5000;
|
||||
const { signal } = getTimeoutSignal(timeoutMs);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(timeoutMs - 1);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test anySignal", () => {
|
||||
it("should return an AbortSignal", () => {
|
||||
const signal = anySignal(new AbortController().signal);
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("should abort when any of the input signals is aborted", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
const signal = anySignal(controller1.signal, controller2.signal);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
controller1.abort();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle an array of signals", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
const signal = anySignal([controller1.signal, controller2.signal]);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
controller2.abort();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should abort immediately if one of the input signals is already aborted", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
controller1.abort();
|
||||
|
||||
const signal = anySignal(controller1.signal, controller2.signal);
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
1
skyvern-ts/client/tests/unit/fetcher/test-file.txt
Normal file
1
skyvern-ts/client/tests/unit/fetcher/test-file.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is a test file!
|
||||
Reference in New Issue
Block a user