Files
Sylvain Duchesne 5e3e821e2a Run locally like the sibling apps: fixed ports, the pod provider in Docker, the app outside
Festipod now follows the local convention of welcometomyplace, mutual-aid.app
and portejunes: one fixed port pair per app (Festipod defaults: backend 3005,
app 4005; pod provider 3000/5000), overridable in backend/.env and .env.
`make start` runs docker-compose-dev.yml (the local pod provider from images,
Fuseki, Redis, mailcatcher, Arena); the backend and the app run outside Docker
with `cd backend && pnpm dev` and `pnpm dev`. pnpm stays the package manager.

The old docker-compose.yml, which containerized Festipod's own frontend and
backend and could not reach the host's Fuseki and Redis, is gone; portless is
dropped from the dev script, since the backend declares a single OIDC redirect
URI. The local backend runs without a Redis cache, as the sibling apps do. The
e2e suites and validate read the app, backend and pod provider URLs from the
env files instead of hardcoding them.

The deployment and local cookbook leaves record the traps met while resetting
datasets: a dataset re-created under the same name needs a Fuseki restart
(ClosedException otherwise), the anonymous WebACL probe at boot is expected,
and a change of the backend's URL leaves persistent accounts with a
participations collection the new app identity cannot control.
2026-11-01 11:40:27 +01:00

292 lines
13 KiB
TypeScript
Raw Permalink 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 || 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<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();