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!
|
||||
120
skyvern-ts/client/tests/unit/url/join.test.ts
Normal file
120
skyvern-ts/client/tests/unit/url/join.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { join } from "../../../src/core/url/index";
|
||||
|
||||
describe("join", () => {
|
||||
describe("basic functionality", () => {
|
||||
it("should return empty string for empty base", () => {
|
||||
expect(join("")).toBe("");
|
||||
expect(join("", "path")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle single segment", () => {
|
||||
expect(join("base", "segment")).toBe("base/segment");
|
||||
expect(join("base/", "segment")).toBe("base/segment");
|
||||
expect(join("base", "/segment")).toBe("base/segment");
|
||||
expect(join("base/", "/segment")).toBe("base/segment");
|
||||
});
|
||||
|
||||
it("should handle multiple segments", () => {
|
||||
expect(join("base", "path1", "path2", "path3")).toBe("base/path1/path2/path3");
|
||||
expect(join("base/", "/path1/", "/path2/", "/path3/")).toBe("base/path1/path2/path3/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL handling", () => {
|
||||
it("should handle absolute URLs", () => {
|
||||
expect(join("https://example.com", "api", "v1")).toBe("https://example.com/api/v1");
|
||||
expect(join("https://example.com/", "/api/", "/v1/")).toBe("https://example.com/api/v1/");
|
||||
expect(join("https://example.com/base", "api", "v1")).toBe("https://example.com/base/api/v1");
|
||||
});
|
||||
|
||||
it("should preserve URL query parameters and fragments", () => {
|
||||
expect(join("https://example.com?query=1", "api")).toBe("https://example.com/api?query=1");
|
||||
expect(join("https://example.com#fragment", "api")).toBe("https://example.com/api#fragment");
|
||||
expect(join("https://example.com?query=1#fragment", "api")).toBe(
|
||||
"https://example.com/api?query=1#fragment",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different protocols", () => {
|
||||
expect(join("http://example.com", "api")).toBe("http://example.com/api");
|
||||
expect(join("ftp://example.com", "files")).toBe("ftp://example.com/files");
|
||||
expect(join("ws://example.com", "socket")).toBe("ws://example.com/socket");
|
||||
});
|
||||
|
||||
it("should fallback to path joining for malformed URLs", () => {
|
||||
expect(join("not-a-url://", "path")).toBe("not-a-url:///path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty segments", () => {
|
||||
expect(join("base", "", "path")).toBe("base/path");
|
||||
expect(join("base", null as any, "path")).toBe("base/path");
|
||||
expect(join("base", undefined as any, "path")).toBe("base/path");
|
||||
});
|
||||
|
||||
it("should handle segments with only slashes", () => {
|
||||
expect(join("base", "/", "path")).toBe("base/path");
|
||||
expect(join("base", "//", "path")).toBe("base/path");
|
||||
});
|
||||
|
||||
it("should handle base paths with trailing slashes", () => {
|
||||
expect(join("base/", "path")).toBe("base/path");
|
||||
});
|
||||
|
||||
it("should handle complex nested paths", () => {
|
||||
expect(join("api/v1/", "/users/", "/123/", "/profile")).toBe("api/v1/users/123/profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should handle API endpoint construction", () => {
|
||||
const baseUrl = "https://api.example.com/v1";
|
||||
expect(join(baseUrl, "users", "123", "posts")).toBe("https://api.example.com/v1/users/123/posts");
|
||||
});
|
||||
|
||||
it("should handle file path construction", () => {
|
||||
expect(join("/var/www", "html", "assets", "images")).toBe("/var/www/html/assets/images");
|
||||
});
|
||||
|
||||
it("should handle relative path construction", () => {
|
||||
expect(join("../parent", "child", "grandchild")).toBe("../parent/child/grandchild");
|
||||
});
|
||||
|
||||
it("should handle Windows-style paths", () => {
|
||||
expect(join("C:\\Users", "Documents", "file.txt")).toBe("C:\\Users/Documents/file.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance scenarios", () => {
|
||||
it("should handle many segments efficiently", () => {
|
||||
const segments = Array(100).fill("segment");
|
||||
const result = join("base", ...segments);
|
||||
expect(result).toBe(`base/${segments.join("/")}`);
|
||||
});
|
||||
|
||||
it("should handle long URLs", () => {
|
||||
const longPath = "a".repeat(1000);
|
||||
expect(join("https://example.com", longPath)).toBe(`https://example.com/${longPath}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trailing slash preservation", () => {
|
||||
it("should preserve trailing slash on final result when base has trailing slash and no segments", () => {
|
||||
expect(join("https://api.example.com/")).toBe("https://api.example.com/");
|
||||
expect(join("https://api.example.com/v1/")).toBe("https://api.example.com/v1/");
|
||||
});
|
||||
|
||||
it("should preserve trailing slash when last segment has trailing slash", () => {
|
||||
expect(join("https://api.example.com", "users/")).toBe("https://api.example.com/users/");
|
||||
expect(join("api/v1", "users/")).toBe("api/v1/users/");
|
||||
});
|
||||
|
||||
it("should preserve trailing slash with multiple segments where last has trailing slash", () => {
|
||||
expect(join("https://api.example.com", "v1", "collections/")).toBe(
|
||||
"https://api.example.com/v1/collections/",
|
||||
);
|
||||
expect(join("base", "path1", "path2/")).toBe("base/path1/path2/");
|
||||
});
|
||||
});
|
||||
});
|
||||
187
skyvern-ts/client/tests/unit/url/qs.test.ts
Normal file
187
skyvern-ts/client/tests/unit/url/qs.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { toQueryString } from "../../../src/core/url/index";
|
||||
|
||||
describe("Test qs toQueryString", () => {
|
||||
describe("Basic functionality", () => {
|
||||
it("should return empty string for null/undefined", () => {
|
||||
expect(toQueryString(null)).toBe("");
|
||||
expect(toQueryString(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for primitive values", () => {
|
||||
expect(toQueryString("hello")).toBe("");
|
||||
expect(toQueryString(42)).toBe("");
|
||||
expect(toQueryString(true)).toBe("");
|
||||
expect(toQueryString(false)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle empty objects", () => {
|
||||
expect(toQueryString({})).toBe("");
|
||||
});
|
||||
|
||||
it("should handle simple key-value pairs", () => {
|
||||
const obj = { name: "John", age: 30 };
|
||||
expect(toQueryString(obj)).toBe("name=John&age=30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array handling", () => {
|
||||
it("should handle arrays with indices format (default)", () => {
|
||||
const obj = { items: ["a", "b", "c"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=b&items%5B2%5D=c");
|
||||
});
|
||||
|
||||
it("should handle arrays with repeat format", () => {
|
||||
const obj = { items: ["a", "b", "c"] };
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat" })).toBe("items=a&items=b&items=c");
|
||||
});
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
const obj = { items: [] };
|
||||
expect(toQueryString(obj)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle arrays with mixed types", () => {
|
||||
const obj = { mixed: ["string", 42, true, false] };
|
||||
expect(toQueryString(obj)).toBe("mixed%5B0%5D=string&mixed%5B1%5D=42&mixed%5B2%5D=true&mixed%5B3%5D=false");
|
||||
});
|
||||
|
||||
it("should handle arrays with objects", () => {
|
||||
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||
expect(toQueryString(obj)).toBe("users%5B0%5D%5Bname%5D=John&users%5B1%5D%5Bname%5D=Jane");
|
||||
});
|
||||
|
||||
it("should handle arrays with objects in repeat format", () => {
|
||||
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat" })).toBe("users%5Bname%5D=John&users%5Bname%5D=Jane");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested objects", () => {
|
||||
it("should handle nested objects", () => {
|
||||
const obj = { user: { name: "John", age: 30 } };
|
||||
expect(toQueryString(obj)).toBe("user%5Bname%5D=John&user%5Bage%5D=30");
|
||||
});
|
||||
|
||||
it("should handle deeply nested objects", () => {
|
||||
const obj = { user: { profile: { name: "John", settings: { theme: "dark" } } } };
|
||||
expect(toQueryString(obj)).toBe(
|
||||
"user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty nested objects", () => {
|
||||
const obj = { user: {} };
|
||||
expect(toQueryString(obj)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encoding", () => {
|
||||
it("should encode by default", () => {
|
||||
const obj = { name: "John Doe", email: "john@example.com" };
|
||||
expect(toQueryString(obj)).toBe("name=John%20Doe&email=john%40example.com");
|
||||
});
|
||||
|
||||
it("should not encode when encode is false", () => {
|
||||
const obj = { name: "John Doe", email: "john@example.com" };
|
||||
expect(toQueryString(obj, { encode: false })).toBe("name=John Doe&email=john@example.com");
|
||||
});
|
||||
|
||||
it("should encode special characters in keys", () => {
|
||||
const obj = { "user name": "John", "email[primary]": "john@example.com" };
|
||||
expect(toQueryString(obj)).toBe("user%20name=John&email%5Bprimary%5D=john%40example.com");
|
||||
});
|
||||
|
||||
it("should not encode special characters in keys when encode is false", () => {
|
||||
const obj = { "user name": "John", "email[primary]": "john@example.com" };
|
||||
expect(toQueryString(obj, { encode: false })).toBe("user name=John&email[primary]=john@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed scenarios", () => {
|
||||
it("should handle complex nested structures", () => {
|
||||
const obj = {
|
||||
filters: {
|
||||
status: ["active", "pending"],
|
||||
category: {
|
||||
type: "electronics",
|
||||
subcategories: ["phones", "laptops"],
|
||||
},
|
||||
},
|
||||
sort: { field: "name", direction: "asc" },
|
||||
};
|
||||
expect(toQueryString(obj)).toBe(
|
||||
"filters%5Bstatus%5D%5B0%5D=active&filters%5Bstatus%5D%5B1%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D%5B0%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D%5B1%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex nested structures with repeat format", () => {
|
||||
const obj = {
|
||||
filters: {
|
||||
status: ["active", "pending"],
|
||||
category: {
|
||||
type: "electronics",
|
||||
subcategories: ["phones", "laptops"],
|
||||
},
|
||||
},
|
||||
sort: { field: "name", direction: "asc" },
|
||||
};
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat" })).toBe(
|
||||
"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",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle arrays with null/undefined values", () => {
|
||||
const obj = { items: ["a", null, "c", undefined, "e"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c&items%5B4%5D=e");
|
||||
});
|
||||
|
||||
it("should handle objects with null/undefined values", () => {
|
||||
const obj = { name: "John", age: null, email: undefined, active: true };
|
||||
expect(toQueryString(obj)).toBe("name=John&age=&active=true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle numeric keys", () => {
|
||||
const obj = { "0": "zero", "1": "one" };
|
||||
expect(toQueryString(obj)).toBe("0=zero&1=one");
|
||||
});
|
||||
|
||||
it("should handle boolean values in objects", () => {
|
||||
const obj = { enabled: true, disabled: false };
|
||||
expect(toQueryString(obj)).toBe("enabled=true&disabled=false");
|
||||
});
|
||||
|
||||
it("should handle empty strings", () => {
|
||||
const obj = { name: "", description: "test" };
|
||||
expect(toQueryString(obj)).toBe("name=&description=test");
|
||||
});
|
||||
|
||||
it("should handle zero values", () => {
|
||||
const obj = { count: 0, price: 0.0 };
|
||||
expect(toQueryString(obj)).toBe("count=0&price=0");
|
||||
});
|
||||
|
||||
it("should handle arrays with empty strings", () => {
|
||||
const obj = { items: ["a", "", "c"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Options combinations", () => {
|
||||
it("should respect both arrayFormat and encode options", () => {
|
||||
const obj = { items: ["a & b", "c & d"] };
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat", encode: false })).toBe("items=a & b&items=c & d");
|
||||
});
|
||||
|
||||
it("should use default options when none provided", () => {
|
||||
const obj = { items: ["a", "b"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=b");
|
||||
});
|
||||
|
||||
it("should merge provided options with defaults", () => {
|
||||
const obj = { items: ["a", "b"], name: "John Doe" };
|
||||
expect(toQueryString(obj, { encode: false })).toBe("items[0]=a&items[1]=b&name=John Doe");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user