208 lines
5.8 KiB
TypeScript
208 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|