Files
festipod/scripts/validate.ts
T
Sylvain Duchesne 7dab6e44e2 tooling(validate): sous-ensemble @data clé rapide par défaut + flag --full + budgets
`bun run validate` finissait en timeout (@data complet >14min). Désormais : par
défaut un SOUS-ENSEMBLE CLÉ de 8 scénarios @data (les couvertures des bugs terrain :
inscription, désinscription, isolation, reconnexion, créateur, compteur, auth×2) en
une invocation → finit en ~8m30. Flag `--full` pour toute la suite @data (budget
élargi 35min). Rotation profil avant @data/@multibrowser, matrice + exit non-zéro.

Baseline actuel : 8/8 @data clé VERTS (les fixes watchShape/optimiste/reconnexion/
anti-fork tiennent) ; polyfill:unit 123 ; rouges = SingletonLock (infra) + un
@multibrowser (limite wallet-partagé A/B).

Note : gate pre-push rapide (tsc+build+polyfill unit) ajouté dans .git/hooks/pre-push
(local, non versionné — pour partage : script tracké + install, suivi).

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

451 lines
16 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;
}
}
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<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 ──────────────────────────────────────────
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);
});