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
This commit is contained in:
Tasnim Kabir Sadik
2026-05-16 19:14:55 +06:00
parent a69118aa2d
commit 349b230913
18 changed files with 1249 additions and 52 deletions

View File

@ -109,8 +109,8 @@ describe("Capsule", () => {
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"),
capsuleResponse("cap_1", "pausing"),
capsuleResponse("cap_1", "resuming"),
new Response(null, { status: 204 }),
]);
const capsule = new Capsule("cap_1", {
@ -122,9 +122,9 @@ describe("Capsule", () => {
range: "10m",
});
await expect(capsule.ping()).resolves.toBeUndefined();
await expect(capsule.pause()).resolves.toMatchObject({ status: "paused" });
await expect(capsule.pause()).resolves.toMatchObject({ status: "pausing" });
await expect(capsule.resume()).resolves.toMatchObject({
status: "running",
status: "resuming",
});
await expect(capsule.destroy()).resolves.toBeUndefined();
@ -157,14 +157,19 @@ describe("Capsule", () => {
expect(calls).toHaveLength(3);
});
it("fails waitForReady on terminal capsule states", async () => {
setupFetch([capsuleResponse("cap_1", "error")]);
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 "error"',
`Capsule cap_1 reached terminal status "${status}"`,
);
});
@ -188,10 +193,64 @@ describe("Capsule", () => {
await assertion;
});
it("does not mutate the remote capsule when closed or disposed", async () => {
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 = new Capsule("cap_1", {
const capsule = Capsule.connect("cap_1", {
baseUrl: "https://api.example.com",
});
@ -201,6 +260,43 @@ describe("Capsule", () => {
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);
});

View File

@ -1,9 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Capsule } from "../src/capsule.js";
import { CodeInterpreter } from "../src/code-interpreter/index.js";
describe("CodeInterpreter", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("creates a capsule with the jupyter template by default", async () => {
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
new Capsule("cap_1", {
@ -19,7 +23,6 @@ describe("CodeInterpreter", () => {
expect(create).toHaveBeenCalledWith("jupyter", {
baseUrl: "https://api.example.com",
});
create.mockRestore();
});
it("connects to an existing capsule", () => {
@ -53,4 +56,34 @@ describe("CodeInterpreter", () => {
timeoutSec: 30,
});
});
it("returns stderr and non-zero exit code on failure", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 1,
stderr: "SyntaxError: invalid syntax\n",
stdout: "",
});
const interpreter = new CodeInterpreter(capsule);
await expect(interpreter.notebook.execCell("invalid(")).resolves.toEqual({
exitCode: 1,
stderr: "SyntaxError: invalid syntax\n",
stdout: "",
});
});
it("disposes the wrapped capsule", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const dispose = vi.spyOn(capsule, Symbol.asyncDispose).mockResolvedValue();
const interpreter = new CodeInterpreter(capsule);
await interpreter[Symbol.asyncDispose]();
expect(dispose).toHaveBeenCalledOnce();
});
});

View File

@ -71,6 +71,51 @@ describe("CommandManager", () => {
});
});
it("connects to an existing background process stream", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const mockConnection = {
close: vi.fn(),
get isClosed() {
return false;
},
send: vi.fn(),
};
const connectProcess = vi
.spyOn(capsule.client.capsules, "connectProcess")
.mockResolvedValue(mockConnection as never);
const result = await capsule.commands.streamProcess("123");
expect(connectProcess).toHaveBeenCalledWith("cap_1", "123", {
onMessage: expect.any(Function),
});
expect(result).toBe(mockConnection);
});
it("passes timeout to streamProcess", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const connectProcess = vi
.spyOn(capsule.client.capsules, "connectProcess")
.mockResolvedValue({
close: vi.fn(),
get isClosed() {
return false;
},
send: vi.fn(),
} as never);
await capsule.commands.streamProcess("worker", { timeoutMs: 5000 });
expect(connectProcess).toHaveBeenCalledWith("cap_1", "worker", {
onMessage: expect.any(Function),
timeoutMs: 5000,
});
});
it("streams command events over the exec WebSocket", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",

View File

@ -3,6 +3,7 @@ import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { AsyncQueue } from "../src/_shared/async-queue.js";
import { HttpClient } from "../src/_shared/http.js";
import { WsConnection } from "../src/_shared/websocket.js";
import { resolveConfig } from "../src/config.js";
@ -233,6 +234,57 @@ describe("HttpClient", () => {
await assertion;
});
it("downloads binary response bodies as ReadableStream", async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body, { status: 200 })),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const stream = await client.download("/v1/file", { path: "/a.txt" });
expect(stream).toBeInstanceOf(ReadableStream);
const reader = stream.getReader();
const { value } = await reader.read();
expect(value).toEqual(new Uint8Array([1, 2, 3]));
});
it("handles content-length zero as empty response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(null, {
status: 200,
headers: { "content-length": "0" },
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const result = await client.get("/v1/empty");
expect(result).toBeUndefined();
});
it("handles empty text body as undefined", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("", { status: 200 })),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const result = await client.get("/v1/blank");
expect(result).toBeUndefined();
});
});
describe("WsConnection", () => {
@ -263,7 +315,8 @@ describe("WsConnection", () => {
await expect(receivedByServer).resolves.toEqual({ type: "start" });
expect(messages).toEqual([{ type: "ready" }]);
connection.close();
await connection[Symbol.asyncDispose]();
expect(connection.isClosed).toBe(true);
server.close();
});
@ -285,3 +338,37 @@ describe("WsConnection", () => {
vi.useRealTimers();
});
});
describe("AsyncQueue", () => {
it("rejects waiting consumers when failed", async () => {
const queue = new AsyncQueue<string>();
const pending = queue.next();
queue.fail(new Error("socket died"));
await expect(pending).rejects.toThrow("socket died");
await expect(queue.next()).rejects.toThrow("socket died");
});
it("ends the queue from return and resolves as done", async () => {
const queue = new AsyncQueue<string>();
const result = await queue.return();
expect(result).toEqual({ done: true, value: undefined });
await expect(queue.next()).resolves.toEqual({
done: true,
value: undefined,
});
});
it("propagates error through throw and rejects future reads", async () => {
const queue = new AsyncQueue<string>();
const error = new Error("consumer threw");
const result = queue.throw(error);
await expect(result).rejects.toThrow("consumer threw");
await expect(queue.next()).rejects.toThrow("consumer threw");
});
});

