Generate Fern TypeSscript SDK (#3785)

This commit is contained in:
Stanislav Novosad
2025-10-23 20:14:59 -06:00
committed by GitHub
parent d55b9637c4
commit 2062adac66
239 changed files with 14550 additions and 3 deletions

View File

@@ -0,0 +1,13 @@
/**
* This is a custom test file, if you wish to add more tests
* to your SDK.
* Be sure to mark this file in `.fernignore`.
*
* If you include example requests/responses in your fern definition,
* you will have tests automatically generated for you.
*/
describe("test", () => {
it("default", () => {
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
import type { RequestHandlerOptions } from "msw";
import type { SetupServer } from "msw/node";
import { mockEndpointBuilder } from "./mockEndpointBuilder";
export interface MockServerOptions {
baseUrl: string;
server: SetupServer;
}
export class MockServer {
private readonly server: SetupServer;
public readonly baseUrl: string;
constructor({ baseUrl, server }: MockServerOptions) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
this.server = server;
}
public mockEndpoint(options?: RequestHandlerOptions): ReturnType<typeof mockEndpointBuilder> {
const builder = mockEndpointBuilder({
once: options?.once,
onBuild: (handler) => {
this.server.use(handler);
},
}).baseUrl(this.baseUrl);
return builder;
}
}

View File

@@ -0,0 +1,106 @@
import { setupServer } from "msw/node";
import { fromJson, toJson } from "../../src/core/json";
import { MockServer } from "./MockServer";
import { randomBaseUrl } from "./randomBaseUrl";
const mswServer = setupServer();
interface MockServerOptions {
baseUrl?: string;
}
async function formatHttpRequest(request: Request, id?: string): Promise<string> {
try {
const clone = request.clone();
const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
let body = "";
try {
const contentType = clone.headers.get("content-type");
if (contentType?.includes("application/json")) {
body = toJson(fromJson(await clone.text()), undefined, 2);
} else if (clone.body) {
body = await clone.text();
}
} catch (_e) {
body = "(unable to parse body)";
}
const title = id ? `### Request ${id} ###\n` : "";
const firstLine = `${title}${request.method} ${request.url.toString()} HTTP/1.1`;
return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
} catch (e) {
return `Error formatting request: ${e}`;
}
}
async function formatHttpResponse(response: Response, id?: string): Promise<string> {
try {
const clone = response.clone();
const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
let body = "";
try {
const contentType = clone.headers.get("content-type");
if (contentType?.includes("application/json")) {
body = toJson(fromJson(await clone.text()), undefined, 2);
} else if (clone.body) {
body = await clone.text();
}
} catch (_e) {
body = "(unable to parse body)";
}
const title = id ? `### Response for ${id} ###\n` : "";
const firstLine = `${title}HTTP/1.1 ${response.status} ${response.statusText}`;
return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
} catch (e) {
return `Error formatting response: ${e}`;
}
}
class MockServerPool {
private servers: MockServer[] = [];
public createServer(options?: Partial<MockServerOptions>): MockServer {
const baseUrl = options?.baseUrl || randomBaseUrl();
const server = new MockServer({ baseUrl, server: mswServer });
this.servers.push(server);
return server;
}
public getServers(): MockServer[] {
return [...this.servers];
}
public listen(): void {
const onUnhandledRequest = process.env.LOG_LEVEL === "debug" ? "warn" : "bypass";
mswServer.listen({ onUnhandledRequest });
if (process.env.LOG_LEVEL === "debug") {
mswServer.events.on("request:start", async ({ request, requestId }) => {
const formattedRequest = await formatHttpRequest(request, requestId);
console.debug(`request:start\n${formattedRequest}`);
});
mswServer.events.on("request:unhandled", async ({ request, requestId }) => {
const formattedRequest = await formatHttpRequest(request, requestId);
console.debug(`request:unhandled\n${formattedRequest}`);
});
mswServer.events.on("response:mocked", async ({ request, response, requestId }) => {
const formattedResponse = await formatHttpResponse(response, requestId);
console.debug(`response:mocked\n${formattedResponse}`);
});
}
}
public close(): void {
this.servers = [];
mswServer.close();
}
}
export const mockServerPool = new MockServerPool();

View File

@@ -0,0 +1,215 @@
import { type DefaultBodyType, type HttpHandler, HttpResponse, type HttpResponseResolver, http } from "msw";
import { url } from "../../src/core";
import { toJson } from "../../src/core/json";
import { withHeaders } from "./withHeaders";
import { withJson } from "./withJson";
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
interface MethodStage {
baseUrl(baseUrl: string): MethodStage;
all(path: string): RequestHeadersStage;
get(path: string): RequestHeadersStage;
post(path: string): RequestHeadersStage;
put(path: string): RequestHeadersStage;
delete(path: string): RequestHeadersStage;
patch(path: string): RequestHeadersStage;
options(path: string): RequestHeadersStage;
head(path: string): RequestHeadersStage;
}
interface RequestHeadersStage extends RequestBodyStage, ResponseStage {
header(name: string, value: string): RequestHeadersStage;
headers(headers: Record<string, string>): RequestBodyStage;
}
interface RequestBodyStage extends ResponseStage {
jsonBody(body: unknown): ResponseStage;
}
interface ResponseStage {
respondWith(): ResponseStatusStage;
}
interface ResponseStatusStage {
statusCode(statusCode: number): ResponseHeaderStage;
}
interface ResponseHeaderStage extends ResponseBodyStage, BuildStage {
header(name: string, value: string): ResponseHeaderStage;
headers(headers: Record<string, string>): ResponseHeaderStage;
}
interface ResponseBodyStage {
jsonBody(body: unknown): BuildStage;
}
interface BuildStage {
build(): HttpHandler;
}
export interface HttpHandlerBuilderOptions {
onBuild?: (handler: HttpHandler) => void;
once?: boolean;
}
class RequestBuilder implements MethodStage, RequestHeadersStage, RequestBodyStage, ResponseStage {
private method: HttpMethod = "get";
private _baseUrl: string = "";
private path: string = "/";
private readonly predicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[] = [];
private readonly handlerOptions?: HttpHandlerBuilderOptions;
constructor(options?: HttpHandlerBuilderOptions) {
this.handlerOptions = options;
}
baseUrl(baseUrl: string): MethodStage {
this._baseUrl = baseUrl;
return this;
}
all(path: string): RequestHeadersStage {
this.method = "all";
this.path = path;
return this;
}
get(path: string): RequestHeadersStage {
this.method = "get";
this.path = path;
return this;
}
post(path: string): RequestHeadersStage {
this.method = "post";
this.path = path;
return this;
}
put(path: string): RequestHeadersStage {
this.method = "put";
this.path = path;
return this;
}
delete(path: string): RequestHeadersStage {
this.method = "delete";
this.path = path;
return this;
}
patch(path: string): RequestHeadersStage {
this.method = "patch";
this.path = path;
return this;
}
options(path: string): RequestHeadersStage {
this.method = "options";
this.path = path;
return this;
}
head(path: string): RequestHeadersStage {
this.method = "head";
this.path = path;
return this;
}
header(name: string, value: string): RequestHeadersStage {
this.predicates.push((resolver) => withHeaders({ [name]: value }, resolver));
return this;
}
headers(headers: Record<string, string>): RequestBodyStage {
this.predicates.push((resolver) => withHeaders(headers, resolver));
return this;
}
jsonBody(body: unknown): ResponseStage {
if (body === undefined) {
throw new Error("Undefined is not valid JSON. Do not call jsonBody if you want an empty body.");
}
this.predicates.push((resolver) => withJson(body, resolver));
return this;
}
respondWith(): ResponseStatusStage {
return new ResponseBuilder(this.method, this.buildUrl(), this.predicates, this.handlerOptions);
}
private buildUrl(): string {
return url.join(this._baseUrl, this.path);
}
}
class ResponseBuilder implements ResponseStatusStage, ResponseHeaderStage, ResponseBodyStage, BuildStage {
private readonly method: HttpMethod;
private readonly url: string;
private readonly requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[];
private readonly handlerOptions?: HttpHandlerBuilderOptions;
private responseStatusCode: number = 200;
private responseHeaders: Record<string, string> = {};
private responseBody: DefaultBodyType = undefined;
constructor(
method: HttpMethod,
url: string,
requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[],
options?: HttpHandlerBuilderOptions,
) {
this.method = method;
this.url = url;
this.requestPredicates = requestPredicates;
this.handlerOptions = options;
}
public statusCode(code: number): ResponseHeaderStage {
this.responseStatusCode = code;
return this;
}
public header(name: string, value: string): ResponseHeaderStage {
this.responseHeaders[name] = value;
return this;
}
public headers(headers: Record<string, string>): ResponseHeaderStage {
this.responseHeaders = { ...this.responseHeaders, ...headers };
return this;
}
public jsonBody(body: unknown): BuildStage {
if (body === undefined) {
throw new Error("Undefined is not valid JSON. Do not call jsonBody if you expect an empty body.");
}
this.responseBody = toJson(body);
return this;
}
public build(): HttpHandler {
const responseResolver: HttpResponseResolver = () => {
const response = new HttpResponse(this.responseBody, {
status: this.responseStatusCode,
headers: this.responseHeaders,
});
// if no Content-Type header is set, delete the default text content type that is set
if (Object.keys(this.responseHeaders).some((key) => key.toLowerCase() === "content-type") === false) {
response.headers.delete("Content-Type");
}
return response;
};
const finalResolver = this.requestPredicates.reduceRight((acc, predicate) => predicate(acc), responseResolver);
const handler = http[this.method](this.url, finalResolver, this.handlerOptions);
this.handlerOptions?.onBuild?.(handler);
return handler;
}
}
export function mockEndpointBuilder(options?: HttpHandlerBuilderOptions): MethodStage {
return new RequestBuilder(options);
}

View File

@@ -0,0 +1,4 @@
export function randomBaseUrl(): string {
const randomString = Math.random().toString(36).substring(2, 15);
return `http://${randomString}.localhost`;
}

View File

@@ -0,0 +1,10 @@
import { afterAll, beforeAll } from "vitest";
import { mockServerPool } from "./MockServerPool";
beforeAll(() => {
mockServerPool.listen();
});
afterAll(() => {
mockServerPool.close();
});

View File

@@ -0,0 +1,70 @@
import { type HttpResponseResolver, passthrough } from "msw";
/**
* Creates a request matcher that validates if request headers match specified criteria
* @param expectedHeaders - Headers to match against
* @param resolver - Response resolver to execute if headers match
*/
export function withHeaders(
expectedHeaders: Record<string, string | RegExp | ((value: string) => boolean)>,
resolver: HttpResponseResolver,
): HttpResponseResolver {
return (args) => {
const { request } = args;
const { headers } = request;
const mismatches: Record<
string,
{ actual: string | null; expected: string | RegExp | ((value: string) => boolean) }
> = {};
for (const [key, expectedValue] of Object.entries(expectedHeaders)) {
const actualValue = headers.get(key);
if (actualValue === null) {
mismatches[key] = { actual: null, expected: expectedValue };
continue;
}
if (typeof expectedValue === "function") {
if (!expectedValue(actualValue)) {
mismatches[key] = { actual: actualValue, expected: expectedValue };
}
} else if (expectedValue instanceof RegExp) {
if (!expectedValue.test(actualValue)) {
mismatches[key] = { actual: actualValue, expected: expectedValue };
}
} else if (expectedValue !== actualValue) {
mismatches[key] = { actual: actualValue, expected: expectedValue };
}
}
if (Object.keys(mismatches).length > 0) {
const formattedMismatches = formatHeaderMismatches(mismatches);
console.error("Header mismatch:", formattedMismatches);
return passthrough();
}
return resolver(args);
};
}
function formatHeaderMismatches(
mismatches: Record<string, { actual: string | null; expected: string | RegExp | ((value: string) => boolean) }>,
): Record<string, { actual: string | null; expected: string }> {
const formatted: Record<string, { actual: string | null; expected: string }> = {};
for (const [key, { actual, expected }] of Object.entries(mismatches)) {
formatted[key] = {
actual,
expected:
expected instanceof RegExp
? expected.toString()
: typeof expected === "function"
? "[Function]"
: expected,
};
}
return formatted;
}

View File

@@ -0,0 +1,158 @@
import { type HttpResponseResolver, passthrough } from "msw";
import { fromJson, toJson } from "../../src/core/json";
/**
* Creates a request matcher that validates if the request JSON body exactly matches the expected object
* @param expectedBody - The exact body object to match against
* @param resolver - Response resolver to execute if body matches
*/
export function withJson(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver {
return async (args) => {
const { request } = args;
let clonedRequest: Request;
let bodyText: string | undefined;
let actualBody: unknown;
try {
clonedRequest = request.clone();
bodyText = await clonedRequest.text();
if (bodyText === "") {
console.error("Request body is empty, expected a JSON object.");
return passthrough();
}
actualBody = fromJson(bodyText);
} catch (error) {
console.error(`Error processing request body:\n\tError: ${error}\n\tBody: ${bodyText}`);
return passthrough();
}
const mismatches = findMismatches(actualBody, expectedBody);
if (Object.keys(mismatches).filter((key) => !key.startsWith("pagination.")).length > 0) {
console.error("JSON body mismatch:", toJson(mismatches, undefined, 2));
return passthrough();
}
return resolver(args);
};
}
function findMismatches(actual: any, expected: any): Record<string, { actual: any; expected: any }> {
const mismatches: Record<string, { actual: any; expected: any }> = {};
if (typeof actual !== typeof expected) {
if (areEquivalent(actual, expected)) {
return {};
}
return { value: { actual, expected } };
}
if (typeof actual !== "object" || actual === null || expected === null) {
if (actual !== expected) {
if (areEquivalent(actual, expected)) {
return {};
}
return { value: { actual, expected } };
}
return {};
}
if (Array.isArray(actual) && Array.isArray(expected)) {
if (actual.length !== expected.length) {
return { length: { actual: actual.length, expected: expected.length } };
}
const arrayMismatches: Record<string, { actual: any; expected: any }> = {};
for (let i = 0; i < actual.length; i++) {
const itemMismatches = findMismatches(actual[i], expected[i]);
if (Object.keys(itemMismatches).length > 0) {
for (const [mismatchKey, mismatchValue] of Object.entries(itemMismatches)) {
arrayMismatches[`[${i}]${mismatchKey === "value" ? "" : `.${mismatchKey}`}`] = mismatchValue;
}
}
}
return arrayMismatches;
}
const actualKeys = Object.keys(actual);
const expectedKeys = Object.keys(expected);
const allKeys = new Set([...actualKeys, ...expectedKeys]);
for (const key of allKeys) {
if (!expectedKeys.includes(key)) {
if (actual[key] === undefined) {
continue; // Skip undefined values in actual
}
mismatches[key] = { actual: actual[key], expected: undefined };
} else if (!actualKeys.includes(key)) {
if (expected[key] === undefined) {
continue; // Skip undefined values in expected
}
mismatches[key] = { actual: undefined, expected: expected[key] };
} else if (
typeof actual[key] === "object" &&
actual[key] !== null &&
typeof expected[key] === "object" &&
expected[key] !== null
) {
const nestedMismatches = findMismatches(actual[key], expected[key]);
if (Object.keys(nestedMismatches).length > 0) {
for (const [nestedKey, nestedValue] of Object.entries(nestedMismatches)) {
mismatches[`${key}${nestedKey === "value" ? "" : `.${nestedKey}`}`] = nestedValue;
}
}
} else if (actual[key] !== expected[key]) {
if (areEquivalent(actual[key], expected[key])) {
continue;
}
mismatches[key] = { actual: actual[key], expected: expected[key] };
}
}
return mismatches;
}
function areEquivalent(actual: unknown, expected: unknown): boolean {
if (actual === expected) {
return true;
}
if (isEquivalentBigInt(actual, expected)) {
return true;
}
if (isEquivalentDatetime(actual, expected)) {
return true;
}
return false;
}
function isEquivalentBigInt(actual: unknown, expected: unknown) {
if (typeof actual === "number") {
actual = BigInt(actual);
}
if (typeof expected === "number") {
expected = BigInt(expected);
}
if (typeof actual === "bigint" && typeof expected === "bigint") {
return actual === expected;
}
return false;
}
function isEquivalentDatetime(str1: unknown, str2: unknown): boolean {
if (typeof str1 !== "string" || typeof str2 !== "string") {
return false;
}
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
if (!isoDatePattern.test(str1) || !isoDatePattern.test(str2)) {
return false;
}
try {
const date1 = new Date(str1).getTime();
const date2 = new Date(str2).getTime();
return date1 === date2;
} catch {
return false;
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": null,
"rootDir": "..",
"baseUrl": "..",
"types": ["vitest/globals"]
},
"include": ["../src", "../tests"],
"exclude": []
}

View 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);
}
});
});

