288 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|