9381c206df
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.
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
#!/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();
|