- 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
304 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|