View 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,
});
});
});
});

View 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");
});
});
});

View 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",
);
});
});

View 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"}');
});
});

View 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",
},
});
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View File

@@ -0,0 +1 @@
This is a test file!

View 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/");
});
});
});

View 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");
});
});
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
// This file was auto-generated by Fern from our API Definition.
import * as Skyvern from "../../src/api/index";
import { SkyvernClient } from "../../src/Client";
import { mockServerPool } from "../mock-server/MockServerPool";
describe("Scripts", () => {
test("runScript (1)", async () => {
const server = mockServerPool.createServer();
const client = new SkyvernClient({ xApiKey: "test", apiKey: "test", environment: server.baseUrl });
const rawResponseBody = { key: "value" };
server
.mockEndpoint()
.post("/v1/scripts/s_abc123/run")
.respondWith()
.statusCode(200)
.jsonBody(rawResponseBody)
.build();
const response = await client.scripts.runScript("s_abc123");
expect(response).toEqual({
key: "value",
});
});
test("runScript (2)", async () => {
const server = mockServerPool.createServer();
const client = new SkyvernClient({ xApiKey: "test", apiKey: "test", environment: server.baseUrl });
const rawResponseBody = { key: "value" };
server
.mockEndpoint()
.post("/v1/scripts/script_id/run")
.respondWith()
.statusCode(422)
.jsonBody(rawResponseBody)
.build();
await expect(async () => {
return await client.scripts.runScript("script_id");
}).rejects.toThrow(Skyvern.UnprocessableEntityError);
});
});