feat: add high-level capsule feature modules
This commit is contained in:
@ -323,6 +323,9 @@ describe("WrennClient", () => {
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/write",
|
||||
});
|
||||
expect(calls.at(-1)?.init.body).toBeInstanceOf(FormData);
|
||||
expect((calls.at(-1)?.init.body as FormData).get("file")).toBeInstanceOf(
|
||||
Blob,
|
||||
);
|
||||
await client.files.download("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
|
||||
116
tests/commands.test.ts
Normal file
116
tests/commands.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Capsule } from "../src/capsule.js";
|
||||
|
||||
describe("CommandManager", () => {
|
||||
it("executes foreground commands through the capsule client", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const exec = vi.spyOn(capsule.client.capsules, "exec").mockResolvedValue({
|
||||
exit_code: 0,
|
||||
stdout: "ok\n",
|
||||
});
|
||||
|
||||
await expect(
|
||||
capsule.commands.exec("node", {
|
||||
args: ["--version"],
|
||||
timeoutSec: 5,
|
||||
}),
|
||||
).resolves.toMatchObject({ stdout: "ok\n" });
|
||||
|
||||
expect(exec).toHaveBeenCalledWith("cap_1", {
|
||||
args: ["--version"],
|
||||
background: false,
|
||||
cmd: "node",
|
||||
timeout_sec: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("starts, lists, and kills background processes", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const exec = vi.spyOn(capsule.client.capsules, "exec").mockResolvedValue({
|
||||
pid: 123,
|
||||
tag: "worker",
|
||||
});
|
||||
const listProcesses = vi
|
||||
.spyOn(capsule.client.capsules, "listProcesses")
|
||||
.mockResolvedValue({ processes: [{ pid: 123, tag: "worker" }] });
|
||||
const killProcess = vi
|
||||
.spyOn(capsule.client.capsules, "killProcess")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
capsule.commands.start("sleep", {
|
||||
args: ["60"],
|
||||
cwd: "/tmp",
|
||||
envs: { A: "1" },
|
||||
tag: "worker",
|
||||
}),
|
||||
).resolves.toMatchObject({ tag: "worker" });
|
||||
await expect(capsule.commands.list()).resolves.toMatchObject({
|
||||
processes: [{ tag: "worker" }],
|
||||
});
|
||||
await expect(
|
||||
capsule.commands.kill("worker", "SIGTERM"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(exec).toHaveBeenCalledWith("cap_1", {
|
||||
args: ["60"],
|
||||
background: true,
|
||||
cmd: "sleep",
|
||||
cwd: "/tmp",
|
||||
envs: { A: "1" },
|
||||
tag: "worker",
|
||||
timeout_sec: 30,
|
||||
});
|
||||
expect(listProcesses).toHaveBeenCalledWith("cap_1");
|
||||
expect(killProcess).toHaveBeenCalledWith("cap_1", "worker", {
|
||||
signal: "SIGTERM",
|
||||
});
|
||||
});
|
||||
|
||||
it("streams command events over the exec WebSocket", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const sent: unknown[] = [];
|
||||
let onMessage: ((message: unknown) => void) | undefined;
|
||||
vi.spyOn(capsule.client.capsules, "execStream").mockImplementation(
|
||||
async (_id, opts) => {
|
||||
onMessage = opts.onMessage;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
send: (message: unknown) => sent.push(message),
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const events = capsule.commands.stream("printf", { args: ["hello"] });
|
||||
const first = events.next();
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||
expect(sent).toEqual([{ args: ["hello"], cmd: "printf", type: "start" }]);
|
||||
|
||||
onMessage?.({ data: "hello", type: "stdout" });
|
||||
await expect(first).resolves.toEqual({
|
||||
done: false,
|
||||
value: { data: "hello", type: "stdout" },
|
||||
});
|
||||
|
||||
const done = events.next();
|
||||
onMessage?.({ exit_code: 0, type: "exit" });
|
||||
await expect(done).resolves.toEqual({
|
||||
done: false,
|
||||
value: { exit_code: 0, type: "exit" },
|
||||
});
|
||||
await expect(events.next()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
92
tests/files.test.ts
Normal file
92
tests/files.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Capsule } from "../src/capsule.js";
|
||||
|
||||
function streamFrom(text: string): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(Buffer.from(text));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("FileManager", () => {
|
||||
it("reads and writes files through the capsule file client", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const download = vi
|
||||
.spyOn(capsule.client.files, "download")
|
||||
.mockResolvedValue(streamFrom("hello"));
|
||||
const upload = vi
|
||||
.spyOn(capsule.client.files, "upload")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await expect(capsule.files.read("/tmp/a.txt")).resolves.toEqual(
|
||||
Buffer.from("hello"),
|
||||
);
|
||||
await expect(
|
||||
capsule.files.write("/tmp/a.txt", "hello"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(download).toHaveBeenCalledWith("cap_1", { path: "/tmp/a.txt" });
|
||||
expect(upload).toHaveBeenCalledWith("cap_1", {
|
||||
file: "hello",
|
||||
path: "/tmp/a.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps directory operations", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const list = vi.spyOn(capsule.client.files, "list").mockResolvedValue({
|
||||
entries: [{ name: "a.txt", path: "/tmp/a.txt", type: "file" }],
|
||||
});
|
||||
const mkdir = vi.spyOn(capsule.client.files, "mkdir").mockResolvedValue({
|
||||
entry: { path: "/tmp/new", type: "directory" },
|
||||
});
|
||||
const remove = vi
|
||||
.spyOn(capsule.client.files, "remove")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
capsule.files.list("/tmp", { depth: 2 }),
|
||||
).resolves.toMatchObject({
|
||||
entries: [{ name: "a.txt" }],
|
||||
});
|
||||
await expect(capsule.files.mkdir("/tmp/new")).resolves.toMatchObject({
|
||||
entry: { type: "directory" },
|
||||
});
|
||||
await expect(capsule.files.remove("/tmp/a.txt")).resolves.toBeUndefined();
|
||||
|
||||
expect(list).toHaveBeenCalledWith("cap_1", { depth: 2, path: "/tmp" });
|
||||
expect(mkdir).toHaveBeenCalledWith("cap_1", { path: "/tmp/new" });
|
||||
expect(remove).toHaveBeenCalledWith("cap_1", { path: "/tmp/a.txt" });
|
||||
});
|
||||
|
||||
it("streams downloads as Buffer chunks and uploads streaming content", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
vi.spyOn(capsule.client.files, "streamDownload").mockResolvedValue(
|
||||
streamFrom("hello"),
|
||||
);
|
||||
const streamUpload = vi
|
||||
.spyOn(capsule.client.files, "streamUpload")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of capsule.files.downloadStream("/tmp/a.txt")) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
await capsule.files.uploadStream("/tmp/a.txt", Buffer.from("hello"));
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe("hello");
|
||||
expect(streamUpload).toHaveBeenCalledWith("cap_1", {
|
||||
file: expect.any(Blob),
|
||||
path: "/tmp/a.txt",
|
||||
});
|
||||
});
|
||||
});
|
||||
180
tests/integration/capsule-features.integration.test.ts
Normal file
180
tests/integration/capsule-features.integration.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
|
||||
import type { CommandStreamEvent } from "../../src/commands.js";
|
||||
import { DEFAULT_BASE_URL } from "../../src/config.js";
|
||||
import type { PtyEvent } from "../../src/pty.js";
|
||||
|
||||
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
|
||||
const apiKey = process.env.WRENN_API_KEY;
|
||||
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
|
||||
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
|
||||
const testTimeoutMs = waitTimeoutMs + 45_000;
|
||||
const describeWithApiKey = apiKey ? describe : describe.skip;
|
||||
|
||||
const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions;
|
||||
|
||||
async function withLiveCapsule(
|
||||
fn: (capsule: Capsule) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let capsule: Capsule | undefined;
|
||||
try {
|
||||
capsule = await Capsule.create(template, {
|
||||
...clientOpts,
|
||||
timeout_sec: 120,
|
||||
});
|
||||
await capsule.waitForReady({
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: waitTimeoutMs,
|
||||
});
|
||||
await fn(capsule);
|
||||
} finally {
|
||||
if (capsule) {
|
||||
await capsule.destroy().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCommandEvents(
|
||||
events: AsyncGenerator<CommandStreamEvent>,
|
||||
): Promise<CommandStreamEvent[]> {
|
||||
const collected: CommandStreamEvent[] = [];
|
||||
for await (const event of events) {
|
||||
collected.push(event);
|
||||
if (event.type === "exit" || event.type === "error") break;
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
async function nextPtyEvent(
|
||||
events: AsyncIterableIterator<PtyEvent>,
|
||||
): Promise<PtyEvent> {
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error("Timed out waiting for PTY event")),
|
||||
15_000,
|
||||
);
|
||||
});
|
||||
const result = await Promise.race([events.next(), timeout]);
|
||||
if (result.done) throw new Error("PTY closed before next event");
|
||||
return result.value;
|
||||
}
|
||||
|
||||
describeWithApiKey("Phase 4 live integration", () => {
|
||||
it(
|
||||
"executes foreground, streaming, and background commands",
|
||||
async () => {
|
||||
await withLiveCapsule(async (capsule) => {
|
||||
const result = await capsule.commands.exec("printf", {
|
||||
args: ["phase4-command"],
|
||||
timeoutSec: 10,
|
||||
});
|
||||
expect(result.exit_code).toBe(0);
|
||||
expect(result.stdout).toContain("phase4-command");
|
||||
|
||||
const streamEvents = await collectCommandEvents(
|
||||
capsule.commands.stream("printf", { args: ["phase4-stream"] }),
|
||||
);
|
||||
expect(streamEvents.some((event) => event.type === "stdout")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(streamEvents.at(-1)).toMatchObject({ type: "exit" });
|
||||
|
||||
const tag = `phase4-${Date.now()}`;
|
||||
const process = await capsule.commands.start("sleep", {
|
||||
args: ["60"],
|
||||
tag,
|
||||
});
|
||||
expect(process.tag).toBe(tag);
|
||||
|
||||
const processes = await capsule.commands.list();
|
||||
expect(processes.processes?.some((entry) => entry.tag === tag)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(
|
||||
capsule.commands.kill(tag, "SIGTERM"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"performs file read/write/list/remove and streaming transfers",
|
||||
async () => {
|
||||
await withLiveCapsule(async (capsule) => {
|
||||
const dir = `/tmp/wrenn-phase4-${Date.now()}`;
|
||||
const file = `${dir}/hello.txt`;
|
||||
const streamFile = `${dir}/stream.txt`;
|
||||
|
||||
await capsule.files.mkdir(dir);
|
||||
await capsule.files.write(file, "phase4-file");
|
||||
|
||||
const content = await capsule.files.read(file);
|
||||
expect(content.toString()).toBe("phase4-file");
|
||||
|
||||
const listing = await capsule.files.list(dir, { depth: 1 });
|
||||
expect(
|
||||
listing.entries?.some((entry) => entry.name === "hello.txt"),
|
||||
).toBe(true);
|
||||
|
||||
await capsule.files.uploadStream(
|
||||
streamFile,
|
||||
Buffer.from("phase4-stream-file"),
|
||||
);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of capsule.files.downloadStream(streamFile)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
expect(Buffer.concat(chunks).toString()).toBe("phase4-stream-file");
|
||||
|
||||
await expect(capsule.files.remove(file)).resolves.toBeUndefined();
|
||||
await expect(capsule.files.remove(streamFile)).resolves.toBeUndefined();
|
||||
await expect(capsule.files.remove(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"starts and controls an interactive PTY session",
|
||||
async () => {
|
||||
await withLiveCapsule(async (capsule) => {
|
||||
const session = await capsule.pty.start({
|
||||
cmd: "/bin/sh",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
const started = await nextPtyEvent(session.events);
|
||||
expect(started.type).toBe("started");
|
||||
|
||||
session.resize(100, 30);
|
||||
session.input("printf phase4-pty\\n\nexit\n");
|
||||
|
||||
const events: PtyEvent[] = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const event = await nextPtyEvent(session.events);
|
||||
events.push(event);
|
||||
if (event.type === "exit" || event.type === "error") break;
|
||||
}
|
||||
|
||||
const output = events
|
||||
.filter(
|
||||
(event) =>
|
||||
event.type === "output" && typeof event.data === "string",
|
||||
)
|
||||
.map((event) =>
|
||||
Buffer.from(event.data as string, "base64").toString(),
|
||||
)
|
||||
.join("");
|
||||
|
||||
expect(output).toContain("phase4-pty");
|
||||
expect(events.at(-1)).toMatchObject({ type: "exit" });
|
||||
session.close();
|
||||
});
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
});
|
||||
71
tests/pty.test.ts
Normal file
71
tests/pty.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Capsule } from "../src/capsule.js";
|
||||
|
||||
describe("PtyManager", () => {
|
||||
it("starts a PTY, sends controls, and yields events", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const sent: unknown[] = [];
|
||||
let onMessage: ((message: unknown) => void) | undefined;
|
||||
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
|
||||
async (_id, opts) => {
|
||||
onMessage = opts.onMessage;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
send: (message: unknown) => sent.push(message),
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const session = await capsule.pty.start({
|
||||
cmd: "/bin/sh",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
});
|
||||
expect(sent).toEqual([
|
||||
{ cmd: "/bin/sh", cols: 100, rows: 30, type: "start" },
|
||||
]);
|
||||
|
||||
session.input("ls\n");
|
||||
session.resize(120, 40);
|
||||
session.kill();
|
||||
expect(sent.slice(1)).toEqual([
|
||||
{ data: Buffer.from("ls\n").toString("base64"), type: "input" },
|
||||
{ cols: 120, rows: 40, type: "resize" },
|
||||
{ type: "kill" },
|
||||
]);
|
||||
|
||||
const event = session.events.next();
|
||||
onMessage?.({ data: Buffer.from("ok").toString("base64"), type: "output" });
|
||||
await expect(event).resolves.toEqual({
|
||||
done: false,
|
||||
value: {
|
||||
data: Buffer.from("ok").toString("base64"),
|
||||
type: "output",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("connects to an existing PTY tag", 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.connect("pty-tag");
|
||||
|
||||
expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user