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://api.wrenn.dev" }); }); 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((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((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(); }); });