#!/usr/bin/env bun /** * validate.ts — the Festipod validation matrix. * * SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in * the gate: * * (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo; * the layer is exactly the complement of the polyfill/broker tags, and its * command is, verbatim: * node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js \ * --config cucumber.json \ * --tags 'not @wip and not @humain and not @data and not @e2e and not @multibrowser' * `cucumber.json` additionally excludes `@suspendu` and `@e2e-activitypods` * (Cucumber ANDs the config tags with the CLI ones), so this stays the * stack-free UI layer exactly as verified at 125 scenarios / 0 failed. * * (b) Festipod @e2e-activitypods — the smoke over the LOCAL ActivityPods stack * (pod provider + Festipod backend + the app served for real). Distinct * from the suspended `@e2e`: no wallet, no broker, so `cucumber.json` * excludes it. Its config is `cucumber.smoke.json`; its reproducible setup * is the concept note `data-layer`, `cookbook_local-activitypods-platform.md`. * Requires the stack to be UP (the app server and Chromium are started * here); an unreachable stack fails the layer rather than skipping it. * * SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted): * - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are * polyfill/broker-bound: they sign the shared NextGraph wallet through the * harness (`src/shared/test-harness/harness-ng.tsx`). They are tagged * `@suspendu` in the feature files and excluded there, awaiting the NextGraph * workstream. * - the SDK's own tests (nextgraph/ng-eventually-js) are NOT Festipod's and are * no longer run from here (concept `bdd-testing`, * `rule_tests-validate-festipod-not-the-sdk`). * * A smoke layer that cannot reach its stack FAILS, loudly — it never silently * skips. Exit code is non-zero if any layer fails. */ import { spawnSync } from "child_process"; // ─── Config ──────────────────────────────────────────────────────────────── const REPO_DIR = process.cwd(); /** The UI layer, verbatim — see the header. */ const UI_TAGS = "not @wip and not @humain and not @data and not @e2e and not @multibrowser"; /** The ActivityPods smoke, minus its documented suspends. */ const APODS_TAGS = "@e2e-activitypods and not @suspendu"; const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min // ─── Helpers ─────────────────────────────────────────────────────────────── interface StepResult { label: string; status: "passed" | "failed" | "error"; failures: string[]; exitCode: number; durationMs: number; } /** The Cucumber invocation every layer shares (same form as the UI gate command). */ function cucumberArgs(config: string, tags: string): string[] { return [ "--import", "tsx/esm", "node_modules/@cucumber/cucumber/bin/cucumber.js", "--config", config, "--tags", tags, ]; } function runStep(label: string, args: string[], timeoutMs: number): StepResult { const t0 = Date.now(); console.log(`\n${"═".repeat(64)}`); console.log(`▶ ${label}`); console.log(` node ${args.join(" ")}`); console.log(`${"═".repeat(64)}`); const result = spawnSync("node", args, { cwd: REPO_DIR, env: process.env, encoding: "utf-8", timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024, }); const durationMs = Date.now() - t0; const combined = (result.stdout ?? "") + "\n" + (result.stderr ?? ""); process.stdout.write(result.stdout ?? ""); process.stderr.write(result.stderr ?? ""); if (result.error) { return { label, status: "error", failures: [`Process error: ${result.error.message}`], exitCode: result.status ?? 1, durationMs, }; } return { label, status: (result.status ?? 1) === 0 ? "passed" : "failed", failures: extractFailures(combined), exitCode: result.status ?? 1, durationMs, }; } /** The failure lines worth surfacing: failed scenario names and the summary. */ function extractFailures(output: string): string[] { const lines = output.split("\n"); const failures: string[] = []; for (const line of lines) { const l = line.trim(); if (/^(✗|✕|×|✖|✘)\s/.test(l)) failures.push(l); if (/\d+ sc[eé]narios?.*(failed|undefined)/.test(l) && !failures.includes(l)) { failures.push(l); } } return failures.filter(Boolean); } function fmtDuration(ms: number): string { if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60_000)}m${((ms % 60_000) / 1000).toFixed(0)}s`; } function printMatrix(steps: StepResult[]): void { console.log("\n"); console.log("╔══════════════════════════════════════════════════════════╗"); console.log("║ VALIDATION MATRIX ║"); console.log("╚══════════════════════════════════════════════════════════╝\n"); const maxLabel = Math.max(...steps.map((s) => s.label.length)); for (const step of steps) { const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ "; console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`); for (const f of step.failures) console.log(` ↳ ${f}`); } console.log(""); console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph)."); const failed = steps.filter((s) => s.status !== "passed"); console.log(failed.length === 0 ? " 🟢 ALL LAYERS PASSED" : ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`); console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`); console.log(""); } // ─── Main ────────────────────────────────────────────────────────────────── function main(): void { console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`); const steps: StepResult[] = []; steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS)); steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS)); printMatrix(steps); process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0); } main();