View File

@ -71,4 +71,36 @@ describe("Git", () => {
"At least one file is required",
);
});
it("checks out an existing branch without creating it", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stdout: "",
});
await capsule.git.checkout("main");
expect(exec).toHaveBeenCalledWith("git", {
args: ["checkout", "main"],
});
});
it("normalizes a single string file argument in add", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stdout: "",
});
await capsule.git.add("src/a.ts");
expect(exec).toHaveBeenCalledWith("git", {
args: ["add", "src/a.ts"],
});
});
});

View File

@ -90,4 +90,36 @@ describeWithApiKey("Capsule live integration", () => {
},
waitTimeoutMs * 2 + 30_000,
);
it(
"destroys an owned live capsule when async disposed",
async () => {
let capsuleId: string | undefined;
try {
const capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
capsuleId = capsule.id;
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
await capsule[Symbol.asyncDispose]();
await expect(
Capsule.connect(capsuleId, clientOpts).getInfo(),
).resolves.toMatchObject({ id: capsuleId, status: "stopped" });
capsuleId = undefined;
} finally {
if (capsuleId) {
await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs + 30_000,
);
});

View File

@ -200,4 +200,33 @@ describeWithApiKey("Higher-level abstractions live integration", () => {
},
testTimeoutMs,
);
it(
"destroys an owned code interpreter capsule when async disposed",
async () => {
let capsuleId: string | undefined;
try {
const interpreter = await createCodeInterpreterWithRetry();
capsuleId = interpreter.capsule.id;
await interpreter.capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
await interpreter[Symbol.asyncDispose]();
await expect(
Capsule.connect(capsuleId, clientOpts).getInfo(),
).resolves.toMatchObject({ id: capsuleId, status: "stopped" });
capsuleId = undefined;
} finally {
if (capsuleId) {
await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined);
}
}
},
testTimeoutMs,
);
});

View File

@ -9,11 +9,12 @@ describe("PtyManager", () => {
});
const sent: unknown[] = [];
let onMessage: ((message: unknown) => void) | undefined;
const close = vi.fn();
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
async (_id, opts) => {
onMessage = opts.onMessage;
return {
close: vi.fn(),
close,
get isClosed() {
return false;
},
@ -49,6 +50,9 @@ describe("PtyManager", () => {
type: "output",
},
});
await session[Symbol.asyncDispose]();
expect(close).toHaveBeenCalledOnce();
});
it("connects to an existing PTY tag", async () => {
@ -68,4 +72,60 @@ describe("PtyManager", () => {
expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]);
});
it("closes the connection when close() is called directly", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const close = vi.fn();
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
close,
get isClosed() {
return false;
},
send: vi.fn(),
} as never);
const session = await capsule.pty.start();
session.close();
expect(close).toHaveBeenCalledOnce();
});
it("sends all PtyStartOptions fields in the start message", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const sent: unknown[] = [];
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
close: vi.fn(),
get isClosed() {
return false;
},
send: (message: unknown) => sent.push(message),
} as never);
await capsule.pty.start({
cmd: "/bin/bash",
args: ["--login"],
cols: 120,
rows: 40,
envs: { TERM: "xterm-256color" },
cwd: "/home/user",
user: "user",
});
expect(sent).toEqual([
{
type: "start",
cmd: "/bin/bash",
args: ["--login"],
cols: 120,
rows: 40,
envs: { TERM: "xterm-256color" },
cwd: "/home/user",
user: "user",
},
]);
});
});