Public: anyone can read this; the crew can change it.
The real-cell stranger test walks claim link → first ask on a new app → first pull green and live, starting from the kept branch #12243
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12077-the-real-cell-stranger-test-walks-claim → main 15 commits fork 2 threads · 2 unresolved
5 files
changed +513
−0
scripts/gate.ts +5
| @@ -181,6 +181,11 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 181 | 181 | reason: "the cold home takes 20 timed requests at ~65 ms, each after a write, until its per-app reads are grouped", |
| 182 | 182 | issue: 12310, |
| 183 | 183 | }, |
| 184 | { | |
| 185 | file: "test/spawn-stranger-cell.test.ts", | |
| 186 | reason: "one test walks a whole forge in a real cell, from claim link to the first pull green; it is not a unit", | |
| 187 | issue: 12077, | |
| 188 | }, | |
| 184 | 189 | ]; |
| 185 | 190 | |
| 186 | 191 | /** Everything wrong with one list of named files, `list` its name in the lines. */ |
test/helpers/real-stranger-e2e.ts added +325
| @@ -0,0 +1,325 @@ | ||
| 1 | /** | |
| 2 | * The stranger's path in a real cell (#12077), as `test/spawn-stranger-cell.test.ts` | |
| 3 | * drives it. Its own process, for the reason `real-spawn-e2e.ts` is: a check | |
| 4 | * sets `FORGE_CELLS=off` for the forges its tests boot. | |
| 5 | * | |
| 6 | * A parent with the real server is asked for a forge by name, as the home's | |
| 7 | * form does, with no account. The child comes up in a real cell at the plan's | |
| 8 | * size; the walk claims it through its claim link with the forge's name as | |
| 9 | * username and no email, asks "a page that says hello and shows today's date" | |
| 10 | * through the form on its first screen, and follows the ask to a new app, its | |
| 11 | * first pull landing on green, and the app's own address serving the page to | |
| 12 | * its owner as a browser reaches it. The model is `stranger-claude.sh`, a | |
| 13 | * fixed edit; the cell, the crew's clone and push, the pull, its check, the | |
| 14 | * merge and the deploy are the forge's own. The second ask is not walked. | |
| 15 | * | |
| 16 | * Each phase has its own budget and a message that names it. Printed: one JSON | |
| 17 | * line of what was seen, with `error` set to the phase that did not finish. | |
| 18 | */ | |
| 19 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 20 | import { tmpdir } from "node:os"; | |
| 21 | import { join } from "node:path"; | |
| 22 | import { createApp } from "../../src/app.ts"; | |
| 23 | import { cellsAvailable } from "../../src/apps/contain.ts"; | |
| 24 | import { serve } from "../../src/http/server.ts"; | |
| 25 | import { useProbe } from "../../src/spawn/door.ts"; | |
| 26 | import { cellChildren, stopCellChildren } from "../../src/spawn/cell.ts"; | |
| 27 | import { lastStop } from "../../src/spawn/stopped.ts"; | |
| 28 | import "./roomy-disk.ts"; | |
| 29 | ||
| 30 | // One budget per phase, each counted from when it began: a slow phase never | |
| 31 | // shortens the next, and a run that ran out says which one did. The test's | |
| 32 | // timeout is their sum plus slack (see test/spawn-stranger-cell.test.ts). | |
| 33 | const BUDGET_MS = { up: 120_000, ask: 60_000, pull: 120_000, serves: 60_000 } as const; | |
| 34 | const NAME = "kid"; | |
| 35 | const ASK = "a page that says hello and shows today's date"; | |
| 36 | const PARENT = "forge.test"; | |
| 37 | const CHILD = `${NAME}--${PARENT}`; | |
| 38 | ||
| 39 | process.env["FORGE_CLAUDE"] = join(import.meta.dir, "stranger-claude.sh"); | |
| 40 | ||
| 41 | let cleanup: () => Promise<void> = async () => {}; | |
| 42 | process.on("SIGTERM", () => void cleanup().finally(() => process.exit(143))); | |
| 43 | ||
| 44 | const cells = await cellsAvailable(); | |
| 45 | if (!cells.ok) { | |
| 46 | console.log(JSON.stringify({ skipped: cells.reason })); | |
| 47 | process.exit(0); | |
| 48 | } | |
| 49 | ||
| 50 | const dataDir = mkdtempSync(join(tmpdir(), "real-stranger-")); | |
| 51 | const parent = createApp({ dataDir, url: `https://${PARENT}`, port: 0 }); | |
| 52 | const servers = await serve(parent); | |
| 53 | // The child's public address (https://kid--forge.test) is not a real host here: ask it | |
| 54 | // the way every other request in this walk does, through the parent's own server with | |
| 55 | // the child's Host, on /claim (the parent answers /healthz itself, whatever the host). | |
| 56 | useProbe(parent, () => async () => { | |
| 57 | try { | |
| 58 | const res = await fetch(`${servers.url}/claim`, { | |
| 59 | headers: { host: CHILD }, | |
| 60 | redirect: "manual", | |
| 61 | signal: AbortSignal.timeout(700), | |
| 62 | }); | |
| 63 | await res.body?.cancel(); | |
| 64 | return res.status === 200; | |
| 65 | } catch { | |
| 66 | return false; | |
| 67 | } | |
| 68 | }); | |
| 69 | cleanup = async () => { | |
| 70 | servers.stop(); | |
| 71 | await stopCellChildren(parent); | |
| 72 | }; | |
| 73 | ||
| 74 | const seen: Record<string, unknown> = {}; | |
| 75 | const statuses = new Set<number>(); | |
| 76 | /** Cookies a browser would hold, by host. */ | |
| 77 | const jar = new Map<string, Map<string, string>>(); | |
| 78 | ||
| 79 | /** One request to `host` through the parent's real server; every status is noted, a 429 too. */ | |
| 80 | async function call( | |
| 81 | host: string, | |
| 82 | path: string, | |
| 83 | init: { method?: string; body?: URLSearchParams; accept?: string } = {}, | |
| 84 | ): Promise<Response> { | |
| 85 | const cookies = [...(jar.get(host)?.entries() ?? [])].map(([k, v]) => `${k}=${v}`).join("; "); | |
| 86 | const res = await fetch(`${servers.url}${path}`, { | |
| 87 | method: init.method ?? "GET", | |
| 88 | redirect: "manual", | |
| 89 | signal: AbortSignal.timeout(20_000), | |
| 90 | ...(init.body === undefined ? {} : { body: init.body }), | |
| 91 | headers: { | |
| 92 | host, | |
| 93 | ...(init.method === "POST" ? { origin: `https://${host}` } : {}), | |
| 94 | ...(init.accept === undefined ? {} : { accept: init.accept }), | |
| 95 | ...(cookies === "" ? {} : { cookie: cookies }), | |
| 96 | }, | |
| 97 | }); | |
| 98 | statuses.add(res.status); | |
| 99 | for (const line of res.headers.getSetCookie()) { | |
| 100 | const [pair = ""] = line.split(";"); | |
| 101 | const at = pair.indexOf("="); | |
| 102 | if (at < 1) continue; | |
| 103 | const held = jar.get(host) ?? new Map<string, string>(); | |
| 104 | const value = pair.slice(at + 1); | |
| 105 | if (value === "" || /max-age=0/i.test(line)) held.delete(pair.slice(0, at)); | |
| 106 | else held.set(pair.slice(0, at), value); | |
| 107 | jar.set(host, held); | |
| 108 | } | |
| 109 | return res; | |
| 110 | } | |
| 111 | ||
| 112 | /** Retries `read` each second until it answers, or says which phase ran out and what it saw. */ | |
| 113 | async function until<T>( | |
| 114 | phase: string, | |
| 115 | ms: number, | |
| 116 | read: () => Promise<T | undefined>, | |
| 117 | last: () => unknown = () => undefined, | |
| 118 | ): Promise<T> { | |
| 119 | const end = Date.now() + ms; | |
| 120 | for (;;) { | |
| 121 | const value = await read().catch((e: unknown) => { | |
| 122 | seen["lastError"] = String(e); | |
| 123 | return undefined; | |
| 124 | }); | |
| 125 | if (value !== undefined) return value; | |
| 126 | if (Date.now() >= end) { | |
| 127 | const saw = JSON.stringify(last() ?? null); | |
| 128 | throw new Error(`the ${phase} phase did not finish in ${ms / 1000}s: ${saw}`); | |
| 129 | } | |
| 130 | await Bun.sleep(1000); | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | function fail(phase: string, what: string): never { | |
| 135 | throw new Error(`the ${phase} phase failed: ${what}`); | |
| 136 | } | |
| 137 | ||
| 138 | /** The options of the ask form's target select, as a person sees them. */ | |
| 139 | const targets = (html: string): string[] => | |
| 140 | [...html.matchAll(/<option[^>]*value="([^"]*)"/g)].map((m) => m[1]!); | |
| 141 | ||
| 142 | /** Follows redirects as a browser does, from `start`, to the first answer that is not one. */ | |
| 143 | async function browse( | |
| 144 | start: string, | |
| 145 | ): Promise<{ status: number; body: string; hops: string[] }> { | |
| 146 | let url = new URL(start); | |
| 147 | const hops: string[] = []; | |
| 148 | for (let i = 0; i < 8; i++) { | |
| 149 | const res = await call(url.host, url.pathname + url.search, { accept: "text/html" }); | |
| 150 | hops.push(`${res.status} ${url.host}${url.pathname}`); | |
| 151 | const where = res.headers.get("location"); | |
| 152 | if (res.status >= 300 && res.status < 400 && where !== null) { | |
| 153 | await res.body?.cancel(); | |
| 154 | url = new URL(where, url); | |
| 155 | continue; | |
| 156 | } | |
| 157 | return { status: res.status, body: await res.text(), hops }; | |
| 158 | } | |
| 159 | return { status: 0, body: "", hops }; | |
| 160 | } | |
| 161 | ||
| 162 | try { | |
| 163 | // Phase 1: a forge by name, from the home's own form; the child comes up in a cell. | |
| 164 | const home = await call(PARENT, "/"); | |
| 165 | if (home.status !== 200 || !(await home.text()).includes('id="start-forge"')) { | |
| 166 | fail("up", `the home did not carry the form to start a forge (${home.status})`); | |
| 167 | } | |
| 168 | const started = await call(PARENT, "/forges/start", { | |
| 169 | method: "POST", | |
| 170 | body: new URLSearchParams({ name: NAME }), | |
| 171 | }); | |
| 172 | const waiting = await started.text(); | |
| 173 | const token = /href="[^"]*\/welcome#([A-Za-z0-9_-]{43})"/.exec(waiting)?.[1] ?? ""; | |
| 174 | if (started.status !== 200 || token === "") { | |
| 175 | fail("up", `starting the forge answered ${started.status} with no claim link`); | |
| 176 | } | |
| 177 | let upSeen: unknown = null; | |
| 178 | await until( | |
| 179 | "cell up", | |
| 180 | BUDGET_MS.up, | |
| 181 | async () => { | |
| 182 | const res = await call(PARENT, `/forges/${NAME}/ready`, { accept: "application/json" }); | |
| 183 | const body = (await res.json()) as { ready?: unknown }; | |
| 184 | upSeen = { status: res.status, ...body, lastError: seen["lastError"] }; | |
| 185 | return res.status === 200 && body.ready === true ? true : undefined; | |
| 186 | }, | |
| 187 | () => upSeen, | |
| 188 | ); | |
| 189 | const held = cellChildren(parent) | |
| 190 | .list() | |
| 191 | .find((c) => c.name === NAME); | |
| 192 | seen["placement"] = held === undefined ? "none" : "cell"; | |
| 193 | seen["limits"] = held?.limits ?? null; | |
| 194 | if (held === undefined) fail("up", "the child is not in a cell"); | |
| 195 | ||
| 196 | // Phase 2: the claim link, with the forge's own name as username, a password and no email. | |
| 197 | const form = await call(CHILD, "/claim"); | |
| 198 | if (form.status !== 200) fail("claim", `the account form answered ${form.status}`); | |
| 199 | await form.body?.cancel(); | |
| 200 | const claimed = await call(CHILD, "/claim", { | |
| 201 | method: "POST", | |
| 202 | body: new URLSearchParams({ token, username: NAME, password: "a-long-stranger-passphrase" }), | |
| 203 | }); | |
| 204 | seen["claim"] = claimed.status; | |
| 205 | if (claimed.status !== 303 || jar.get(CHILD) === undefined) { | |
| 206 | fail("claim", `claiming answered ${claimed.status}`); | |
| 207 | } | |
| 208 | await claimed.body?.cancel(); | |
| 209 | ||
| 210 | // Phase 3: the ask, through the form on the forge's first screen, and where it was filed. | |
| 211 | const landing = await call(CHILD, claimed.headers.get("location") ?? "/"); | |
| 212 | const first = await landing.text(); | |
| 213 | const tell = /<form[^>]*action="\/tell"[^>]*>([\s\S]*?)<\/form>/.exec(first)?.[1]; | |
| 214 | if (landing.status !== 200 || tell === undefined) { | |
| 215 | fail("ask", `the forge's first screen answered ${landing.status} with no form to ask through`); | |
| 216 | } | |
| 217 | const fields = new URLSearchParams(); | |
| 218 | for (const m of tell!.matchAll(/<input[^>]*type="hidden"[^>]*>/g)) { | |
| 219 | const name = /name="([^"]+)"/.exec(m[0])?.[1]; | |
| 220 | if (name) fields.set(name, /value="([^"]*)"/.exec(m[0])?.[1] ?? ""); | |
| 221 | } | |
| 222 | const select = /<select[^>]*name="([^"]+)"[^>]*>([\s\S]*?)<\/select>/.exec(tell!) ?? undefined; | |
| 223 | const chosen = | |
| 224 | select === undefined | |
| 225 | ? undefined | |
| 226 | : (/<option[^>]*value="([^"]*)"[^>]*\sselected/.exec(select[2]!)?.[1] ?? | |
| 227 | targets(select[2]!)[0]); | |
| 228 | seen["firstTarget"] = chosen ?? fields.get("app") ?? null; | |
| 229 | if (select !== undefined && chosen !== undefined) fields.set(select[1]!, chosen); | |
| 230 | const textName = /<textarea[^>]*name="([^"]+)"/.exec(tell!)?.[1] ?? ""; | |
| 231 | if (textName === "") fail("ask", "the form has no place to write the ask"); | |
| 232 | fields.set(textName, ASK); | |
| 233 | const before = new Set(targets(first)); | |
| 234 | const asked = await call(CHILD, "/tell", { method: "POST", body: fields }); | |
| 235 | seen["tell"] = asked.status; | |
| 236 | await asked.body?.cancel(); | |
| 237 | if (asked.status >= 400) fail("ask", `the ask answered ${asked.status}`); | |
| 238 | const filed = await until("ask filed on a new app", BUDGET_MS.ask, async () => { | |
| 239 | const res = await call(CHILD, "/"); | |
| 240 | const now = targets(await res.text()); | |
| 241 | // The forge's own copy may show among the targets once there is an app; never the | |
| 242 | // ask's home. | |
| 243 | const app = /^[a-z0-9][a-z0-9._-]*\/(?!forge$)[a-z0-9][a-z0-9._-]*$/; | |
| 244 | return now.find((t) => app.test(t) && !before.has(t)); | |
| 245 | }); | |
| 246 | seen["firstFiledOn"] = filed; | |
| 247 | const slug = filed.split("/")[1]!; | |
| 248 | const onForge = await call(CHILD, `/api/v1/repos/${NAME}/forge/issues?state=all`, { | |
| 249 | accept: "application/json", | |
| 250 | }); | |
| 251 | const forgeBody = onForge.status === 200 ? ((await onForge.json()) as { items?: unknown[] }) : {}; | |
| 252 | const forgeItems = forgeBody.items?.length ?? 0; | |
| 253 | await onForge.body?.cancel(); | |
| 254 | seen["forgeIssues"] = onForge.status === 200 ? forgeItems : null; | |
| 255 | const onApp = await call(CHILD, `/api/v1/repos/${filed}/issues?state=all`, { | |
| 256 | accept: "application/json", | |
| 257 | }); | |
| 258 | seen["appIssues"] = | |
| 259 | onApp.status === 200 | |
| 260 | ? (((await onApp.json()) as { items?: unknown[] }).items?.length ?? 0) | |
| 261 | : onApp.status; | |
| 262 | ||
| 263 | // Phase 4: the first pull lands by itself, on green. | |
| 264 | let pulls: unknown = null; | |
| 265 | const landed = await until( | |
| 266 | "first pull", | |
| 267 | BUDGET_MS.pull, | |
| 268 | async () => { | |
| 269 | const res = await call(CHILD, `/api/v1/repos/${filed}/pulls?state=all`, { | |
| 270 | accept: "application/json", | |
| 271 | }); | |
| 272 | if (res.status !== 200) return undefined; | |
| 273 | type Pull = { number: number; state: string; mergedAt?: unknown; check?: string }; | |
| 274 | const items = ((await res.json()) as { items?: Pull[] }).items ?? []; | |
| 275 | pulls = items.map((p) => ({ | |
| 276 | number: p.number, | |
| 277 | state: p.state, | |
| 278 | merged: p.mergedAt != null, | |
| 279 | check: p.check, | |
| 280 | })); | |
| 281 | return items.find((p) => p.mergedAt != null); | |
| 282 | }, | |
| 283 | () => pulls, | |
| 284 | ); | |
| 285 | seen["firstPull"] = { | |
| 286 | number: landed.number, | |
| 287 | state: landed.state, | |
| 288 | merged: true, | |
| 289 | check: landed.check, | |
| 290 | }; | |
| 291 | ||
| 292 | // Phase 5: the app's own address serves the page to its owner, as a browser reaches it. | |
| 293 | const host = `${slug}--${CHILD}`; | |
| 294 | const day = () => new Date().toISOString().slice(0, 10); | |
| 295 | const from = day(); | |
| 296 | let page: { status: number; body: string; hops: string[] } | undefined; | |
| 297 | await until( | |
| 298 | "app serving", | |
| 299 | BUDGET_MS.serves, | |
| 300 | async () => { | |
| 301 | page = await browse(`https://${host}/hello`); | |
| 302 | const hello = /^Hello! Today is \d{4}-\d{2}-\d{2}\.$/; | |
| 303 | return page.status === 200 && hello.test(page.body) ? page : undefined; | |
| 304 | }, | |
| 305 | () => ({ host, page }), | |
| 306 | ); | |
| 307 | seen["firstServes"] = { | |
| 308 | host, | |
| 309 | status: page!.status, | |
| 310 | body: page!.body.slice(0, 80), | |
| 311 | hops: page!.hops, | |
| 312 | }; | |
| 313 | const shown = /\d{4}-\d{2}-\d{2}/.exec(page!.body)?.[0] ?? ""; | |
| 314 | seen["dateShown"] = shown === from || shown === day(); | |
| 315 | ||
| 316 | seen["stop"] = lastStop(parent, held!.bodyId)?.why ?? null; | |
| 317 | } catch (e) { | |
| 318 | seen["error"] = e instanceof Error ? e.message : String(e); | |
| 319 | } finally { | |
| 320 | seen["statuses"] = [...statuses].sort((a, b) => a - b); | |
| 321 | console.log(JSON.stringify(seen)); | |
| 322 | await cleanup(); | |
| 323 | parent.close(); | |
| 324 | rmSync(dataDir, { recursive: true, force: true }); | |
| 325 | } |
test/helpers/stranger-builder.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * What the stranger's walk (`real-stranger-e2e.ts`) puts in the model's place | |
| 3 | * (#12077), run by `bun` in the clone the crew gives it: one fixed edit, the | |
| 4 | * app's own tests, and only then a commit and a push. Given a prompt that asks | |
| 5 | * for a page that says hello and shows today's date, it adds a `/hello` route | |
| 6 | * to the app the template made, and a test of it. Any other prompt, or an app | |
| 7 | * that already has the route (a reviewer's clone, a second run), changes | |
| 8 | * nothing and says so. Argument: none; the prompt is read from stdin. | |
| 9 | */ | |
| 10 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 11 | import { ANCHOR } from "./build-standin.ts"; | |
| 12 | ||
| 13 | /** What `/hello` says, before the date. */ | |
| 14 | export const HELLO = "Hello! Today is "; | |
| 15 | ||
| 16 | const today = 'new Date().toISOString().slice(0, 10)'; | |
| 17 | const body = `${JSON.stringify(HELLO)} + ${today} + "."`; | |
| 18 | const route = ` { method: "GET", path: "/hello", handle: () => text(${body}) },\n\n`; | |
| 19 | ||
| 20 | const test = `import { afterAll, expect, test } from "bun:test"; | |
| 21 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 22 | import { tmpdir } from "node:os"; | |
| 23 | import { join } from "node:path"; | |
| 24 | import { serve } from "./server.ts"; | |
| 25 | ||
| 26 | const dataDir = mkdtempSync(join(tmpdir(), "hello-")); | |
| 27 | const server = serve({ port: 0, dataDir, identityKey: "a-key-only-the-forge-holds" }); | |
| 28 | afterAll(() => { | |
| 29 | server.stop(true); | |
| 30 | rmSync(dataDir, { recursive: true, force: true }); | |
| 31 | }); | |
| 32 | ||
| 33 | test("/hello says hello and today's date", async () => { | |
| 34 | const res = await fetch(\`http://\${server.hostname}:\${server.port}/hello\`); | |
| 35 | expect(res.status).toBe(200); | |
| 36 | expect(await res.text()).toMatch(/^Hello! Today is \\d{4}-\\d{2}-\\d{2}\\.$/); | |
| 37 | }); | |
| 38 | `; | |
| 39 | ||
| 40 | const finish = (ok: boolean, result: string): void => | |
| 41 | console.log( | |
| 42 | JSON.stringify({ | |
| 43 | type: "result", | |
| 44 | subtype: ok ? "success" : "error_during_execution", | |
| 45 | is_error: !ok, | |
| 46 | result, | |
| 47 | total_cost_usd: 0.01, | |
| 48 | num_turns: 1, | |
| 49 | session_id: "stranger-1", | |
| 50 | terminal_reason: ok ? "completed" : "tool_error", | |
| 51 | }), | |
| 52 | ); | |
| 53 | const git = (...args: string[]): void => { | |
| 54 | const r = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" }); | |
| 55 | if (r.exitCode !== 0) throw new Error(`${args.join(" ")}: ${r.stderr.toString()}`); | |
| 56 | }; | |
| 57 | ||
| 58 | const prompt = (await Bun.stdin.text()).toLowerCase(); | |
| 59 | const routes = readFileSync("src/routes.ts", "utf8"); | |
| 60 | if (!prompt.includes("hello") || !prompt.includes("date") || routes.includes('path: "/hello"')) { | |
| 61 | finish(true, "DECLINED: nothing here I am asked to build."); | |
| 62 | process.exit(0); | |
| 63 | } | |
| 64 | if (!routes.includes(ANCHOR)) { | |
| 65 | finish(false, "the template's routes have changed shape"); | |
| 66 | process.exit(0); | |
| 67 | } | |
| 68 | writeFileSync("src/routes.ts", routes.replace(ANCHOR, route + ANCHOR)); | |
| 69 | writeFileSync("src/hello.test.ts", test); | |
| 70 | const tested = Bun.spawnSync(["bun", "test"], { stdout: "pipe", stderr: "pipe" }); | |
| 71 | if (tested.exitCode !== 0) { | |
| 72 | finish(false, `its tests failed: ${tested.stderr.toString().slice(-300)}`); | |
| 73 | process.exit(0); | |
| 74 | } | |
| 75 | git("add", "-A"); | |
| 76 | git("commit", "-qm", "Add a page that says hello and today's date"); | |
| 77 | git("push", "-q", "-u", "origin", "HEAD"); | |
| 78 | finish(true, "Added /hello, tests pass."); |
test/helpers/stranger-claude.sh added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # The model's place in test/spawn-stranger-cell.test.ts (#12077): Claude Code's | |
| 3 | # stream-json, with a fixed edit made by stranger-builder.ts. It lives in the | |
| 4 | # repository because a cell hides the temp directory a stand-in would be | |
| 5 | # written to; the parent hands its path to the child as FORGE_CLAUDE. | |
| 6 | if [ "$1" = "--version" ]; then echo "2.1.0 (stranger stand-in)"; exit 0; fi | |
| 7 | IFS= read -r first | |
| 8 | echo '{"type":"system","subtype":"init","session_id":"stranger-1","model":"stand-in"}' | |
| 9 | printf '%s\n' "" | bun "$(dirname "")/stranger-builder.ts" |
test/spawn-stranger-cell.test.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | /** | |
| 2 | * The stranger's path in a real cell (#12077, v1 item 1 of #11541): a forge | |
| 3 | * named from the home's form comes up at size small, is claimed through its | |
| 4 | * claim link with the forge's name as username and no email, and is asked for | |
| 5 | * "a page that says hello and shows today's date" through the form on its | |
| 6 | * first screen. The model's call is a fixed edit (`helpers/stranger-claude.sh`); | |
| 7 | * everything else is the forge's own. The ask is filed on a new app and never | |
| 8 | * `<name>/forge`, the first pull lands on green by itself, the app's address | |
| 9 | * serves the page to its owner, and nothing was answered 429 or stopped for | |
| 10 | * memory. The second ask is not walked here. | |
| 11 | * | |
| 12 | * It runs where bwrap and socat are, and says it skipped where they are not | |
| 13 | * (under FORGE_TEST_SOCKET, as `spawn-cell-e2e.test.ts` does). Its budget, as | |
| 14 | * that file's: each phase has its own (cell up 120 s, ask filed 60 s, first | |
| 15 | * pull 120 s, app serving 60 s, set in the helper and named in its failure), | |
| 16 | * and the helper's timeout and this test's are their sum plus slack: 380 s and | |
| 17 | * 400 s. The first real run took 47 s whole. It is a whole forge made and | |
| 18 | * built on, not a unit. | |
| 19 | */ | |
| 20 | import { test } from "bun:test"; | |
| 21 | import { join } from "node:path"; | |
| 22 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; | |
| 23 | import { missing } from "./helpers/tools.ts"; | |
| 24 | import { failedWith } from "./helpers/machine-note.ts"; | |
| 25 | import { resultLine } from "./helpers/result-line.ts"; | |
| 26 | ||
| 27 | const cellTools = | |
| 28 | process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat"); | |
| 29 | ||
| 30 | const name = | |
| 31 | "a stranger claims a small child, asks for a page, and the first change lands live, " + | |
| 32 | "with no 429 and no memory stop"; | |
| 33 | ||
| 34 | test.skipIf(!cellTools)(name, async () => { | |
| 35 | const env: Record<string, string> = {}; | |
| 36 | for (const [k, v] of Object.entries(process.env)) { | |
| 37 | if (v !== undefined && k !== "FORGE_CELLS") env[k] = v; | |
| 38 | } | |
| 39 | const walk = join(import.meta.dir, "helpers", "real-stranger-e2e.ts"); | |
| 40 | const proc = Bun.spawn([process.execPath, walk], { | |
| 41 | env, | |
| 42 | stdout: "pipe", | |
| 43 | stderr: "pipe", | |
| 44 | timeout: 380_000, | |
| 45 | }); | |
| 46 | const [out, errText] = await Promise.all([ | |
| 47 | new Response(proc.stdout).text(), | |
| 48 | new Response(proc.stderr).text(), | |
| 49 | proc.exited, | |
| 50 | ]); | |
| 51 | let seen: Record<string, unknown>; | |
| 52 | try { | |
| 53 | seen = JSON.parse(resultLine(out)) as Record<string, unknown>; | |
| 54 | } catch { | |
| 55 | throw new Error(failedWith("the stranger's walk printed no result", out.slice(-800), errText)); | |
| 56 | } | |
| 57 | if (typeof seen["skipped"] === "string") { | |
| 58 | const why = seen["skipped"]; | |
| 59 | console.log(`SKIPPING: this host refuses a cell inside the test (${why}); no forge started`); | |
| 60 | return; | |
| 61 | } | |
outdated
700 s of timeout for a test that has never been seen to pass. Land the first real run in the description before trusting these budgets. |
||
| 62 | // The walk's own line, in the check's log, whether it passed or not. | |
| 63 | console.log(`stranger walk: ${JSON.stringify(seen)}`); | |
| 64 | const bad = (what: string) => new Error(failedWith(what, seen, errText)); | |
| 65 | if (typeof seen["error"] === "string") throw bad(`the stranger's path broke: ${seen["error"]}`); | |
| 66 | const limits = seen["limits"] as { memoryMiB?: number; cpus?: number } | null; | |
| 67 | if (seen["placement"] !== "cell" || limits?.memoryMiB !== 2048 || limits.cpus !== 1) { | |
| 68 | throw bad("the forge did not come up in a small cell"); | |
| 69 | } | |
| 70 | if (seen["claim"] !== 303) throw bad("the claim link did not sign the stranger in"); | |
| 71 | if (seen["firstTarget"] !== "new-app") { | |
| 72 | throw bad("the first screen's form did not default to a new app"); | |
| 73 | } | |
| 74 | const filed = String(seen["firstFiledOn"]); | |
| 75 | if (!/^kid\/(?!forge$)[a-z0-9][a-z0-9._-]*$/.test(filed)) { | |
| 76 | throw bad("the ask was not filed on a new app"); | |
| 77 | } | |
| 78 | if (seen["forgeIssues"] !== null && seen["forgeIssues"] !== 0) { | |
| 79 | throw bad("the ask was filed on the forge's own copy"); | |
| 80 | } | |
| 81 | if (typeof seen["appIssues"] !== "number" || seen["appIssues"] < 1) { | |
| 82 | throw bad("the new app holds no ask"); | |
| 83 | } | |
| 84 | const pull = seen["firstPull"] as { merged?: boolean; check?: string } | undefined; | |
| 85 | if (pull?.merged !== true || pull.check !== "green") { | |
| 86 | throw bad("the first pull did not land on green"); | |
| 87 | } | |
| 88 | const serves = seen["firstServes"] as { status?: number } | undefined; | |
| 89 | if (serves?.status !== 200 || seen["dateShown"] !== true) { | |
| 90 | throw bad("the app's address did not serve the page with today's date"); | |
| 91 | } | |
| 92 | if ((seen["statuses"] as number[]).includes(429)) { | |
| 93 | throw bad("a request was answered 429, the sentence for a full host"); | |
| 94 | } | |
| 95 | if (seen["stop"] !== null) throw bad("the child was stopped for memory"); | |
| 96 | }, 400_000); |
new above
offline
The SLOW entry is acceptable for this walk, but the check must still run it unskipped. Prove that in the description.