170 lines
7.0 KiB
TypeScript
170 lines
7.0 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* validate.ts — the Festipod validation matrix.
|
||
*
|
||
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
|
||
* the gate:
|
||
*
|
||
* (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.
|
||
*
|
||
* (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.
|
||
*
|
||
* 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";
|
||
|
||
// ─── Config ────────────────────────────────────────────────────────────────
|
||
|
||
const REPO_DIR = process.cwd();
|
||
|
||
/** The UI layer, verbatim — see the header. */
|
||
const UI_TAGS = "not @wip and not @humain and not @data and not @e2e and not @multibrowser";
|
||
|
||
/** The ActivityPods smoke, minus its documented suspends. */
|
||
const APODS_TAGS = "@e2e-activitypods and not @suspendu";
|
||
|
||
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
|
||
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
|
||
|
||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
interface StepResult {
|
||
label: string;
|
||
status: "passed" | "failed" | "error";
|
||
failures: string[];
|
||
exitCode: number;
|
||
durationMs: number;
|
||
}
|
||
|
||
/** 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(64)}`);
|
||
console.log(`▶ ${label}`);
|
||
console.log(` node ${args.join(" ")}`);
|
||
console.log(`${"═".repeat(64)}`);
|
||
|
||
const result = spawnSync("node", args, {
|
||
cwd: REPO_DIR,
|
||
env: process.env,
|
||
encoding: "utf-8",
|
||
timeout: timeoutMs,
|
||
maxBuffer: 20 * 1024 * 1024,
|
||
});
|
||
|
||
const durationMs = Date.now() - t0;
|
||
const combined = (result.stdout ?? "") + "\n" + (result.stderr ?? "");
|
||
process.stdout.write(result.stdout ?? "");
|
||
process.stderr.write(result.stderr ?? "");
|
||
|
||
if (result.error) {
|
||
return {
|
||
label,
|
||
status: "error",
|
||
failures: [`Process error: ${result.error.message}`],
|
||
exitCode: result.status ?? 1,
|
||
durationMs,
|
||
};
|
||
}
|
||
return {
|
||
label,
|
||
status: (result.status ?? 1) === 0 ? "passed" : "failed",
|
||
failures: extractFailures(combined),
|
||
exitCode: result.status ?? 1,
|
||
durationMs,
|
||
};
|
||
}
|
||
|
||
/** The failure lines worth surfacing: failed scenario names and the summary. */
|
||
function extractFailures(output: string): string[] {
|
||
const lines = output.split("\n");
|
||
const failures: string[] = [];
|
||
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`;
|
||
return `${Math.floor(ms / 60_000)}m${((ms % 60_000) / 1000).toFixed(0)}s`;
|
||
}
|
||
|
||
function printMatrix(steps: StepResult[]): void {
|
||
console.log("\n");
|
||
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" ? "❌" : "⚠️ ";
|
||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
|
||
for (const f of step.failures) console.log(` ↳ ${f}`);
|
||
}
|
||
console.log("");
|
||
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 ──────────────────────────────────────────────────────────────────
|
||
|
||
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));
|
||
|
||
printMatrix(steps);
|
||
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
|
||
}
|
||
|
||
main();
|