Files
js-sdk/tests/capsule.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

304 lines
8.4 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", "pausing"),
capsuleResponse("cap_1", "resuming"),
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: "pausing" });
await expect(capsule.resume()).resolves.toMatchObject({
status: "resuming",
});
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.each([
"error",
"missing",
"stopping",
"stopped",
])("fails waitForReady on terminal capsule state %s", async (status) => {
setupFetch([capsuleResponse("cap_1", status)]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.waitForReady()).rejects.toThrow(
`Capsule cap_1 reached terminal status "${status}"`,
);
});
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("resumes and waits until the capsule is running", async () => {
vi.useFakeTimers();
const { calls } = setupFetch([
capsuleResponse("cap_1", "resuming"),
capsuleResponse("cap_1", "running"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.resume({
wait: true,
intervalMs: 100,
timeoutMs: 1_000,
});
await vi.advanceTimersByTimeAsync(100);
await expect(ready).resolves.toMatchObject({ status: "running" });
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"POST https://api.example.com/v1/capsules/cap_1/resume",
"GET https://api.example.com/v1/capsules/cap_1",
]);
});
it("aborts waitForReady when the signal is already aborted", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const controller = new AbortController();
controller.abort();
await expect(
capsule.waitForReady({ signal: controller.signal }),
).rejects.toThrow("Operation aborted");
});
it("aborts waitForReady when the signal fires during polling", async () => {
vi.useFakeTimers();
setupFetch([capsuleResponse("cap_1", "starting")]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const controller = new AbortController();
const ready = capsule.waitForReady({
intervalMs: 100,
timeoutMs: 5_000,
signal: controller.signal,
});
controller.abort();
await expect(ready).rejects.toThrow("Operation aborted");
});
it("does not mutate the remote capsule when connected capsules are closed or disposed", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = Capsule.connect("cap_1", {
baseUrl: "https://api.example.com",
});
capsule.close();
await capsule[Symbol.asyncDispose]();
expect(fetchMock).not.toHaveBeenCalled();
});
it("destroys created capsules when async disposed", async () => {
const { calls } = setupFetch([
capsuleResponse("cap_created"),
new Response(null, { status: 204 }),
]);
const capsule = await Capsule.create({
baseUrl: "https://api.example.com",
});
await capsule[Symbol.asyncDispose]();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"POST https://api.example.com/v1/capsules",
"DELETE https://api.example.com/v1/capsules/cap_created",
]);
});
it("does not destroy an already destroyed owned capsule twice", async () => {
const { calls } = setupFetch([
capsuleResponse("cap_created"),
new Response(null, { status: 204 }),
]);
const capsule = await Capsule.create({
baseUrl: "https://api.example.com",
});
await capsule.destroy();
await capsule[Symbol.asyncDispose]();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"POST https://api.example.com/v1/capsules",
"DELETE https://api.example.com/v1/capsules/cap_created",
]);
});
it("exports Sandbox as a deprecated Capsule alias", () => {
expect(Sandbox).toBe(Capsule);
});
});