7302936502
Boote le VRAI App via le broker (hook Before @e2e existant), navigue vers l'accueil connecté et asserte deux choses fortes : HomeScreen a réellement monté (.app-navbar + bouton "Relayer", absents d'un spinner/bandeau broker) ET aucune erreur runtime (pageerror/console.error) n'a été émise pendant le boot connecté. Le World collecte désormais les pageErrors (réinitialisés par scénario, logging existant préservé). Câblé dans `bun run validate` (run par défaut) via @smoke and not @wip, avec nettoyage Chromium. Preuve: un throw dans HomeScreen fait virer le smoke au rouge; sans lui, vert. Comble le trou qui laissait passer la régression page-blanche (aucune suite n'exécutait @e2e et aucune assertion ne gardait le rendu connecté). Doctrine: bdd-testing/knowledge_e2e-layer documente le smoke @smoke + pageErrors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
491 lines
18 KiB
TypeScript
491 lines
18 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||
*
|
||
* 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]
|
||
*
|
||
* With --full flag:
|
||
* (c) becomes full @data suite (cucumber --tags @data)
|
||
*
|
||
* 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.
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
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 FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||
const POLYFILL_PROFILE = path.join(
|
||
POLYFILL_DIR,
|
||
"e2e",
|
||
".playwright-profile-lib",
|
||
);
|
||
|
||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||
const BLOAT_THRESHOLD_MB = 50;
|
||
|
||
/**
|
||
* 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("|");
|
||
|
||
// ─── 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 {
|
||
const t0 = Date.now();
|
||
console.log(`\n${"═".repeat(60)}`);
|
||
console.log(`▶ ${label}`);
|
||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||
console.log(`${"═".repeat(60)}`);
|
||
|
||
const env = { ...process.env, ...extraEnv };
|
||
|
||
const result = spawnSync(cmd, args, {
|
||
cwd,
|
||
env,
|
||
encoding: "utf-8",
|
||
timeout: timeoutMs,
|
||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||
});
|
||
|
||
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);
|
||
|
||
if (result.error) {
|
||
console.error(`[${label}] process error:`, result.error.message);
|
||
return {
|
||
label,
|
||
status: "error",
|
||
failures: [`Process error: ${result.error.message}`],
|
||
exitCode: result.status ?? 1,
|
||
durationMs,
|
||
};
|
||
}
|
||
|
||
const exitCode = result.status ?? 1;
|
||
const failures = extractFailures(combined, label);
|
||
const status = exitCode === 0 ? "passed" : "failed";
|
||
|
||
return { label, status, failures, exitCode, 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[] {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
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`;
|
||
}
|
||
|
||
function printMatrix(
|
||
steps: StepResult[],
|
||
wipResult: StepResult | null,
|
||
fullMode: boolean,
|
||
): 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("");
|
||
|
||
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("");
|
||
}
|
||
|
||
// ─── 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,
|
||
];
|
||
}
|
||
|
||
// ─── 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");
|
||
|
||
const steps: StepResult[] = [];
|
||
|
||
// ── (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);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error("validate.ts: unhandled error:", e);
|
||
process.exit(1);
|
||
});
|