feat: add SDK foundation layer
Implement config resolution, typed errors, HTTP and WebSocket transport helpers, and timeout handling for the SDK foundation. Add unit and local integration tests covering the SDK foundation behaviour and align package exports with the tsup output.
This commit is contained in:
287
tests/foundation.test.ts
Normal file
287
tests/foundation.test.ts
Normal file
@ -0,0 +1,287 @@
|
||||
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<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user