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); }); });