#!/usr/bin/env bun /** * validate.ts — the Festipod validation matrix. * * SCOPE (2026-10-09, after the ActivityPods-first switch; e2e probes added * 2026-10-13). Four 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. * * (c) Festipod e2e rights matrix — `bun run test:e2e-rights`: three fresh * users plus anonymous, every feature with positive AND negative access * lines (public surface, public reads, refused writes, profile privacy, * catalog and public intent, discovery, join/leave and counters, * visibility toggles). Exit 0 only when every hard line holds. * * (d) Festipod e2e revocation — `bun run test:e2e-revocation`: the app * notices its registration being revoked on the pod, logs the user out, * and recovers through consent. * * (c) and (d) drive the browser through the pod's login and consent, so * they need the WHOLE local stack, the app served at APP_URL included * (the OIDC redirect_uri is registered for that origin only). They are * STACK-GATED: the stack is probed first; when any part is unreachable * both are SKIPPED — loudly, the unreachable URL named in the matrix and * in the verdict, never silently — and when it is up they run, and a * non-zero exit fails the layer. A fully-down stack still fails * `validate` through (b), which never skips. * * 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. A skipped e2e probe never counts as passed: it is reported as * SKIPPED with its reason. 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 const TIMEOUT_RIGHTS_MS = 25 * 60 * 1000; // 25 min (the matrix bounds itself at 15 min of UI drives) const TIMEOUT_REVOCATION_MS = 15 * 60 * 1000; // 15 min (the probe bounds itself at 10 min) /** The local stack, with the same defaults and env overrides as the e2e scripts. */ const strip = (u: string): string => u.replace(/\/+$/, ""); const POD_PROVIDER = strip(process.env.POD_PROVIDER_URL || process.env.VITE_POD_PROVIDER_URL || 'http://localhost:3000'); const POD_FRONT = strip(process.env.POD_FRONTEND_URL || "http://localhost:5000"); const BACKEND = strip(process.env.BACKEND_URL || process.env.VITE_BACKEND_URL || 'http://localhost:3005'); const APP = strip(process.env.APP_URL || `http://localhost:${process.env.PORT || 4005}`); const FUSEKI = strip(process.env.FUSEKI_URL || "http://localhost:3030"); /** What the browser-driven e2e probes need answering 200 — every piece of the local stack. */ const E2E_STACK: Array<[string, string]> = [ // The triplestore both servers store into: with it down they still answer their static routes // (observed 2026-10-13: the pod's OpenID configuration 200 while every sign-up failed). ["triplestore (fuseki)", `${FUSEKI}/`], ["pod provider", `${POD_PROVIDER}/.well-known/openid-configuration`], ["pod frontend (login + consent)", `${POD_FRONT}/`], ["Festipod backend", `${BACKEND}/app`], ["Festipod app at APP_URL (the only registered OIDC redirect origin)", `${APP}/`], ]; // ─── Helpers ─────────────────────────────────────────────────────────────── interface StepResult { label: string; status: "passed" | "failed" | "error" | "skipped"; /** Failure lines; for a skipped layer, why it was skipped. */ 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, command = "node", extract: (output: string) => string[] = extractFailures, ): StepResult { const t0 = Date.now(); console.log(`\n${"═".repeat(64)}`); console.log(`▶ ${label}`); console.log(` ${command} ${args.join(" ")}`); console.log(`${"═".repeat(64)}`); const result = spawnSync(command, 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: extract(combined), exitCode: result.status ?? 1, durationMs, }; } /** A stack-gated layer that did not run: reported, never counted as passed. */ function skippedStep(label: string, reasons: string[]): StepResult { console.log(`\n${"═".repeat(64)}`); console.log(`⏭️ ${label} — SKIPPED: the local stack is down`); for (const r of reasons) console.log(` ↳ ${r}`); console.log(`${"═".repeat(64)}`); return { label, status: "skipped", failures: reasons, exitCode: 0, durationMs: 0 }; } /** Every part of the e2e stack that does not answer 200, as "name: url → status". Empty when the stack is up. */ async function e2eStackDown(): Promise { const down: string[] = []; for (const [name, url] of E2E_STACK) { let status: number | string; try { status = (await fetch(url, { signal: AbortSignal.timeout(5000) })).status; } catch (e) { status = `unreachable (${e instanceof Error ? e.message : String(e)})`; } if (status !== 200) down.push(`${name}: ${url} → ${typeof status === "number" ? `HTTP ${status}` : status}`); } return down; } /** The rights matrix's verdict lines: every failed hard line, any notice, and the verdict. */ function extractMatrixFailures(output: string): string[] { return output .split("\n") .map((l) => l.trim()) .filter((l) => /^(FAILED: |VERDICT: FAIL|!!! (?!NOTICE:)\S)/.test(l) || l.includes("RIGHTS MATRIX — ABORTED")); } /** * The revocation probe's JSON report (pretty-printed: its top-level braces are * the only ones at column 0): the phases that did not hold, and unexpected ≥400s. */ function extractRevocationFailures(output: string): string[] { const start = output.lastIndexOf("\n{\n") + 1; const end = output.indexOf("\n}", start); try { const report = JSON.parse(output.slice(start, end + 2)) as Record; if (report["ok"] === true) return []; const failures: string[] = []; for (const phase of ["positive", "negative", "recovered"]) { const p = report[phase] as { ok?: unknown } | undefined; if (p && p.ok !== true) failures.push(`phase ${phase}: ok=${String(p.ok)}`); } const fatal = report["fatal4xx"]; if (Array.isArray(fatal) && fatal.length) failures.push(`${fatal.length} unexpected ≥400 response(s)`); if (typeof report["fatal"] === "string") failures.push(`aborted: ${report["fatal"]}`); return failures.length ? failures : ["ok=false"]; } catch { return ["no JSON report found in the output"]; } } /** 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" ? "❌" : step.status === "skipped" ? "⏭️ " : "⚠️ "; const time = step.status === "skipped" ? "SKIPPED — local stack down" : fmtDuration(step.durationMs); console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${time}]`); 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 === "failed" || s.status === "error"); const skipped = steps.filter((s) => s.status === "skipped"); if (skipped.length) { console.log(` 🟡 ${skipped.length} LAYER(S) SKIPPED (local stack down — NOT validated): ${skipped.map((s) => s.label).join(", ")}`); } console.log(failed.length > 0 ? ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}` : skipped.length ? " 🟢 NO LAYER FAILED — but the skipped layers above were not run" : " 🟢 ALL LAYERS PASSED"); console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`); console.log(""); } // ─── Main ────────────────────────────────────────────────────────────────── async function main(): Promise { 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)); // Stack-gated e2e probes: one stack probe, then both run or both are skipped (loudly). const down = await e2eStackDown(); if (down.length) { const reasons = [...down, "start the stack: concept `data-layer`, `cookbook_local-activitypods-platform.md`"]; steps.push(skippedStep("festipod:e2e-rights", reasons)); steps.push(skippedStep("festipod:e2e-revocation", reasons)); } else { steps.push(runStep("festipod:e2e-rights", ["run", "test:e2e-rights"], TIMEOUT_RIGHTS_MS, "bun", extractMatrixFailures)); steps.push(runStep("festipod:e2e-revocation", ["run", "test:e2e-revocation"], TIMEOUT_REVOCATION_MS, "bun", extractRevocationFailures)); } printMatrix(steps); process.exit(steps.some((s) => s.status === "failed" || s.status === "error") ? 1 : 0); } void main();