feat: add higher-level git and code interpreter APIs
This commit is contained in:
56
tests/code-interpreter.test.ts
Normal file
56
tests/code-interpreter.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Capsule } from "../src/capsule.js";
|
||||
import { CodeInterpreter } from "../src/code-interpreter/index.js";
|
||||
|
||||
describe("CodeInterpreter", () => {
|
||||
it("creates a capsule with the jupyter template by default", async () => {
|
||||
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
|
||||
new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
const interpreter = await CodeInterpreter.create({
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
expect(interpreter.capsule.id).toBe("cap_1");
|
||||
expect(create).toHaveBeenCalledWith("jupyter", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
create.mockRestore();
|
||||
});
|
||||
|
||||
it("connects to an existing capsule", () => {
|
||||
const interpreter = CodeInterpreter.connect("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
expect(interpreter.capsule.id).toBe("cap_1");
|
||||
});
|
||||
|
||||
it("executes notebook cells through python3", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||
exit_code: 0,
|
||||
stderr: "",
|
||||
stdout: "42\n",
|
||||
});
|
||||
const interpreter = new CodeInterpreter(capsule);
|
||||
|
||||
await expect(
|
||||
interpreter.notebook.execCell("print(6 * 7)"),
|
||||
).resolves.toEqual({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "42\n",
|
||||
});
|
||||
expect(exec).toHaveBeenCalledWith("python3", {
|
||||
args: ["-c", "print(6 * 7)"],
|
||||
timeoutSec: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -21,7 +21,6 @@ describe("CommandManager", () => {
|
||||
|
||||
expect(exec).toHaveBeenCalledWith("cap_1", {
|
||||
args: ["--version"],
|
||||
background: false,
|
||||
cmd: "node",
|
||||
timeout_sec: 5,
|
||||
});
|
||||
|
||||
74
tests/git.test.ts
Normal file
74
tests/git.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Capsule } from "../src/capsule.js";
|
||||
import { Git } from "../src/git/index.js";
|
||||
|
||||
describe("Git", () => {
|
||||
it("runs clone with optional path and branch", 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 expect(
|
||||
capsule.git.clone("https://example.com/repo.git", {
|
||||
branch: "main",
|
||||
path: "/work/repo",
|
||||
}),
|
||||
).resolves.toMatchObject({ exit_code: 0 });
|
||||
|
||||
expect(exec).toHaveBeenCalledWith("git", {
|
||||
args: [
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
"https://example.com/repo.git",
|
||||
"/work/repo",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs repository commands in cwd", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const git = new Git(capsule, { cwd: "/work/repo" });
|
||||
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||
exit_code: 0,
|
||||
stdout: "main\n",
|
||||
});
|
||||
|
||||
await git.status();
|
||||
await git.pull({ branch: "main", remote: "origin" });
|
||||
await git.push({ branch: "main", remote: "origin" });
|
||||
await git.log({ maxCount: 3 });
|
||||
await git.branch();
|
||||
await git.checkout("feature", { create: true });
|
||||
await git.add(["src/a.ts", "src/b.ts"]);
|
||||
await git.commit("test commit");
|
||||
|
||||
expect(exec.mock.calls).toEqual([
|
||||
["git", { args: ["status", "--short"], cwd: "/work/repo" }],
|
||||
["git", { args: ["pull", "origin", "main"], cwd: "/work/repo" }],
|
||||
["git", { args: ["push", "origin", "main"], cwd: "/work/repo" }],
|
||||
["git", { args: ["log", "--oneline", "-n", "3"], cwd: "/work/repo" }],
|
||||
["git", { args: ["branch", "--show-current"], cwd: "/work/repo" }],
|
||||
["git", { args: ["checkout", "-b", "feature"], cwd: "/work/repo" }],
|
||||
["git", { args: ["add", "src/a.ts", "src/b.ts"], cwd: "/work/repo" }],
|
||||
["git", { args: ["commit", "-m", "test commit"], cwd: "/work/repo" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects empty git add file lists", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
await expect(capsule.git.add([])).rejects.toThrow(
|
||||
"At least one file is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
202
tests/integration/higher-level-abstractions.integration.test.ts
Normal file
202
tests/integration/higher-level-abstractions.integration.test.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
|
||||
import { CodeInterpreter } from "../../src/code-interpreter/index.js";
|
||||
import { DEFAULT_BASE_URL } from "../../src/config.js";
|
||||
import { Git } from "../../src/git/index.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 codeTemplate = process.env.WRENN_TEST_CODE_TEMPLATE ?? "jupyter";
|
||||
const codeCapsuleId = process.env.WRENN_TEST_CODE_CAPSULE_ID;
|
||||
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
|
||||
const testTimeoutMs = waitTimeoutMs + 60_000;
|
||||
const describeWithApiKey = apiKey ? describe : describe.skip;
|
||||
|
||||
const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetryableServerError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"statusCode" in error &&
|
||||
typeof error.statusCode === "number" &&
|
||||
error.statusCode >= 500
|
||||
);
|
||||
}
|
||||
|
||||
async function expectCommandOk(
|
||||
promise: Promise<{ exit_code?: number; stderr?: string }>,
|
||||
): Promise<void> {
|
||||
const result = await promise;
|
||||
expect(result.exit_code, result.stderr).toBe(0);
|
||||
}
|
||||
|
||||
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 createCodeInterpreterWithRetry(): Promise<CodeInterpreter> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
return await CodeInterpreter.create({
|
||||
...clientOpts,
|
||||
template: codeTemplate,
|
||||
timeout_sec: 120,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetryableServerError(error) || attempt === 3) break;
|
||||
await delay(2_000);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function getCodeInterpreter(): Promise<{
|
||||
interpreter: CodeInterpreter;
|
||||
shouldDestroy: boolean;
|
||||
}> {
|
||||
if (!codeCapsuleId) {
|
||||
return {
|
||||
interpreter: await createCodeInterpreterWithRetry(),
|
||||
shouldDestroy: true,
|
||||
};
|
||||
}
|
||||
|
||||
const interpreter = CodeInterpreter.connect(codeCapsuleId, clientOpts);
|
||||
const info = await interpreter.capsule.getInfo();
|
||||
if (info.status === "paused") {
|
||||
await interpreter.capsule.resume({
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: waitTimeoutMs,
|
||||
wait: true,
|
||||
});
|
||||
} else {
|
||||
await interpreter.capsule.waitForReady({
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: waitTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
return { interpreter, shouldDestroy: false };
|
||||
}
|
||||
|
||||
describeWithApiKey("Higher-level abstractions live integration", () => {
|
||||
it(
|
||||
"runs git workflows inside a live capsule",
|
||||
async () => {
|
||||
await withLiveCapsule(async (capsule) => {
|
||||
const root = `/tmp/wrenn-git-${Date.now()}`;
|
||||
const origin = `${root}/origin.git`;
|
||||
const work = `${root}/work`;
|
||||
|
||||
await expectCommandOk(
|
||||
capsule.commands.exec("mkdir", { args: ["-p", root] }),
|
||||
);
|
||||
await expectCommandOk(
|
||||
capsule.commands.exec("git", { args: ["init", "--bare", origin] }),
|
||||
);
|
||||
|
||||
await expectCommandOk(capsule.git.clone(origin, { path: work }));
|
||||
const git = new Git(capsule, { cwd: work });
|
||||
const pwd = await capsule.commands.exec("pwd", { cwd: work });
|
||||
expect(pwd.stdout?.trim(), pwd.stderr).toBe(work);
|
||||
|
||||
await expectCommandOk(
|
||||
capsule.commands.exec("git", {
|
||||
args: ["config", "user.email", "integration@example.com"],
|
||||
cwd: work,
|
||||
}),
|
||||
);
|
||||
await expectCommandOk(
|
||||
capsule.commands.exec("git", {
|
||||
args: ["config", "user.name", "Integration Test"],
|
||||
cwd: work,
|
||||
}),
|
||||
);
|
||||
|
||||
await expectCommandOk(
|
||||
capsule.commands.exec("sh", {
|
||||
args: ["-lc", "printf 'phase5-git\\n' > README.md"],
|
||||
cwd: work,
|
||||
}),
|
||||
);
|
||||
const dirty = await git.status();
|
||||
expect(dirty.exit_code, dirty.stderr).toBe(0);
|
||||
expect(dirty.stdout, dirty.stderr).toContain("README.md");
|
||||
|
||||
await expectCommandOk(git.add("README.md"));
|
||||
const commit = await git.commit("initial commit");
|
||||
expect(commit.exit_code, commit.stderr).toBe(0);
|
||||
|
||||
const log = await git.log({ maxCount: 1 });
|
||||
expect(log.exit_code, log.stderr).toBe(0);
|
||||
expect(log.stdout, log.stderr).toContain("initial commit");
|
||||
|
||||
await expectCommandOk(git.branch());
|
||||
await expectCommandOk(git.checkout("feature", { create: true }));
|
||||
const branch = await git.branch();
|
||||
expect(branch.exit_code, branch.stderr).toBe(0);
|
||||
expect(branch.stdout?.trim(), branch.stderr).toBe("feature");
|
||||
|
||||
await expectCommandOk(
|
||||
git.push({ branch: "feature", remote: "origin" }),
|
||||
);
|
||||
await expectCommandOk(
|
||||
git.pull({ branch: "feature", remote: "origin" }),
|
||||
);
|
||||
});
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"executes a code interpreter cell in a live capsule",
|
||||
async () => {
|
||||
let interpreter: CodeInterpreter | undefined;
|
||||
let shouldDestroy = false;
|
||||
try {
|
||||
const codeInterpreter = await getCodeInterpreter();
|
||||
interpreter = codeInterpreter.interpreter;
|
||||
shouldDestroy = codeInterpreter.shouldDestroy;
|
||||
|
||||
const result = await interpreter.notebook.execCell("print(6 * 7)", {
|
||||
timeoutSec: 15,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("42");
|
||||
expect(result.stderr).toBe("");
|
||||
} finally {
|
||||
if (interpreter && shouldDestroy) {
|
||||
await interpreter.capsule.destroy().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user