#!/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 @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_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; } } 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 SingletonLock left by a // previous crashed run — Chromium refuses to launch if the lock exists. const lockPath = path.join(profilePath, "SingletonLock"); if (fs.existsSync(lockPath)) { try { fs.rmSync(lockPath, { force: true }); console.log(`[rotate] ${label}: removed stale SingletonLock.`); } catch { // Non-fatal: if we can't remove it, launch will fail with a clear error } } 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 = {}, ): 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 { 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 ────────────────────────────────────────── 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 ──────────────────────────────────────────── steps.push( runStep( "festipod:@multibrowser", "node", cucumberArgsByTags("@multibrowser"), FESTIPOD_DIR, TIMEOUT_MULTIBROWSER_MS, ), ); // ── (e) 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); });