Files
js-sdk/tests/foundation.test.ts
2026-05-14 22:51:01 +06:00

288 lines
7.6 KiB
TypeScript

import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { HttpClient } from "../src/_shared/http.js";
import { WsConnection } from "../src/_shared/websocket.js";
import { resolveConfig } from "../src/config.js";
import {
AuthenticationError,
BadRequestError,
ConflictError,
ForbiddenError,
HostHasCapsulesError,
NotFoundError,
PayloadTooLargeError,
ServerError,
TimeoutError,
throwErrorFromResponse,
WrennError,
} from "../src/exceptions.js";
describe("resolveConfig", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("uses defaults when no options or environment variables are set", () => {
vi.stubEnv("WRENN_BASE_URL", undefined);
vi.stubEnv("WRENN_API_KEY", undefined);
vi.stubEnv("WRENN_TOKEN", undefined);
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
expect(resolveConfig()).toEqual({ baseUrl: "https://app.wrenn.dev/api" });
});
it("prefers explicit options over environment variables", () => {
vi.stubEnv("WRENN_BASE_URL", "https://env.example.com");
vi.stubEnv("WRENN_API_KEY", "env-api-key");
vi.stubEnv("WRENN_TOKEN", "env-token");
vi.stubEnv("WRENN_HOST_TOKEN", "env-host-token");
expect(
resolveConfig({
baseUrl: "https://opts.example.com",
apiKey: "opts-api-key",
token: "opts-token",
hostToken: "opts-host-token",
}),
).toEqual({
baseUrl: "https://opts.example.com",
apiKey: "opts-api-key",
token: "opts-token",
hostToken: "opts-host-token",
});
});
});
describe("throwErrorFromResponse", () => {
it.each([
[400, BadRequestError],
[401, AuthenticationError],
[403, ForbiddenError],
[404, NotFoundError],
[408, TimeoutError],
[409, ConflictError],
[413, PayloadTooLargeError],
[500, ServerError],
])("maps HTTP %i responses", async (status, ErrorClass) => {
const response = Response.json(
{ error: { code: "test_error", message: "Test failure" } },
{ status, statusText: "Failed" },
);
await expect(throwErrorFromResponse(response)).rejects.toMatchObject({
name: ErrorClass.name,
statusCode: status,
code: "test_error",
message: "Test failure",
});
});
it("maps host conflict responses with capsule IDs", async () => {
const response = Response.json(
{
error: {
code: "host_has_capsules",
message: "Host has capsules",
sandbox_ids: ["cap_1", "cap_2"],
},
},
{ status: 409, statusText: "Conflict" },
);
const error = throwErrorFromResponse(response);
await expect(error).rejects.toBeInstanceOf(HostHasCapsulesError);
await expect(error).rejects.toMatchObject({
name: "HostHasCapsulesError",
statusCode: 409,
sandboxIds: ["cap_1", "cap_2"],
});
});
it("falls back to WrennError for non-JSON error bodies", async () => {
const response = new Response("not json", {
status: 418,
statusText: "I'm a teapot",
});
await expect(throwErrorFromResponse(response)).rejects.toBeInstanceOf(
WrennError,
);
});
});
describe("HttpClient", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("sends default auth headers and query params", async () => {
const fetchMock = vi.fn(async () => Response.json({ ok: true }));
vi.stubGlobal("fetch", fetchMock);
const client = new HttpClient({
baseUrl: "https://api.example.com/",
apiKey: "api-key",
token: "jwt-token",
hostToken: "host-token",
});
await expect(
client.get<{ ok: boolean }>("/v1/test", {
params: { a: "one", b: 2, c: true, skipped: undefined },
}),
).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/v1/test?a=one&b=2&c=true",
expect.objectContaining({
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer jwt-token",
"X-API-Key": "api-key",
"X-Host-Token": "host-token",
},
}),
);
});
it("omits content type for bodyless requests", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
await client.delete("/v1/test");
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/v1/test",
expect.objectContaining({
headers: { Accept: "application/json" },
method: "DELETE",
}),
);
});
it("maps unsuccessful responses to SDK errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json(
{ error: { code: "missing", message: "Not found" } },
{ status: 404 },
),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
await expect(client.get("/v1/missing")).rejects.toBeInstanceOf(
NotFoundError,
);
});
it("throws TimeoutError when timeoutMs aborts a request", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const request = client.get("/v1/slow", { timeoutMs: 100 });
const assertion = expect(request).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(100);
await assertion;
});
it("applies timeoutMs to multipart uploads", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const upload = client.upload("/v1/upload", new FormData(), {
timeoutMs: 100,
});
const assertion = expect(upload).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(100);
await assertion;
});
});
describe("WsConnection", () => {
it("connects, sends JSON messages, and receives parsed messages", async () => {
const server = new WebSocketServer({ port: 0 });
const messages: unknown[] = [];
const receivedByServer = new Promise<unknown>((resolve) => {
server.on("connection", (socket, request) => {
expect(request.headers["x-api-key"]).toBe("api-key");
expect(request.headers["x-host-token"]).toBe("host-token");
socket.on("message", (raw) => resolve(JSON.parse(raw.toString())));
socket.send(JSON.stringify({ type: "ready" }));
});
});
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address() as AddressInfo;
const connection = await WsConnection.connect({
baseUrl: `http://127.0.0.1:${address.port}`,
path: "/stream",
apiKey: "api-key",
hostToken: "host-token",
onMessage: (message) => messages.push(message),
});
connection.send({ type: "start" });
await expect(receivedByServer).resolves.toEqual({ type: "start" });
expect(messages).toEqual([{ type: "ready" }]);
connection.close();
server.close();
});
it("rejects with TimeoutError if the connection does not open in time", async () => {
vi.useFakeTimers();
const server = new WebSocketServer({ noServer: true });
const connection = WsConnection.connect({
baseUrl: "http://127.0.0.1:9",
path: "/stream",
timeoutMs: 100,
onMessage: () => undefined,
});
const assertion = expect(connection).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(100);
await assertion;
server.close();
vi.useRealTimers();
});
});