Suspend the NextGraph suites, and smoke the ActivityPods stack

This commit is contained in:
Sylvain Duchesne
2026-10-09 21:18:51 +02:00
parent c195211629
commit f8c2e3b809
27 changed files with 977 additions and 649 deletions
+96 -417
View File
@@ -1,190 +1,101 @@
#!/usr/bin/env bun
/**
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
* validate.ts — the Festipod validation matrix.
*
* Default run (no flags) — key subset only, fast:
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
* (f) Festipod @wip [informational only, non-blocking]
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
* the gate:
*
* With --full flag:
* (c) becomes full @data suite (cucumber --tags @data)
* (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.
*
* Each step runs even if the previous one failed (--bail mode is OFF).
* Exit code is non-zero if any non-informational step has failures.
* (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.
*
* Profile rotation: both Playwright profiles are rotated before @data and
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
* 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";
import * as fs from "fs";
import * as path from "path";
// ─── Config ────────────────────────────────────────────────────────────────
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
const POLYFILL_DIR =
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
const REPO_DIR = process.cwd();
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
const POLYFILL_PROFILE = path.join(
POLYFILL_DIR,
"e2e",
".playwright-profile-lib",
);
/** The UI layer, verbatim — see the header. */
const UI_TAGS = "not @wip and not @humain and not @data and not @e2e and not @multibrowser";
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
const BLOAT_THRESHOLD_MB = 50;
/** The ActivityPods smoke, minus its documented suspends. */
const APODS_TAGS = "@e2e-activitypods and not @suspendu";
/**
* Per-step timeouts:
* - polyfill unit/e2e: short steps, keep 10 min
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
* - @data full: full suite, ~35 min margin
* - @multibrowser: 7 scenarios, ~10 min margin
* - @wip: informational, 10 min
*/
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
/**
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
*
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
*
* French accent chars must be URL-safe in the regex — cucumber uses JS
* RegExp, which handles unicode natively; we pass the literal string.
*/
const DATA_KEY_NAME_REGEX = [
"S'inscrire à un événement",
"Se désinscrire d'un événement$",
"Une identité fraîche ne voit pas la participation d'une autre",
"Une page fraîche pour la même identité relit ses propres données",
"Le créateur ne participe pas automatiquement à son événement",
"L'inscription fait converger le compteur dérivé du propriétaire",
"Un portefeuille connecté est vide par défaut",
"Les données du portefeuille sont distinctes des données par défaut",
].join("|");
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
// ─── Helpers ───────────────────────────────────────────────────────────────
function dirSizeMB(dir: string): number {
if (!fs.existsSync(dir)) return 0;
try {
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
const line = result.stdout.trim().split("\n")[0] ?? "";
return parseInt(line.split("\t")[0] ?? "0", 10);
} catch {
return 0;
}
}
/**
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
* call even when the profile does not exist yet.
*/
function cleanSingletons(profilePath: string, label: string): void {
if (!fs.existsSync(profilePath)) return;
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
for (const name of singletons) {
const p = path.join(profilePath, name);
if (fs.existsSync(p)) {
try {
fs.rmSync(p, { force: true });
console.log(`[rotate] ${label}: removed stale ${name}.`);
} catch {
// Non-fatal: if we can't remove it, launch will fail with a clear error
}
}
}
}
function rotateProfile(profilePath: string, label: string): void {
const sizeMB = dirSizeMB(profilePath);
if (sizeMB > BLOAT_THRESHOLD_MB) {
console.log(
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
);
try {
fs.rmSync(profilePath, { recursive: true, force: true });
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
} catch (e) {
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
}
} else {
// Even if we keep the profile, remove any stale Chromium singleton files left
// by a previous crashed run — Chromium refuses to launch if they exist.
cleanSingletons(profilePath, label);
console.log(
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
);
}
}
interface StepResult {
label: string;
status: "passed" | "failed" | "error";
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
failures: string[];
/** Raw exit code */
exitCode: number;
durationMs: number;
}
/**
* Run a command and capture its output. Returns the result with parsed
* pass/fail summary. Never throws — all errors are captured in StepResult.
*/
function runStep(
label: string,
cmd: string,
args: string[],
cwd: string,
timeoutMs: number,
extraEnv: Record<string, string> = {},
): StepResult {
/** 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(60)}`);
console.log(`\n${"═".repeat(64)}`);
console.log(`▶ ${label}`);
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
console.log(`${"═".repeat(60)}`);
console.log(` node ${args.join(" ")}`);
console.log(`${"═".repeat(64)}`);
const env = { ...process.env, ...extraEnv };
const result = spawnSync(cmd, args, {
cwd,
env,
const result = spawnSync("node", args, {
cwd: REPO_DIR,
env: process.env,
encoding: "utf-8",
timeout: timeoutMs,
maxBuffer: 20 * 1024 * 1024, // 20MB
maxBuffer: 20 * 1024 * 1024,
});
const durationMs = Date.now() - t0;
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const combined = stdout + "\n" + stderr;
// Print output in real-time equivalent (post-hoc since spawnSync)
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
const combined = (result.stdout ?? "") + "\n" + (result.stderr ?? "");
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
if (result.error) {
console.error(`[${label}] process error:`, result.error.message);
return {
label,
status: "error",
@@ -193,298 +104,66 @@ function runStep(
durationMs,
};
}
const exitCode = result.status ?? 1;
const failures = extractFailures(combined, label);
const status = exitCode === 0 ? "passed" : "failed";
return { label, status, failures, exitCode, durationMs };
return {
label,
status: (result.status ?? 1) === 0 ? "passed" : "failed",
failures: extractFailures(combined),
exitCode: result.status ?? 1,
durationMs,
};
}
/**
* Extract meaningful failure lines from combined stdout+stderr.
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
*/
function extractFailures(output: string, label: string): string[] {
/** The failure lines worth surfacing: failed scenario names and the summary. */
function extractFailures(output: string): string[] {
const lines = output.split("\n");
const failures: string[] = [];
if (label.includes("polyfill:unit")) {
// bun test output: lines starting with "✗" or "FAIL" or "fail"
for (const line of lines) {
const l = line.trim();
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
failures.push(l);
}
}
// Also capture summary line "N passed, M failed"
const summary = lines.find(
(l) => l.includes("passed") && l.includes("failed"),
);
if (summary) failures.push(summary.trim());
} else if (label.includes("polyfill:e2e")) {
// e2e/run.ts output: lines starting with " [FAIL]"
for (const line of lines) {
const l = line.trim();
if (l.startsWith("[FAIL]")) failures.push(l);
}
// Summary: "N passed / M failed" style
const summary = lines.find(
(l) => l.includes("passed") || l.includes("failed"),
);
if (summary && !failures.includes(summary.trim()))
failures.push(summary.trim());
} else {
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
for (const line of lines) {
const l = line.trim();
if (
/^(✗|✕|×|✖)\s/.test(l) ||
l.startsWith("✘") ||
l.includes("# Scénario:") ||
l.includes("# Scenario:") ||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
) {
if (l.length > 1) failures.push(l);
}
}
// Cucumber "N scenarios (M failed)" summary
const summary = lines.find((l) =>
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
);
if (summary) failures.push(summary.trim());
// Individual scenario fail lines: "✗ Scenario name (features/...)"
for (const line of lines) {
const l = line.trim();
if (l.startsWith("✗") || l.startsWith("✕")) {
if (!failures.includes(l)) failures.push(l);
}
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`;
const m = Math.floor(ms / 60_000);
const s = ((ms % 60_000) / 1000).toFixed(0);
return `${m}m${s}s`;
return `${Math.floor(ms / 60_000)}m${((ms % 60_000) / 1000).toFixed(0)}s`;
}
function printMatrix(
steps: StepResult[],
wipResult: StepResult | null,
fullMode: boolean,
): void {
function printMatrix(steps: StepResult[]): void {
console.log("\n");
console.log("╔══════════════════════════════════════════════════════════╗");
console.log("║ VALIDATION MATRIX ║");
if (fullMode) {
console.log("║ (mode: --full, @data complet) ║");
} else {
console.log("║ (mode: défaut, sous-ensemble clé) ║");
}
console.log("╚══════════════════════════════════════════════════════════╝");
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" ? "❌" : "⚠️ ";
const pad = step.label.padEnd(maxLabel + 2);
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
for (const f of step.failures) {
console.log(` ↳ ${f}`);
}
}
if (wipResult) {
console.log("");
console.log(" ── @wip (informational, non-blocking) ──────────────────");
const icon =
wipResult.status === "passed"
? "✅"
: wipResult.status === "failed"
? "❌"
: "⚠️ ";
const pad = wipResult.label.padEnd(maxLabel + 2);
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
for (const f of wipResult.failures) {
console.log(` ↳ ${f}`);
}
}
console.log("");
const allPassed = steps.every((s) => s.status === "passed");
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
(wipResult?.durationMs ?? 0);
if (allPassed) {
console.log(" 🟢 ALL STEPS PASSED");
} else {
const failed = steps.filter((s) => s.status !== "passed");
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
}
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
if (!fullMode) {
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
for (const f of step.failures) console.log(` ↳ ${f}`);
}
console.log("");
}
// ─── Cucumber command builder ───────────────────────────────────────────────
function cucumberArgsByTags(tags: string): string[] {
return [
"--import",
"tsx/esm",
"node_modules/.bin/cucumber-js",
"--config",
"cucumber.json",
"--tags",
tags,
];
}
function cucumberArgsByName(nameRegex: string): string[] {
return [
"--import",
"tsx/esm",
"node_modules/.bin/cucumber-js",
"--config",
"cucumber.json",
"--tags",
"@data",
"--name",
nameRegex,
];
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 ──────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
const fullMode = args.includes("--full");
if (fullMode) {
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
} else {
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
console.log(" Pour @data complet : bun run validate -- --full");
}
console.log(` Festipod: ${FESTIPOD_DIR}`);
console.log(` Polyfill: ${POLYFILL_DIR}`);
console.log("");
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
rotateProfile(FESTIPOD_PROFILE, "festipod");
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
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));
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
steps.push(
runStep(
"polyfill:unit",
"bun",
["test"],
POLYFILL_DIR,
TIMEOUT_POLYFILL_UNIT_MS,
),
);
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
// Clean singleton files immediately before launching Chromium — guards against
// any file left by polyfill:unit (unlikely but defensive) or by a previous
// interrupted run that the initial rotateProfile call ran before.
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
steps.push(
runStep(
"polyfill:e2e",
"bun",
["run", "e2e/run.ts"],
POLYFILL_DIR,
TIMEOUT_POLYFILL_E2E_MS,
),
);
// ── (c) Festipod @data ────────────────────────────────────────────────────
if (fullMode) {
// --full : lance tout @data
steps.push(
runStep(
"festipod:@data (complet)",
"node",
cucumberArgsByTags("@data"),
FESTIPOD_DIR,
TIMEOUT_DATA_FULL_MS,
),
);
} else {
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
steps.push(
runStep(
"festipod:@data (clé)",
"node",
cucumberArgsByName(DATA_KEY_NAME_REGEX),
FESTIPOD_DIR,
TIMEOUT_DATA_KEY_MS,
),
);
}
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
// cross-session scenario, blocked by the shared-wallet structural limit) must
// not gate the baseline — it flows into the informational @wip pass below.
steps.push(
runStep(
"festipod:@multibrowser",
"node",
cucumberArgsByTags("@multibrowser and not @wip"),
FESTIPOD_DIR,
TIMEOUT_MULTIBROWSER_MS,
),
);
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
steps.push(
runStep(
"festipod:@smoke",
"node",
cucumberArgsByTags("@smoke and not @wip"),
FESTIPOD_DIR,
TIMEOUT_SMOKE_MS,
),
);
// ── (f) Festipod @wip [informational] ────────────────────────────────────
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
const wipResult = runStep(
"festipod:@wip",
"node",
cucumberArgsByTags("@wip"),
FESTIPOD_DIR,
TIMEOUT_WIP_MS,
);
// ── Matrix ────────────────────────────────────────────────────────────────
printMatrix(steps, wipResult, fullMode);
// ── Exit code ─────────────────────────────────────────────────────────────
const anyFailed = steps.some((s) => s.status !== "passed");
process.exit(anyFailed ? 1 : 0);
printMatrix(steps);
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
}
main().catch((e) => {
console.error("validate.ts: unhandled error:", e);
process.exit(1);
});
main();