| 1 | // Shared setup for the agent e2e files (tasks.test.mjs, terminals.test.mjs): a real `wrangler dev` |
| 2 | // Worker, sandboxes in an `agent.mjs --sandbox-pool` process, a fake model provider, and helpers. |
| 3 | // Importing it registers the before/after hooks that start and stop them. |
| 4 | import { spawn, execFileSync } from "node:child_process"; |
| 5 | import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs"; |
| 6 | import { tmpdir } from "node:os"; |
| 7 | import { createHash, randomBytes } from "node:crypto"; |
| 8 | import { createRequire } from "node:module"; |
| 9 | import { join, dirname, resolve } from "node:path"; |
| 10 | import { fileURLToPath } from "node:url"; |
| 11 | import { createServer as netServer } from "node:net"; |
| 12 | import { createServer as httpServer } from "node:http"; |
| 13 | import { before, after } from "node:test"; |
| 14 | import assert from "node:assert/strict"; |
| 15 | |
| 16 | export const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 17 | export const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); |
| 18 | export const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-")); |
| 19 | export const poolRoot = join(work, "pool"); |
| 20 | // The terminal test speaks WebSocket with headers (Origin, cookies), which Node's built-in client can't set. |
| 21 | export const { WebSocket: WS } = createRequire(resolve(APP, "../../containers/runner/package.json"))("ws"); |
| 22 | export const RIVAL_SESSION = `rival-${randomBytes(12).toString("hex")}`; |
| 23 | export const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`; |
| 24 | export const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`; |
| 25 | export let base; |
| 26 | export let dev; |
| 27 | export let pool; |
| 28 | export let upstream; |
| 29 | export let devLog = ""; |
| 30 | export let poolLog = ""; |
| 31 | export let token; |
| 32 | export const modelRequests = []; |
| 33 | |
| 34 | export const freePort = () => |
| 35 | new Promise((res) => { |
| 36 | const s = netServer(); |
| 37 | s.listen(0, "127.0.0.1", () => { |
| 38 | const { port } = s.address(); |
| 39 | s.close(() => res(port)); |
| 40 | }); |
| 41 | }); |
| 42 | |
| 43 | export async function api(method, path, body, tok = token) { |
| 44 | const res = await fetch(`${base}/api/v3${path}`, { |
| 45 | method, |
| 46 | headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, |
| 47 | body: body === undefined ? undefined : JSON.stringify(body), |
| 48 | }); |
| 49 | const text = await res.text(); |
| 50 | let json = null; |
| 51 | try { |
| 52 | json = text ? JSON.parse(text) : null; |
| 53 | } catch { |
| 54 | json = text; |
| 55 | } |
| 56 | return { status: res.status, json }; |
| 57 | } |
| 58 | export async function ok(method, path, body, tok) { |
| 59 | const r = await api(method, path, body, tok); |
| 60 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 61 | return r.json; |
| 62 | } |
| 63 | /** As the signed-in person in a browser (the dev sign-in: no credentials). */ |
| 64 | export const asPerson = (method, path, body) => api(method, path, body, null); |
| 65 | |
| 66 | export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 67 | export async function until(what, fn, timeoutMs = 90_000) { |
| 68 | const end = Date.now() + timeoutMs; |
| 69 | let last; |
| 70 | while (Date.now() < end) { |
| 71 | last = await fn(); |
| 72 | if (last) return last; |
| 73 | await sleep(500); |
| 74 | } |
| 75 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}\n--- pool log tail ---\n${poolLog.slice(-3000)}`); |
| 76 | } |
| 77 | |
| 78 | export function git(cwd, args, tok = token) { |
| 79 | const auth = Buffer.from(`x-token:${tok}`).toString("base64"); |
| 80 | return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], { |
| 81 | cwd, |
| 82 | encoding: "utf8", |
| 83 | stdio: ["ignore", "pipe", "pipe"], |
| 84 | env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" }, |
| 85 | }); |
| 86 | } |
| 87 | |
| 88 | export async function mcp(name, args, tok = token) { |
| 89 | const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${tok}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) }); |
| 90 | const { result } = await r.json(); |
| 91 | return result; |
| 92 | } |
| 93 | |
| 94 | /** The sandboxes the pool has started, newest first. */ |
| 95 | export const poolSandboxes = () => |
| 96 | existsSync(poolRoot) |
| 97 | ? readdirSync(poolRoot) |
| 98 | .map((id) => ({ id, at: statSync(join(poolRoot, id)).mtimeMs })) |
| 99 | .sort((a, b) => b.at - a.at) |
| 100 | .map((x) => x.id) |
| 101 | : []; |
| 102 | |
| 103 | before(async () => { |
| 104 | if (!existsSync(AGENT)) execFileSync("pnpm", ["--filter", "@diffinery/runner", "build"], { cwd: APP, stdio: "pipe" }); |
| 105 | // A stand-in for the provider: records what reaches it and answers like the Messages API. |
| 106 | upstream = httpServer((req, res) => { |
| 107 | let body = ""; |
| 108 | req.on("data", (d) => (body += d)); |
| 109 | req.on("end", () => { |
| 110 | modelRequests.push({ method: req.method, url: req.url, headers: req.headers, body }); |
| 111 | res.writeHead(200, { "content-type": "application/json", "set-cookie": "upstream=1" }); |
| 112 | res.end(JSON.stringify({ type: "message", content: [{ type: "text", text: "fake model says hi" }] })); |
| 113 | }); |
| 114 | }); |
| 115 | await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); |
| 116 | const upstreamUrl = `http://127.0.0.1:${upstream.address().port}`; |
| 117 | |
| 118 | // A stand-in for a Claude Code someone signed in to in their terminal (phase 4b): it reports what it was given and |
| 119 | // where it ran, and changes a file, without any model. |
| 120 | const fakeBin = join(work, "bin"); |
| 121 | mkdirSync(fakeBin, { recursive: true }); |
| 122 | writeFileSync( |
| 123 | join(fakeBin, "claude"), |
| 124 | [ |
| 125 | "#!/bin/sh", |
| 126 | 'd=$(basename "$PWD")', |
| 127 | 'echo "{\\"type\\":\\"assistant\\",\\"message\\":{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"signed-in claude: key=${ANTHROPIC_API_KEY:-none} base=${ANTHROPIC_BASE_URL:-none} dir=$d\\"}]}}"', |
| 128 | 'printf "%s\\n" "$DIFFINERY_TASK_PROMPT"', |
| 129 | 'echo "made in the terminal" > terminal.txt', |
| 130 | 'echo \'{"type":"result","subtype":"success","result":"Done in the terminal."}\'', |
| 131 | ].join("\n"), |
| 132 | { mode: 0o755 }, |
| 133 | ); |
| 134 | const poolPort = await freePort(); |
| 135 | const poolSecret = randomBytes(16).toString("hex"); |
| 136 | pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true }); |
| 137 | pool.stdout.on("data", (d) => (poolLog += d)); |
| 138 | pool.stderr.on("data", (d) => (poolLog += d)); |
| 139 | await until("sandbox pool", async () => poolLog.includes("listening"), 30_000); |
| 140 | |
| 141 | const port = await freePort(); |
| 142 | base = `http://127.0.0.1:${port}`; |
| 143 | const persist = join(work, "state"); |
| 144 | execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 145 | // A second person, signed in with a session: not the terminal's opener, whatever their role. |
| 146 | { |
| 147 | const at = new Date().toISOString(); |
| 148 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 149 | const hash = createHash("sha256").update(RIVAL_SESSION).digest("hex"); |
| 150 | execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('u-rival', 'rival', 'user', 'Rival', 'active', '${at}'); INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash}', 'u-rival', 'passkey', '${at}', '${at}', '${later}')`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 151 | } |
| 152 | const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl, MODEL_UPSTREAM_OPENROUTER: upstreamUrl, AGENT_POLL_MS: "500", TERMINAL_POLL_MS: "3000" }; |
| 153 | dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"], { |
| 154 | cwd: APP, |
| 155 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| 156 | stdio: ["ignore", "pipe", "pipe"], |
| 157 | detached: true, |
| 158 | }); |
| 159 | dev.stdout.on("data", (d) => (devLog += d)); |
| 160 | dev.stderr.on("data", (d) => (devLog += d)); |
| 161 | await until( |
| 162 | "wrangler dev", |
| 163 | async () => { |
| 164 | try { |
| 165 | return (await fetch(`${base}/healthz`)).ok; |
| 166 | } catch { |
| 167 | return false; |
| 168 | } |
| 169 | }, |
| 170 | 120_000, |
| 171 | ); |
| 172 | const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) }); |
| 173 | assert.equal(res.status, 201, await res.clone().text()); |
| 174 | token = (await res.json()).token; |
| 175 | }); |
| 176 | |
| 177 | after(() => { |
| 178 | for (const p of [dev, pool]) { |
| 179 | if (p?.pid) { |
| 180 | try { |
| 181 | process.kill(-p.pid, "SIGTERM"); |
| 182 | } catch {} |
| 183 | } |
| 184 | } |
| 185 | upstream?.close(); |
| 186 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 187 | }); |