Files
festipod/scripts/validate.ts
T
Sylvain Duchesne 0fc8479e22 tooling(validate): nettoyage SingletonLock + @multibrowser réactif @wip (limite wallet-partagé)
- validate.ts : `cleanSingletons()` retire SingletonLock/Cookie/Socket avant
  polyfill:e2e (et à la rotation) → fiabilise polyfill:e2e (fini le faux rouge
  ProcessSingleton). @multibrowser lancé en `@multibrowser and not @wip`.
- e2e-multibrowser : le scénario réactif « Un participant apparaît réactivement »
  passe @wip, commentaire d'en-tête expliquant la LIMITE : en wallet-partagé A et B
  partagent UNE identité NG → B voit l'événement de A comme possédé → son
  owner-materializer écrit le doc de A → StorageError. PAS un bug produit (prod =
  wallets distincts). Vrai fix = isolation distinct-wallets (chantier T02.d/g).
  Réserve : ce scénario était vert (517045c) ; à re-vérifier lors de l'isolation
  distinct-wallets (peut masquer une interaction phase-B).

Baseline validate visé : vert sauf ce @multibrowser documenté.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:17:18 +02:00

472 lines
17 KiB
TypeScript
Raw 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 — 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;
}
}
/**
* 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 @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);
});