1#!/usr/bin/env bun
2/**
3 * Cross-version test suite for alchemy.
4 *
5 * Deploys a tiny app (a Cloudflare state store + one Worker) using a range of
6 * alchemy versions, each from its own folder under ./test, and runs a set of
7 * upgrade scenarios BACK TO BACK (sequentially, never in parallel — they all
8 * share one account-wide state store and one fixed worker name):
9 *
10 * TEST 1 — sequential 1-by-1 upgrade
11 * Deploy the oldest version, then upgrade the SAME app in place through
12 * every version in order (v4 → v5 → v6 → v7 → current), walking the
13 * account-wide state store up one version at a time.
14 *
15 * TEST 2 — direct-to-latest
16 * For each older example, deploy it and then upgrade the SAME app DIRECTLY
17 * to latest (the current branch), skipping the intermediate versions — a
18 * big-jump upgrade (e.g. state store v4 → v7 in one step).
19 *
20 * Both tests assert, after every deploy, that the live worker serves the marker
21 * baked into the version that was just deployed — proving the running code was
22 * actually replaced in place.
23 *
24 * Each scenario ("unit") owns a FRESH state store: the runner tears the state
25 * store (worker + secrets) down before and after every unit, so — e.g. — the
26 * 1-by-1 upgrade runs on its own store, that store is destroyed, then each
27 * direct-to-latest jump deploys a brand-new store. (Pass --reuse-store to skip
28 * the teardowns and reuse one store across units, which is faster but less
29 * isolated.)
30 *
31 * Stage folders (see ./test/*):
32 * 01-beta.39 — last npm release with state store version 4
33 * 02-beta.44 — last npm release with state store version 5
34 * 03-beta.45 — last npm release with state store version 6
35 * 04-beta.59 — last v2 release on npm (state store version 7 = latest npm)
36 * 05-current — the current branch (workspace source, state store version 7)
37 *
38 * ──────────────────────────────────────────────────────────────────────────
39 * WARNING: the Cloudflare state store is ACCOUNT-WIDE and shared by every
40 * stack on the account/profile. These tests bootstrap state store v4, which
41 * DOWNGRADES the store if the account already had a higher version. Run this
42 * against a dedicated / throwaway Cloudflare account only.
43 * ──────────────────────────────────────────────────────────────────────────
44 *
45 * Usage:
46 * bun run test/cross-version-test/run.ts --profile <profile> [flags]
47 * ALCHEMY_PROFILE=<profile> bun run test/cross-version-test/run.ts
48 *
49 * The suite runs FORCE-THROUGH: every edge in `EDGES` is attempted on its own
50 * fresh state store, a failure is recorded (never aborts the run), and a
51 * PASS/FAIL summary + a `===RESULTS_JSON===` block is printed at the end. Mark
52 * an edge KNOWN BAD by commenting it out of `EDGES` (with a note).
53 *
54 * Usage:
55 * bun run test/cross-version-test/run.ts --profile <profile> [flags]
56 * ALCHEMY_PROFILE=<profile> bun run test/cross-version-test/run.ts
57 *
58 * Flags:
59 * --profile <p> Cloudflare auth profile (~/.alchemy/profiles.json).
60 * Falls back to $ALCHEMY_PROFILE. REQUIRED (no default, to
61 * avoid accidentally mutating the wrong account).
62 * --group <g> Only run edges in one group: "sequential" or "jump".
63 * --stage <s> Alchemy stage name for the app. Default: "xver".
64 * --no-install Skip `bun install` in npm stages (reuse existing installs).
65 * --reuse-store Reuse ONE state store across all edges (skip the per-edge
66 * teardown/redeploy). Faster, but edges aren't isolated.
67 * --settle <sec> Seconds to wait after a store teardown for workers.dev to
68 * propagate the deletion (default 25). Avoids the fresh
69 * bootstrap seeing the just-deleted worker's stale version.
70 * --boot-retries <n> Bootstrap attempts on transient 404/500/version-not-
71 * ready during hoist (default 3).
72 */
73
74import { spawn } from "node:child_process";
75import * as fs from "node:fs";
76import { fileURLToPath } from "node:url";
77import * as path from "node:path";
78
79const HERE = path.dirname(fileURLToPath(import.meta.url));
80const REPO_ROOT = path.resolve(HERE, "..", "..");
81const TEST_DIR = path.join(HERE, "test");
82
83interface Stage {
84 dir: string;
85 label: string;
86 marker: string;
87 /** npm stages get an isolated `bun install`; workspace stages don't. */
88 kind: "npm" | "workspace";
89}
90
91const STAGES: Stage[] = [
92 {
93 dir: "01-beta.39",
94 label: "alchemy@2.0.0-beta.39 (last npm release with state store v4)",
95 marker: "01-beta.39",
96 kind: "npm",
97 },
98 {
99 dir: "02-beta.44",
100 label: "alchemy@2.0.0-beta.44 (last npm release with state store v5)",
101 marker: "02-beta.44",
102 kind: "npm",
103 },
104 {
105 dir: "03-beta.45",
106 label: "alchemy@2.0.0-beta.45 (last npm release with state store v6)",
107 marker: "03-beta.45",
108 kind: "npm",
109 },
110 {
111 dir: "04-beta.59",
112 label: "alchemy@2.0.0-beta.59 (latest v2 on npm, state store v7)",
113 marker: "04-beta.59",
114 kind: "npm",
115 },
116 {
117 dir: "05-current",
118 label: "current branch (workspace source, state store v7)",
119 marker: "05-current",
120 kind: "workspace",
121 },
122];
123
124// The newest version — the target every TEST 2 jump upgrades directly to.
125const LATEST = STAGES[STAGES.length - 1];
126
127// ── arg parsing ───────────────────────────────────────────────────────────
128function parseArgs(argv: string[]) {
129 const out: Record<string, string | boolean> = {};
130 for (let i = 0; i < argv.length; i++) {
131 const a = argv[i];
132 if (!a.startsWith("--")) continue;
133 const key = a.slice(2);
134 const next = argv[i + 1];
135 if (next === undefined || next.startsWith("--")) {
136 out[key] = true;
137 } else {
138 out[key] = next;
139 i++;
140 }
141 }
142 return out;
143}
144
145const args = parseArgs(process.argv.slice(2));
146const PROFILE = (args.profile as string) ?? process.env.ALCHEMY_PROFILE;
147const STAGE = (args.stage as string) ?? "xver";
148const NO_INSTALL = args["no-install"] === true;
149const REUSE_STORE = args["reuse-store"] === true;
150// Seconds to wait after a state-store teardown before redeploying, so the
151// same-named worker's DELETION propagates on workers.dev (otherwise the fresh
152// bootstrap sees the old worker's version — "version not ready").
153const SETTLE = Number(args.settle ?? 25);
154// Retries for the bootstrap step, which can transiently 404/500 while the
155// freshly-deployed store worker's HTTP endpoint comes up.
156const BOOT_RETRIES = Number(args["boot-retries"] ?? 3);
157const GROUP =
158 typeof args.group === "string"
159 ? (args.group as "sequential" | "jump")
160 : undefined;
161
162const RESET = "\x1b[0m";
163const BOLD = "\x1b[1m";
164const GREEN = "\x1b[32m";
165const RED = "\x1b[31m";
166const YELLOW = "\x1b[33m";
167const CYAN = "\x1b[36m";
168
169function banner(msg: string) {
170 const line = "─".repeat(Math.min(78, msg.length + 4));
171 console.log(
172 `\n${CYAN}${line}\n ${BOLD}${msg}${RESET}${CYAN}\n${line}${RESET}`,
173 );
174}
175
176const sleep = (seconds: number) =>
177 new Promise((r) => setTimeout(r, seconds * 1000));
178
179if (!PROFILE) {
180 console.error(
181 `${RED}error: no Cloudflare profile.${RESET}\n` +
182 `Pass --profile <name> or set $ALCHEMY_PROFILE.\n` +
183 `This run mutates the account-wide state store — point it at a\n` +
184 `dedicated/throwaway account, not your shared testing account.`,
185 );
186 process.exit(2);
187}
188
189// ── subprocess helper: stream output live AND capture it ────────────────────
190function run(
191 cmd: string,
192 cmdArgs: string[],
193 opts: { cwd: string },
194): Promise<{ code: number; output: string }> {
195 return new Promise((resolve) => {
196 const printable = `${cmd} ${cmdArgs.join(" ")}`;
197 console.log(`${YELLOW}$ (${path.basename(opts.cwd)}) ${printable}${RESET}`);
198 const child = spawn(cmd, cmdArgs, {
199 cwd: opts.cwd,
200 // CI=true makes the state-store version check FAIL FAST (die) on a
201 // mismatch instead of prompting `Yes/No` — which would hang forever on a
202 // non-TTY subprocess. `--yes` only covers plan approval, not this prompt.
203 env: { ...process.env, ALCHEMY_PROFILE: PROFILE, CI: "true" },
204 shell: process.platform === "win32", // resolve `bun`/.cmd on Windows
205 // No stdin: any prompt that slips past CI=true reads EOF and cancels
206 // rather than blocking forever on a non-TTY pipe.
207 stdio: ["ignore", "pipe", "pipe"],
208 });
209 let output = "";
210 const onData = (buf: Buffer, to: NodeJS.WriteStream) => {
211 const text = buf.toString();
212 output += text;
213 to.write(text);
214 };
215 child.stdout.on("data", (b) => onData(b, process.stdout));
216 child.stderr.on("data", (b) => onData(b, process.stderr));
217 child.on("close", (code) => resolve({ code: code ?? 1, output }));
218 child.on("error", (err) => {
219 output += String(err);
220 console.error(`${RED}spawn error: ${err}${RESET}`);
221 resolve({ code: 1, output });
222 });
223 });
224}
225
226const stageDir = (stage: Stage) => path.join(TEST_DIR, stage.dir);
227
228/**
229 * Clear the state-store bootstrap's LOCAL staging state
230 * (`<stageDir>/.alchemy/state/CloudflareStateStore`). Bootstrap deploys the
231 * state store into this local backend first, then hoists it to the remote store
232 * and deletes it — but a run killed mid-bootstrap leaves it behind, so the next
233 * bootstrap "resumes" it against cloud resources we've since torn down. Wiping
234 * it makes every bootstrap start fresh. (Only the bootstrap uses local state;
235 * the app itself uses the remote store, so this is safe.)
236 */
237function clearLocalBootstrapState(stage: Stage) {
238 const dir = path.join(
239 stageDir(stage),
240 ".alchemy",
241 "state",
242 "CloudflareStateStore",
243 );
244 try {
245 fs.rmSync(dir, { recursive: true, force: true });
246 } catch {
247 /* ignore */
248 }
249}
250
251// `bun run alc -- <subcommand...>` runs the folder's alchemy CLI:
252// - npm stages -> the version pinned in that folder's node_modules
253// - workspace stage -> the workspace `alchemy` bin (current branch source),
254// resolved by walking up to the repo-root node_modules
255function alc(stage: Stage, subArgs: string[]) {
256 return run("bun", ["run", "alc", "--", ...subArgs], { cwd: stageDir(stage) });
257}
258
259function extractUrl(output: string): string | undefined {
260 // Strip ANSI so the regex matches output rendered through the CLI reporter.
261 const clean = output.replace(/\x1b\[[0-9;]*m/g, "");
262 const match = clean.match(/https:\/\/[a-z0-9.-]+\.workers\.dev[^\s"')]*/i);
263 return match?.[0];
264}
265
266// Pull a concise failure reason out of a subprocess's output for the summary.
267// Skips known benign noise (the CLI's tsconfig warning, the upgrade nag) so it
268// doesn't mask the real error — and so the transient-retry check sees the truth.
269const NOISE = /tsconfig|is available|npm_|Run `bun add`/i;
270const MEANINGFUL =
271 /StateStoreError|BadRequest|AuthError|Decode error|Transport error|HttpClientError|ERROR \(#|not found|Unauthorized|Forbidden|version not ready/i;
272function extractError(output: string): string {
273 const clean = output.replace(/\x1b\[[0-9;]*m/g, "");
274 const lines = clean
275 .split("\n")
276 .map((l) => l.trim())
277 .filter(Boolean);
278 // Prefer the CLI's structured "ERROR (#N): <message>" line — that's the real
279 // cause. Falling back to the raw `_tag:`/`[cause]:` object-dump lines both
280 // misreports AND defeats the transient-retry check.
281 for (const l of lines) {
282 const m = l.match(/ERROR \(#\d+\):\s*(.+)/);
283 if (m?.[1] && !NOISE.test(m[1])) return m[1].slice(0, 240);
284 }
285 const line = lines.find(
286 (l) =>
287 MEANINGFUL.test(l) &&
288 !NOISE.test(l) &&
289 !/^_tag:|^\[cause\]:|^"~effect/.test(l),
290 );
291 return (line ?? "unknown error").slice(0, 240);
292}
293
294// Transient state-store bootstrap/deploy failures that clear once the freshly
295// (re)deployed worker propagates on workers.dev — retry these.
296const TRANSIENT =
297 /Decode error|not ready|version not ready|not found|Transport error|\b404\b|\b500\b|\b502\b|\b503\b|fetch failed|ECONN|ETIMEDOUT/i;
298
299async function verify(url: string, expectedMarker: string): Promise<void> {
300 // Fresh / freshly-updated workers.dev routes transiently 404/5xx while the
301 // edge propagates, so retry with backoff.
302 let lastErr: unknown;
303 for (let attempt = 1; attempt <= 12; attempt++) {
304 try {
305 const res = await fetch(url);
306 if (res.ok) {
307 const body = (await res.json()) as { marker?: string };
308 if (body.marker === expectedMarker) {
309 console.log(
310 `${GREEN}✓ live worker serves marker "${body.marker}"${RESET}`,
311 );
312 return;
313 }
314 throw new Error(
315 `marker mismatch: got "${body.marker}", expected "${expectedMarker}"`,
316 );
317 }
318 lastErr = new Error(`HTTP ${res.status}`);
319 } catch (err) {
320 lastErr = err;
321 }
322 await new Promise((r) => setTimeout(r, Math.min(1000 * attempt, 5000)));
323 }
324 throw new Error(
325 `verification failed for ${url} (expected marker "${expectedMarker}"): ${lastErr}`,
326 );
327}
328
329// ── stage primitives ────────────────────────────────────────────────────────
330const installed = new Set<string>();
331
332async function ensureInstalled(stage: Stage) {
333 if (stage.kind !== "npm" || NO_INSTALL || installed.has(stage.dir)) return;
334 const r = await run("bun", ["install"], { cwd: stageDir(stage) });
335 if (r.code !== 0) throw new Error(`install failed for ${stage.dir}`);
336 installed.add(stage.dir);
337}
338
339/**
340 * Run an alchemy CLI subcommand, retrying on TRANSIENT state-store failures
341 * (404/500 hoisting into a freshly-deployed worker, stale version, transport
342 * blips → "store not found"). A non-transient failure (e.g. the real v5→v6
343 * `500 GET … DecodeError` read incompat) is thrown on the first attempt-set.
344 */
345async function alcRetry(
346 stage: Stage,
347 argv: string[],
348 what: string,
349): Promise<{ code: number; output: string }> {
350 let err = "";
351 for (let attempt = 1; attempt <= Math.max(1, BOOT_RETRIES); attempt++) {
352 if (what === "bootstrap") clearLocalBootstrapState(stage); // never resume a stale local stack
353 const r = await alc(stage, argv);
354 if (r.code === 0) return r;
355 err = extractError(r.output);
356 if (attempt < BOOT_RETRIES && TRANSIENT.test(err)) {
357 console.log(
358 `${YELLOW}${what} attempt ${attempt}/${BOOT_RETRIES} failed (${err}) — settling ${SETTLE}s and retrying${RESET}`,
359 );
360 await sleep(SETTLE);
361 continue;
362 }
363 break;
364 }
365 throw new Error(`${what} failed for ${stage.dir}: ${err}`);
366}
367
368/**
369 * Deploy (or in-place upgrade) the app to `stage`'s version under the given
370 * alchemy stage, and assert the live worker serves that version's marker.
371 * Bootstraps the account-wide state store to the version's STATE_STORE_VERSION
372 * first (idempotent; handles both up- and down-grades), which keeps the deploy
373 * non-interactive. Both bootstrap and deploy retry on transient errors.
374 */
375async function deployAndVerify(
376 stage: Stage,
377 alchemyStage: string,
378): Promise<void> {
379 await ensureInstalled(stage);
380
381 const cloudflareCommand =
382 stage.kind === "workspace"
383 ? ["provider", "cloudflare"]
384 : ["cloudflare"];
385 await alcRetry(
386 stage,
387 [...cloudflareCommand, "bootstrap", "--profile", PROFILE!],
388 "bootstrap",
389 );
390
391 const dep = await alcRetry(
392 stage,
393 // --adopt: take over a pre-existing fixed-name worker instead of failing.
394 ["deploy", "--yes", "--adopt", "--stage", alchemyStage, "--profile", PROFILE!],
395 "deploy",
396 );
397
398 const url = extractUrl(dep.output);
399 if (!url) {
400 throw new Error(`could not determine worker URL for ${stage.dir}`);
401 }
402 console.log(`worker url: ${url}`);
403 await verify(url, stage.marker);
404}
405
406async function destroyApp(stage: Stage, alchemyStage: string) {
407 const r = await alc(stage, [
408 "destroy",
409 "--yes",
410 "--stage",
411 alchemyStage,
412 "--profile",
413 PROFILE!,
414 ]);
415 if (r.code !== 0) {
416 console.error(
417 `${RED}warning: destroy failed (stage ${alchemyStage}) — clean up manually.${RESET}`,
418 );
419 } else {
420 console.log(`${GREEN}✓ app destroyed (stage ${alchemyStage})${RESET}`);
421 }
422}
423
424/**
425 * Delete the account-wide Cloudflare state store (worker + secrets store) via
426 * the current-branch CLI's `provider cloudflare teardown` (idempotent). Run
427 * between units so each one deploys a FRESH state store rather than inheriting
428 * the previous unit's (possibly downgraded) one.
429 */
430async function teardownStore(reason: string) {
431 console.log(`${YELLOW}↺ tearing down state store (${reason})${RESET}`);
432 const r = await alc(LATEST, [
433 "provider",
434 "cloudflare",
435 "teardown",
436 "--profile",
437 PROFILE!,
438 ]);
439 if (r.code !== 0) {
440 console.error(
441 `${RED}warning: state store teardown failed — remove it manually.${RESET}`,
442 );
443 }
444 // Let the worker DELETION propagate on workers.dev before anything redeploys
445 // the same-named worker — otherwise the fresh bootstrap sees the old version.
446 if (SETTLE > 0) {
447 console.log(`${YELLOW} settling ${SETTLE}s for edge propagation…${RESET}`);
448 await sleep(SETTLE);
449 }
450}
451
452const stageByDir = (dir: string): Stage => {
453 const s = STAGES.find((x) => x.dir === dir);
454 if (!s) throw new Error(`unknown stage dir: ${dir}`);
455 return s;
456};
457
458// ── The upgrade paths under test ─────────────────────────────────────────────
459// Each edge = "deploy `from`, then upgrade the same app to `to`" on its OWN
460// fresh state store. The suite runs EVERY edge independently and force-through
461// (a failure is recorded, never aborts the run). Comment an edge out (and note
462// why) to mark it KNOWN BAD and skip it.
463interface Edge {
464 from: string; // stage dir
465 to: string; // stage dir
466 group: "sequential" | "jump";
467}
468
469const EDGES: Edge[] = [
470 // ── sequential 1-by-1 upgrade steps (npm → npm) ──
471 { from: "01-beta.39", to: "02-beta.44", group: "sequential" }, // v4 → v5
472 // KNOWN BAD (v5 → v6): after the store is upgraded to v6 (beta.45), reading a
473 // record written under ≤v5 fails with `500 GET … DecodeError`. beta.45's
474 // legacy-record read (the createdAt/updatedAt reshape, PR #427) is broken;
475 // it's fixed in v7 — upgrading v5 straight to current works (see the v5 →
476 // worktree jump). So don't step through beta.45 with pre-v6 state; skip to
477 // beta.46+.
478 // { from: "02-beta.44", to: "03-beta.45", group: "sequential" }, // v5 → v6
479 { from: "03-beta.45", to: "04-beta.59", group: "sequential" }, // v6 → v7
480 { from: "04-beta.59", to: "05-current", group: "sequential" }, // v7 → worktree
481 // ── direct-to-latest jumps (→ worktree, skipping the middle) ──
482 // KNOWN BAD (v4 → worktree): the current state-store client can't write to a
483 // v4-format store — upgrading it fails with `415 Unsupported Media Type` on
484 // `PUT …/StateStoreEncryptionKey` (deterministic; fails every retry). v4
485 // (beta.37–39) predates the RPC state-store rewrite at v5, so its store HTTP
486 // API is wire-incompatible with current. v5/v6/v7 → worktree all work, so
487 // step a v4 store up to ≥v5 before jumping to latest.
488 // { from: "01-beta.39", to: "05-current", group: "jump" }, // v4 → worktree
489 { from: "02-beta.44", to: "05-current", group: "jump" }, // v5 → worktree
490 { from: "03-beta.45", to: "05-current", group: "jump" }, // v6 → worktree
491];
492
493const EDGE_STAGE = STAGE; // one alchemy stage; each edge gets a fresh store anyway
494
495interface StepResult {
496 name: string;
497 status: "ok" | "fail" | "skip";
498 error?: string;
499}
500interface EdgeResult {
501 label: string;
502 group: string;
503 worktree: boolean;
504 steps: StepResult[];
505 failed: boolean;
506}
507
508async function tryStep(name: string, fn: () => Promise<void>): Promise<StepResult> {
509 try {
510 await fn();
511 console.log(`${GREEN}✓ ${name}${RESET}`);
512 return { name, status: "ok" };
513 } catch (err) {
514 const msg = err instanceof Error ? err.message : String(err);
515 console.error(`${RED}✗ ${name}: ${msg}${RESET}`);
516 return { name, status: "fail", error: msg };
517 }
518}
519
520async function runEdge(edge: Edge): Promise<EdgeResult> {
521 const from = stageByDir(edge.from);
522 const to = stageByDir(edge.to);
523 const worktree = from.kind === "workspace" || to.kind === "workspace";
524 const label = `${from.dir} → ${to.dir}`;
525 banner(`EDGE ${label} [${edge.group}${worktree ? ", worktree" : ""}]`);
526
527 // Fresh store for this edge.
528 clearLocalBootstrapState(from);
529 clearLocalBootstrapState(to);
530 if (!REUSE_STORE) await teardownStore(`fresh for ${label}`);
531
532 const steps: StepResult[] = [];
533
534 const deployStep = await tryStep(`deploy ${from.dir}`, () =>
535 deployAndVerify(from, EDGE_STAGE),
536 );
537 steps.push(deployStep);
538
539 if (deployStep.status === "ok") {
540 steps.push(
541 await tryStep(`upgrade → ${to.dir}`, () => deployAndVerify(to, EDGE_STAGE)),
542 );
543 } else {
544 steps.push({
545 name: `upgrade → ${to.dir}`,
546 status: "skip",
547 error: "prerequisite deploy failed",
548 });
549 }
550
551 // Best-effort cleanup so the next edge starts clean.
552 await destroyApp(to, EDGE_STAGE).catch(() => {});
553 if (!REUSE_STORE) await teardownStore(`after ${label}`).catch(() => {});
554
555 return {
556 label,
557 group: edge.group,
558 worktree,
559 steps,
560 failed: steps.some((s) => s.status !== "ok"),
561 };
562}
563
564// ── main ────────────────────────────────────────────────────────────────────
565async function main() {
566 const edges = EDGES.filter((e) => !GROUP || e.group === GROUP);
567
568 banner("alchemy cross-version test suite (force-through)");
569 console.log(`profile : ${PROFILE}`);
570 console.log(`edges : ${edges.length}`);
571 console.log(`store : ${REUSE_STORE ? "reused" : "FRESH per edge"}`);
572 console.log(`repo : ${REPO_ROOT}`);
573 console.log(
574 `${YELLOW}note: bootstraps state store v4 — run against a dedicated account.${RESET}`,
575 );
576
577 if (!REUSE_STORE) await teardownStore("pre-clean");
578
579 const results: EdgeResult[] = [];
580 for (const edge of edges) {
581 results.push(await runEdge(edge));
582 }
583
584 // ── summary ────────────────────────────────────────────────────────────--
585 banner("SUMMARY");
586 for (const r of results) {
587 const mark = r.failed ? `${RED}FAIL${RESET}` : `${GREEN}PASS${RESET}`;
588 const tag = r.worktree ? " (worktree)" : "";
589 console.log(`${mark} [${r.group}] ${r.label}${tag}`);
590 for (const s of r.steps) {
591 const sym =
592 s.status === "ok" ? `${GREEN}✓${RESET}` : s.status === "skip" ? `${YELLOW}∅${RESET}` : `${RED}✗${RESET}`;
593 console.log(` ${sym} ${s.name}${s.error ? ` — ${s.error}` : ""}`);
594 }
595 }
596
597 // Machine-readable block for downstream parsing.
598 console.log("\n===RESULTS_JSON===");
599 console.log(
600 JSON.stringify(
601 results.map((r) => ({
602 label: r.label,
603 group: r.group,
604 worktree: r.worktree,
605 failed: r.failed,
606 steps: r.steps.map((s) => ({
607 name: s.name,
608 status: s.status,
609 error: s.error,
610 })),
611 })),
612 null,
613 2,
614 ),
615 );
616 console.log("===END_RESULTS_JSON===");
617
618 const failed = results.filter((r) => r.failed);
619 banner(
620 failed.length === 0
621 ? `${GREEN}ALL EDGES PASSED${RESET}`
622 : `${RED}${failed.length}/${results.length} EDGES FAILED${RESET}`,
623 );
624}
625
626main().catch((err) => {
627 console.error(`\n${RED}${BOLD}suite crashed:${RESET} ${err}`);
628 process.exit(1);
629});