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:
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user