feat: add high-level Capsule lifecycle API

This commit is contained in:
Tasnim Kabir Sadik
2026-05-14 22:51:01 +06:00
parent 52282618bd
commit 8fb9753fde
8 changed files with 579 additions and 2 deletions

207
tests/capsule.test.ts Normal file
View File

@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Capsule, Sandbox } from "../src/capsule.js";
interface CapturedRequest {
url: string;
init: RequestInit;
}
function setupFetch(responses: Response[]) {
const calls: CapturedRequest[] = [];
const fetchMock = vi.fn(
async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
const response = responses.shift();
if (!response) throw new Error("No mock response configured");
return response;
},
);
vi.stubGlobal("fetch", fetchMock);
return { calls, fetchMock };
}
function capsuleResponse(id: string, status = "running") {
return Response.json({
id,
memory_mb: 512,
status,
template: "minimal",
timeout_sec: 0,
vcpus: 1,
});
}
describe("Capsule", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("wraps an existing capsule without fetching it", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = Capsule.connect("cap_1", {
apiKey: "api-key",
baseUrl: "https://api.example.com",
});
expect(capsule.id).toBe("cap_1");
expect(fetchMock).not.toHaveBeenCalled();
expect(capsule).toBeInstanceOf(Capsule);
});
it("creates a capsule from defaulted options", async () => {
const { calls } = setupFetch([capsuleResponse("cap_created")]);
const capsule = await Capsule.create({
apiKey: "api-key",
baseUrl: "https://api.example.com/",
});
expect(capsule.id).toBe("cap_created");
expect(calls.at(-1)?.url).toBe("https://api.example.com/v1/capsules");
expect(calls.at(-1)?.init.method).toBe("POST");
expect(calls.at(-1)?.init.body).toBe(
JSON.stringify({
memory_mb: 512,
template: "minimal",
timeout_sec: 0,
vcpus: 1,
}),
);
});
it("creates a capsule with a template overload and explicit resources", async () => {
const { calls } = setupFetch([capsuleResponse("cap_custom")]);
const capsule = await Capsule.create("node", {
apiKey: "api-key",
baseUrl: "https://api.example.com",
memory_mb: 1024,
timeout_sec: 60,
vcpus: 2,
});
expect(capsule.id).toBe("cap_custom");
expect(calls.at(-1)?.init.body).toBe(
JSON.stringify({
memory_mb: 1024,
template: "node",
timeout_sec: 60,
vcpus: 2,
}),
);
});
it("throws when create does not return an id", async () => {
setupFetch([Response.json({ status: "running" })]);
await expect(
Capsule.create({ baseUrl: "https://api.example.com" }),
).rejects.toThrow("Created capsule response did not include an id");
});
it("maps instance lifecycle methods to the low-level client", async () => {
const { calls } = setupFetch([
capsuleResponse("cap_1"),
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
new Response(null, { status: 204 }),
capsuleResponse("cap_1", "paused"),
capsuleResponse("cap_1", "running"),
new Response(null, { status: 204 }),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.getInfo()).resolves.toMatchObject({ id: "cap_1" });
await expect(capsule.getMetrics({ range: "10m" })).resolves.toMatchObject({
range: "10m",
});
await expect(capsule.ping()).resolves.toBeUndefined();
await expect(capsule.pause()).resolves.toMatchObject({ status: "paused" });
await expect(capsule.resume()).resolves.toMatchObject({
status: "running",
});
await expect(capsule.destroy()).resolves.toBeUndefined();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"GET https://api.example.com/v1/capsules/cap_1",
"GET https://api.example.com/v1/capsules/cap_1/metrics?range=10m",
"POST https://api.example.com/v1/capsules/cap_1/ping",
"POST https://api.example.com/v1/capsules/cap_1/pause",
"POST https://api.example.com/v1/capsules/cap_1/resume",
"DELETE https://api.example.com/v1/capsules/cap_1",
]);
});
it("waits until the capsule is running", async () => {
vi.useFakeTimers();
const { calls } = setupFetch([
capsuleResponse("cap_1", "pending"),
capsuleResponse("cap_1", "starting"),
capsuleResponse("cap_1", "running"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 1_000 });
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(100);
await expect(ready).resolves.toMatchObject({ status: "running" });
expect(calls).toHaveLength(3);
});
it("fails waitForReady on terminal capsule states", async () => {
setupFetch([capsuleResponse("cap_1", "error")]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.waitForReady()).rejects.toThrow(
'Capsule cap_1 reached terminal status "error"',
);
});
it("times out while waiting for readiness", async () => {
vi.useFakeTimers();
setupFetch([
capsuleResponse("cap_1", "starting"),
capsuleResponse("cap_1", "starting"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 150 });
const assertion = expect(ready).rejects.toThrow(
"Timed out waiting for capsule cap_1 to become running",
);
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(50);
await assertion;
});
it("does not mutate the remote capsule when closed or disposed", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
capsule.close();
await capsule[Symbol.asyncDispose]();
expect(fetchMock).not.toHaveBeenCalled();
});
it("exports Sandbox as a deprecated Capsule alias", () => {
expect(Sandbox).toBe(Capsule);
});
});

View File

@ -31,7 +31,7 @@ describe("resolveConfig", () => {
vi.stubEnv("WRENN_TOKEN", undefined);
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
expect(resolveConfig()).toEqual({ baseUrl: "https://api.wrenn.dev" });
expect(resolveConfig()).toEqual({ baseUrl: "https://app.wrenn.dev/api" });
});
it("prefers explicit options over environment variables", () => {

View File

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
import { DEFAULT_BASE_URL } from "../../src/config.js";
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
const apiKey = process.env.WRENN_API_KEY;
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
const describeWithApiKey = apiKey ? describe : describe.skip;
const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions;
describeWithApiKey("Capsule live integration", () => {
it(
"creates, waits for, inspects, pings, and destroys a live capsule",
async () => {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
expect(capsule.id).toBeTypeOf("string");
expect(capsule.id.length).toBeGreaterThan(0);
const ready = await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
expect(ready).toMatchObject({ id: capsule.id, status: "running" });
const connected = Capsule.connect(capsule.id, clientOpts);
const info = await connected.getInfo();
expect(info.id).toBe(capsule.id);
await expect(connected.ping()).resolves.toBeUndefined();
const metrics = await connected.getMetrics({ range: "10m" });
expect(metrics).toBeTypeOf("object");
await connected.close();
await connected[Symbol.asyncDispose]();
await Capsule.destroy(capsule.id, clientOpts);
capsule = undefined;
} finally {
if (capsule) {
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs + 30_000,
);
it(
"pauses and resumes a live capsule through high-level methods",
async () => {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
const paused = await capsule.pause();
expect(paused).toMatchObject({ id: capsule.id });
expect(paused.status).toBe("paused");
const resumed = await capsule.resume({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
wait: true,
});
expect(resumed).toMatchObject({ id: capsule.id, status: "running" });
} finally {
if (capsule) {
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs * 2 + 30_000,
);
});