- 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
375 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|