Files
festipod/scripts/validate.ts
T
Sylvain Duchesne 9381c206df One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
2026-10-13 18:11:34 +02:00

292 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 || "http://localhost:3000");
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || "http://localhost:5000");
const BACKEND = strip(process.env.BACKEND_URL || "http://localhost:3003");
const APP = strip(process.env.APP_URL || "http://localhost:4000");
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<string[]> {
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<string, unknown>;
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<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));
// 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();