Files
js-sdk/tests/foundation.test.ts
Tasnim Kabir Sadik 349b230913 feat: align SDK with updated OpenAPI spec and add missing unit tests
- Update generated types from new openapi.yaml (capsule stats, usage,
  metrics, pause/resume lifecycle, host/channel management, auth flow)
- Add Capsule pause/resume/ping/getMetrics lifecycle methods
- Add Capsule.waitForReady abort signal support
- Add PtyManager.connect and PtySession disposal
- Fix HttpClient empty-body response handling (content-length: 0)
- Add streamProcess() to CommandManager for background process streams
- Add integration tests for capsule lifecycle, git, and PTY features
- Add unit tests for AsyncQueue error paths, PtySession.close,
  Git.checkout without create, Git.add single string,
  Notebook.execCell error case, and PtyStartOptions fields
2026-05-16 19:14:55 +06:00

375 lines
10 KiB
TypeScript

import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { AsyncQueue } from "../src/_shared/async-queue.js";
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;
});
it("downloads binary response bodies as ReadableStream", async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body, { status: 200 })),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const stream = await client.download("/v1/file", { path: "/a.txt" });
expect(stream).toBeInstanceOf(ReadableStream);
const reader = stream.getReader();
const { value } = await reader.read();
expect(value).toEqual(new Uint8Array([1, 2, 3]));
});
it("handles content-length zero as empty response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(null, {
status: 200,
headers: { "content-length": "0" },
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const result = await client.get("/v1/empty");
expect(result).toBeUndefined();
});
it("handles empty text body as undefined", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("", { status: 200 })),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const result = await client.get("/v1/blank");
expect(result).toBeUndefined();
});
});
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" }]);
await connection[Symbol.asyncDispose]();
expect(connection.isClosed).toBe(true);
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();
});
});
describe("AsyncQueue", () => {
it("rejects waiting consumers when failed", async () => {
const queue = new AsyncQueue<string>();
const pending = queue.next();
queue.fail(new Error("socket died"));
await expect(pending).rejects.toThrow("socket died");
await expect(queue.next()).rejects.toThrow("socket died");
});
it("ends the queue from return and resolves as done", async () => {
const queue = new AsyncQueue<string>();
const result = await queue.return();
expect(result).toEqual({ done: true, value: undefined });
await expect(queue.next()).resolves.toEqual({
done: true,
value: undefined,
});
});
it("propagates error through throw and rejects future reads", async () => {
const queue = new AsyncQueue<string>();
const error = new Error("consumer threw");
const result = queue.throw(error);
await expect(result).rejects.toThrow("consumer threw");
await expect(queue.next()).rejects.toThrow("consumer threw");
});
});