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 * as path from "node:path";
77import { fileURLToPath } from "node:url";
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 = typeof args.group === "string" ? (args.group as "sequential" | "jump") : undefined;
158
159const RESET = "\x1b[0m";
160const BOLD = "\x1b[1m";
161const GREEN = "\x1b[32m";
162const RED = "\x1b[31m";
163const YELLOW = "\x1b[33m";
164const CYAN = "\x1b[36m";
165
166function banner(msg: string) {
167 const line = "─".repeat(Math.min(78, msg.length + 4));
168 console.log(`\n${CYAN}${line}\n ${BOLD}${msg}${RESET}${CYAN}\n${line}${RESET}`);
169}
170
171const sleep = (seconds: number) => new Promise((r) => setTimeout(r, seconds * 1000));
172
173if (!PROFILE) {
174 console.error(
175 `${RED}error: no Cloudflare profile.${RESET}\n` +
176 `Pass --profile <name> or set $ALCHEMY_PROFILE.\n` +
177 `This run mutates the account-wide state store — point it at a\n` +
178 `dedicated/throwaway account, not your shared testing account.`,
179 );
180 process.exit(2);
181}
182
183// ── subprocess helper: stream output live AND capture it ────────────────────
184function run(
185 cmd: string,
186 cmdArgs: string[],
187 opts: { cwd: string },
188): Promise<{ code: number; output: string }> {
189 return new Promise((resolve) => {
190 const printable = `${cmd} ${cmdArgs.join(" ")}`;
191 console.log(`${YELLOW}$ (${path.basename(opts.cwd)}) ${printable}${RESET}`);
192 const child = spawn(cmd, cmdArgs, {
193 cwd: opts.cwd,
194 // CI=true makes the state-store version check FAIL FAST (die) on a
195 // mismatch instead of prompting `Yes/No` — which would hang forever on a
196 // non-TTY subprocess. `--yes` only covers plan approval, not this prompt.
197 env: { ...process.env, ALCHEMY_PROFILE: PROFILE, CI: "true" },
198 shell: process.platform === "win32", // resolve `bun`/.cmd on Windows
199 // No stdin: any prompt that slips past CI=true reads EOF and cancels
200 // rather than blocking forever on a non-TTY pipe.
201 stdio: ["ignore", "pipe", "pipe"],
202 });
203 let output = "";
204 const onData = (buf: Buffer, to: NodeJS.WriteStream) => {
205 const text = buf.toString();
206 output += text;
207 to.write(text);
208 };
209 child.stdout.on("data", (b) => onData(b, process.stdout));
210 child.stderr.on("data", (b) => onData(b, process.stderr));
211 child.on("close", (code) => resolve({ code: code ?? 1, output }));
212 child.on("error", (err) => {
213 output += String(err);
214 console.error(`${RED}spawn error: ${err}${RESET}`);
215 resolve({ code: 1, output });
216 });
217 });
218}
219
220const stageDir = (stage: Stage) => path.join(TEST_DIR, stage.dir);
221
222/**
223 * Clear the state-store bootstrap's LOCAL staging state
224 * (`<stageDir>/.alchemy/state/CloudflareStateStore`). Bootstrap deploys the
225 * state store into this local backend first, then hoists it to the remote store
226 * and deletes it — but a run killed mid-bootstrap leaves it behind, so the next
227 * bootstrap "resumes" it against cloud resources we've since torn down. Wiping
228 * it makes every bootstrap start fresh. (Only the bootstrap uses local state;
229 * the app itself uses the remote store, so this is safe.)
230 */
231function clearLocalBootstrapState(stage: Stage) {
232 const dir = path.join(stageDir(stage), ".alchemy", "state", "CloudflareStateStore");
233 try {
234 fs.rmSync(dir, { recursive: true, force: true });
235 } catch {
236 /* ignore */
237 }
238}
239
240// `bun run alc -- <subcommand...>` runs the folder's alchemy CLI:
241// - npm stages -> the version pinned in that folder's node_modules
242// - workspace stage -> the workspace `alchemy` bin (current branch source),
243// resolved by walking up to the repo-root node_modules
244function alc(stage: Stage, subArgs: string[]) {
245 return run("bun", ["run", "alc", "--", ...subArgs], { cwd: stageDir(stage) });
246}
247
248function extractUrl(output: string): string | undefined {
249 // Strip ANSI so the regex matches output rendered through the CLI reporter.
250 // oxlint-disable-next-line no-control-regex
251 const clean = output.replace(/\x1b\[[0-9;]*m/g, "");
252 const match = clean.match(/https:\/\/[a-z0-9.-]+\.workers\.dev[^\s"')]*/i);
253 return match?.[0];
254}
255
256// Pull a concise failure reason out of a subprocess's output for the summary.
257// Skips known benign noise (the CLI's tsconfig warning, the upgrade nag) so it
258// doesn't mask the real error — and so the transient-retry check sees the truth.
259const NOISE = /tsconfig|is available|npm_|Run `bun add`/i;
260const MEANINGFUL =
261 /StateStoreError|BadRequest|AuthError|Decode error|Transport error|HttpClientError|ERROR \(#|not found|Unauthorized|Forbidden|version not ready/i;
262function extractError(output: string): string {
263 // oxlint-disable-next-line no-control-regex
264 const clean = output.replace(/\x1b\[[0-9;]*m/g, "");
265 const lines = clean
266 .split("\n")
267 .map((l) => l.trim())
268 .filter(Boolean);
269 // Prefer the CLI's structured "ERROR (#N): <message>" line — that's the real
270 // cause. Falling back to the raw `_tag:`/`[cause]:` object-dump lines both
271 // misreports AND defeats the transient-retry check.
272 for (const l of lines) {
273 const m = l.match(/ERROR \(#\d+\):\s*(.+)/);
274 if (m?.[1] && !NOISE.test(m[1])) return m[1].slice(0, 240);
275 }
276 const line = lines.find(
277 (l) => MEANINGFUL.test(l) && !NOISE.test(l) && !/^_tag:|^\[cause\]:|^"~effect/.test(l),
278 );
279 return (line ?? "unknown error").slice(0, 240);
280}
281
282// Transient state-store bootstrap/deploy failures that clear once the freshly
283// (re)deployed worker propagates on workers.dev — retry these.
284const TRANSIENT =
285 /Decode error|not ready|version not ready|not found|Transport error|\b404\b|\b500\b|\b502\b|\b503\b|fetch failed|ECONN|ETIMEDOUT/i;
286
287async function verify(url: string, expectedMarker: string): Promise<void> {
288 // Fresh / freshly-updated workers.dev routes transiently 404/5xx while the
289 // edge propagates, so retry with backoff.
290 let lastErr: unknown;
291 for (let attempt = 1; attempt <= 12; attempt++) {
292 try {
293 const res = await fetch(url);
294 if (res.ok) {
295 const body = (await res.json()) as { marker?: string };
296 if (body.marker === expectedMarker) {
297 console.log(`${GREEN}✓ live worker serves marker "${body.marker}"${RESET}`);
298 return;
299 }
300 throw new Error(`marker mismatch: got "${body.marker}", expected "${expectedMarker}"`);
301 }
302 lastErr = new Error(`HTTP ${res.status}`);
303 } catch (err) {
304 lastErr = err;
305 }
306 await new Promise((r) => setTimeout(r, Math.min(1000 * attempt, 5000)));
307 }
308 throw new Error(
309 `verification failed for ${url} (expected marker "${expectedMarker}"): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
310 );
311}
312
313// ── stage primitives ────────────────────────────────────────────────────────
314const installed = new Set<string>();
315
316async function ensureInstalled(stage: Stage) {
317 if (stage.kind !== "npm" || NO_INSTALL || installed.has(stage.dir)) return;
318 const r = await run("bun", ["install"], { cwd: stageDir(stage) });
319 if (r.code !== 0) throw new Error(`install failed for ${stage.dir}`);
320 installed.add(stage.dir);
321}
322
323/**
324 * Run an alchemy CLI subcommand, retrying on TRANSIENT state-store failures
325 * (404/500 hoisting into a freshly-deployed worker, stale version, transport
326 * blips → "store not found"). A non-transient failure (e.g. the real v5→v6
327 * `500 GET … DecodeError` read incompat) is thrown on the first attempt-set.
328 */
329async function alcRetry(
330 stage: Stage,
331 argv: string[],
332 what: string,
333): Promise<{ code: number; output: string }> {
334 let err = "";
335 for (let attempt = 1; attempt <= Math.max(1, BOOT_RETRIES); attempt++) {
336 if (what === "bootstrap") clearLocalBootstrapState(stage); // never resume a stale local stack
337 const r = await alc(stage, argv);
338 if (r.code === 0) return r;
339 err = extractError(r.output);
340 if (attempt < BOOT_RETRIES && TRANSIENT.test(err)) {
341 console.log(
342 `${YELLOW}${what} attempt ${attempt}/${BOOT_RETRIES} failed (${err}) — settling ${SETTLE}s and retrying${RESET}`,
343 );
344 await sleep(SETTLE);
345 continue;
346 }
347 break;
348 }
349 throw new Error(`${what} failed for ${stage.dir}: ${err}`);
350}
351
352/**
353 * Deploy (or in-place upgrade) the app to `stage`'s version under the given
354 * alchemy stage, and assert the live worker serves that version's marker.
355 * Bootstraps the account-wide state store to the version's STATE_STORE_VERSION
356 * first (idempotent; handles both up- and down-grades), which keeps the deploy
357 * non-interactive. Both bootstrap and deploy retry on transient errors.
358 */
359async function deployAndVerify(stage: Stage, alchemyStage: string): Promise<void> {
360 await ensureInstalled(stage);
361
362 const cloudflareCommand =
363 stage.kind === "workspace" ? ["provider", "cloudflare"] : ["cloudflare"];
364 await alcRetry(stage, [...cloudflareCommand, "bootstrap", "--profile", PROFILE!], "bootstrap");
365
366 const dep = await alcRetry(
367 stage,
368 // --adopt: take over a pre-existing fixed-name worker instead of failing.
369 ["deploy", "--yes", "--adopt", "--stage", alchemyStage, "--profile", PROFILE!],
370 "deploy",
371 );
372
373 const url = extractUrl(dep.output);
374 if (!url) {
375 throw new Error(`could not determine worker URL for ${stage.dir}`);
376 }
377 console.log(`worker url: ${url}`);
378 await verify(url, stage.marker);
379}
380
381async function destroyApp(stage: Stage, alchemyStage: string) {
382 const r = await alc(stage, ["destroy", "--yes", "--stage", alchemyStage, "--profile", PROFILE!]);
383 if (r.code !== 0) {
384 console.error(
385 `${RED}warning: destroy failed (stage ${alchemyStage}) — clean up manually.${RESET}`,
386 );
387 } else {
388 console.log(`${GREEN}✓ app destroyed (stage ${alchemyStage})${RESET}`);
389 }
390}
391
392/**
393 * Delete the account-wide Cloudflare state store (worker + secrets store) via
394 * the current-branch CLI's `provider cloudflare teardown` (idempotent). Run
395 * between units so each one deploys a FRESH state store rather than inheriting
396 * the previous unit's (possibly downgraded) one.
397 */
398async function teardownStore(reason: string) {
399 console.log(`${YELLOW}↺ tearing down state store (${reason})${RESET}`);
400 const r = await alc(LATEST, ["provider", "cloudflare", "teardown", "--profile", PROFILE!]);
401 if (r.code !== 0) {
402 console.error(`${RED}warning: state store teardown failed — remove it manually.${RESET}`);
403 }
404 // Let the worker DELETION propagate on workers.dev before anything redeploys
405 // the same-named worker — otherwise the fresh bootstrap sees the old version.
406 if (SETTLE > 0) {
407 console.log(`${YELLOW} settling ${SETTLE}s for edge propagation…${RESET}`);
408 await sleep(SETTLE);
409 }
410}
411
412const stageByDir = (dir: string): Stage => {
413 const s = STAGES.find((x) => x.dir === dir);
414 if (!s) throw new Error(`unknown stage dir: ${dir}`);
415 return s;
416};
417
418// ── The upgrade paths under test ─────────────────────────────────────────────
419// Each edge = "deploy `from`, then upgrade the same app to `to`" on its OWN
420// fresh state store. The suite runs EVERY edge independently and force-through
421// (a failure is recorded, never aborts the run). Comment an edge out (and note
422// why) to mark it KNOWN BAD and skip it.
423interface Edge {
424 from: string; // stage dir
425 to: string; // stage dir
426 group: "sequential" | "jump";
427}
428
429const EDGES: Edge[] = [
430 // ── sequential 1-by-1 upgrade steps (npm → npm) ──
431 { from: "01-beta.39", to: "02-beta.44", group: "sequential" }, // v4 → v5
432 // KNOWN BAD (v5 → v6): after the store is upgraded to v6 (beta.45), reading a
433 // record written under ≤v5 fails with `500 GET … DecodeError`. beta.45's
434 // legacy-record read (the createdAt/updatedAt reshape, PR #427) is broken;
435 // it's fixed in v7 — upgrading v5 straight to current works (see the v5 →
436 // worktree jump). So don't step through beta.45 with pre-v6 state; skip to
437 // beta.46+.
438 // { from: "02-beta.44", to: "03-beta.45", group: "sequential" }, // v5 → v6
439 { from: "03-beta.45", to: "04-beta.59", group: "sequential" }, // v6 → v7
440 { from: "04-beta.59", to: "05-current", group: "sequential" }, // v7 → worktree
441 // ── direct-to-latest jumps (→ worktree, skipping the middle) ──
442 // KNOWN BAD (v4 → worktree): the current state-store client can't write to a
443 // v4-format store — upgrading it fails with `415 Unsupported Media Type` on
444 // `PUT …/StateStoreEncryptionKey` (deterministic; fails every retry). v4
445 // (beta.37–39) predates the RPC state-store rewrite at v5, so its store HTTP
446 // API is wire-incompatible with current. v5/v6/v7 → worktree all work, so
447 // step a v4 store up to ≥v5 before jumping to latest.
448 // { from: "01-beta.39", to: "05-current", group: "jump" }, // v4 → worktree
449 { from: "02-beta.44", to: "05-current", group: "jump" }, // v5 → worktree
450 { from: "03-beta.45", to: "05-current", group: "jump" }, // v6 → worktree
451];
452
453const EDGE_STAGE = STAGE; // one alchemy stage; each edge gets a fresh store anyway
454
455interface StepResult {
456 name: string;
457 status: "ok" | "fail" | "skip";
458 error?: string;
459}
460interface EdgeResult {
461 label: string;
462 group: string;
463 worktree: boolean;
464 steps: StepResult[];
465 failed: boolean;
466}
467
468async function tryStep(name: string, fn: () => Promise<void>): Promise<StepResult> {
469 try {
470 await fn();
471 console.log(`${GREEN}✓ ${name}${RESET}`);
472 return { name, status: "ok" };
473 } catch (err) {
474 const msg = err instanceof Error ? err.message : String(err);
475 console.error(`${RED}✗ ${name}: ${msg}${RESET}`);
476 return { name, status: "fail", error: msg };
477 }
478}
479
480async function runEdge(edge: Edge): Promise<EdgeResult> {
481 const from = stageByDir(edge.from);
482 const to = stageByDir(edge.to);
483 const worktree = from.kind === "workspace" || to.kind === "workspace";
484 const label = `${from.dir} → ${to.dir}`;
485 banner(`EDGE ${label} [${edge.group}${worktree ? ", worktree" : ""}]`);
486
487 // Fresh store for this edge.
488 clearLocalBootstrapState(from);
489 clearLocalBootstrapState(to);
490 if (!REUSE_STORE) await teardownStore(`fresh for ${label}`);
491
492 const steps: StepResult[] = [];
493
494 const deployStep = await tryStep(`deploy ${from.dir}`, () => deployAndVerify(from, EDGE_STAGE));
495 steps.push(deployStep);
496
497 if (deployStep.status === "ok") {
498 steps.push(await tryStep(`upgrade → ${to.dir}`, () => deployAndVerify(to, EDGE_STAGE)));
499 } else {
500 steps.push({
501 name: `upgrade → ${to.dir}`,
502 status: "skip",
503 error: "prerequisite deploy failed",
504 });
505 }
506
507 // Best-effort cleanup so the next edge starts clean.
508 await destroyApp(to, EDGE_STAGE).catch(() => {});
509 if (!REUSE_STORE) await teardownStore(`after ${label}`).catch(() => {});
510
511 return {
512 label,
513 group: edge.group,
514 worktree,
515 steps,
516 failed: steps.some((s) => s.status !== "ok"),
517 };
518}
519
520// ── main ────────────────────────────────────────────────────────────────────
521async function main() {
522 const edges = EDGES.filter((e) => !GROUP || e.group === GROUP);
523
524 banner("alchemy cross-version test suite (force-through)");
525 console.log(`profile : ${PROFILE}`);
526 console.log(`edges : ${edges.length}`);
527 console.log(`store : ${REUSE_STORE ? "reused" : "FRESH per edge"}`);
528 console.log(`repo : ${REPO_ROOT}`);
529 console.log(
530 `${YELLOW}note: bootstraps state store v4 — run against a dedicated account.${RESET}`,
531 );
532
533 if (!REUSE_STORE) await teardownStore("pre-clean");
534
535 const results: EdgeResult[] = [];
536 for (const edge of edges) {
537 results.push(await runEdge(edge));
538 }
539
540 // ── summary ────────────────────────────────────────────────────────────--
541 banner("SUMMARY");
542 for (const r of results) {
543 const mark = r.failed ? `${RED}FAIL${RESET}` : `${GREEN}PASS${RESET}`;
544 const tag = r.worktree ? " (worktree)" : "";
545 console.log(`${mark} [${r.group}] ${r.label}${tag}`);
546 for (const s of r.steps) {
547 const sym =
548 s.status === "ok"
549 ? `${GREEN}✓${RESET}`
550 : s.status === "skip"
551 ? `${YELLOW}∅${RESET}`
552 : `${RED}✗${RESET}`;
553 console.log(` ${sym} ${s.name}${s.error ? ` — ${s.error}` : ""}`);
554 }
555 }
556
557 // Machine-readable block for downstream parsing.
558 console.log("\n===RESULTS_JSON===");
559 console.log(
560 JSON.stringify(
561 results.map((r) => ({
562 label: r.label,
563 group: r.group,
564 worktree: r.worktree,
565 failed: r.failed,
566 steps: r.steps.map((s) => ({
567 name: s.name,
568 status: s.status,
569 error: s.error,
570 })),
571 })),
572 null,
573 2,
574 ),
575 );
576 console.log("===END_RESULTS_JSON===");
577
578 const failed = results.filter((r) => r.failed);
579 banner(
580 failed.length === 0
581 ? `${GREEN}ALL EDGES PASSED${RESET}`
582 : `${RED}${failed.length}/${results.length} EDGES FAILED${RESET}`,
583 );
584}
585
586main().catch((err) => {
587 console.error(`\n${RED}${BOLD}suite crashed:${RESET} ${err}`);
588 process.exit(1);
589});