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,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!