Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

684 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* Live visual-QA sweep against a running dashboard dev server. Complements the
* heavier `audit:app` (which prebuilds every plugin-view bundle and walks the
* full route matrix): this tool points a headless Chromium at an *already
* running* server, captures a small matrix of the states that actually matter
* for the MVP — the first-run home/onboarding prompt, the keyboard-adjacent
* mobile composer, and (by seeding a session) the post-onboarding shell + its
* designed degraded renders — and runs each capture through the repo's own
* `visual-qa.mjs` analyzer (OCR + dominant palette + brand-colour fractions).
* One report.json + a terminal summary come out; `--strict` turns the
* brand-colour invariant ("no blue", elizaOS orange is accent-only) into a
* non-zero exit so a lane can gate on it.
*
* Why seeding: first-run local state changes which product surface renders.
* Injecting the canonical `steward_session_token` +
* `eliza:first-run-complete=1` into an *isolated* Playwright context (its own
* localStorage — it never touches the running server's state or other users)
* lets the sweep reach the post-onboarding shell and, when no backend is
* reachable, the designed failure state — which is the three-state-rule render
* worth verifying (see #14415).
*
* Consumed by: an operator or coding agent doing a fast pre-PR visual pass
* without the multi-minute `audit:app` prebuild. Not a CI gate unless a lane
* opts in with `--strict`.
*
* Usage:
* node scripts/visual-qa-live.mjs --base http://127.0.0.1:2138 [--out DIR] [--strict]
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";
import { analyzeScreenshot, changeMetric } from "./lib/visual-qa.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..", "..");
export const COMPOSER_PROBE_TEXT = "remind me to call the pharmacy before 5";
const FIRST_RUN_EXPECTED_ANY_TEXT = [
"Swipe for apps",
"Sign in to Eliza Cloud",
];
const SHELL_EXPECTED_ANY_TEXT = ["Settings", "Backend Unreachable"];
const CHAT_EXPECTED_ANY_TEXT = ["Pull chat up", "Backend Unreachable"];
// Mirrors the canonical Steward seed contract in
// `test/ui-smoke/helpers/test-auth.ts` (STEWARD_SESSION_TOKEN_KEY + the
// alg:none/exp:4102444800 JWT shape) and the first-run flag written by
// `state/persistence.ts` (`"1"`). Playwright specs import that helper directly;
// this is a standalone node script and cannot import the .ts helper at runtime,
// so the shape is duplicated here on purpose — keep the two in sync.
export const STEWARD_SESSION_TOKEN_KEY = "steward_session_token";
export const FIRST_RUN_COMPLETE_KEY = "eliza:first-run-complete";
/** base64url without padding — matches the renderer's test-auth JWT shape. */
function base64Url(input) {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/**
* An unsigned-but-decodable Steward JWT plus the first-run-complete flag. The
* token is decodable (renderer reads its claims) but unsigned, so it never
* passes real API auth — which is exactly why the seeded sweep surfaces the
* designed "backend unreachable" error state rather than live data.
*/
export function buildOnboardedSeed({ subject = "visual-qa-user" } = {}) {
const header = base64Url(JSON.stringify({ alg: "none", typ: "JWT" }));
const payload = base64Url(
JSON.stringify({
sub: subject,
userId: subject,
email: "visual-qa@example.test",
exp: 4102444800, // 2100-01-01 — never triggers a refresh mid-sweep.
}),
);
return {
[STEWARD_SESSION_TOKEN_KEY]: `${header}.${payload}.unsigned`,
[FIRST_RUN_COMPLETE_KEY]: "1",
};
}
/**
* The state matrix. Each entry is one capture: a viewport, a seed profile
* (fresh = first-run home prompt, onboarded = seeded shell), a route, and an
* optional `focusComposer` step to exercise the keyboard-adjacent mobile
* layout.
*/
export const VIEWPORTS = {
desktop: { width: 1280, height: 800, isMobile: false, hasTouch: false },
mobile: { width: 390, height: 844, isMobile: true, hasTouch: true },
};
export function buildStateMatrix() {
const states = [];
for (const [vpName, vp] of Object.entries(VIEWPORTS)) {
states.push({
id: `${vpName}-onboarding`,
viewport: vpName,
seed: "fresh",
route: "/",
expectedAnyTextIncludes: FIRST_RUN_EXPECTED_ANY_TEXT,
});
states.push({
id: `${vpName}-shell`,
viewport: vpName,
seed: "onboarded",
route: "/views",
expectedAnyTextIncludes: SHELL_EXPECTED_ANY_TEXT,
});
states.push({
id: `${vpName}-chat`,
viewport: vpName,
seed: "onboarded",
route: "/chat",
expectedAnyTextIncludes: CHAT_EXPECTED_ANY_TEXT,
});
if (vp.isMobile) {
states.push({
id: `${vpName}-onboarding-composer-focused`,
viewport: vpName,
seed: "fresh",
route: "/",
focusComposer: true,
expectedAnyTextIncludes: FIRST_RUN_EXPECTED_ANY_TEXT,
expectedComposerText: COMPOSER_PROBE_TEXT,
});
states.push({
id: `${vpName}-composer-focused`,
viewport: vpName,
seed: "onboarded",
route: "/chat",
focusComposer: true,
expectedAnyTextIncludes: CHAT_EXPECTED_ANY_TEXT,
expectedComposerText: COMPOSER_PROBE_TEXT,
});
}
}
return states;
}
export function buildCaptureUrl(base, route) {
return new URL(route, base.endsWith("/") ? base : `${base}/`).toString();
}
/**
* Fold per-capture analyzer reports into a pass/fail. The brand invariant is
* the only hard gate: blue must stay under the ceiling on every state (elizaOS
* ships zero blue; orange is accent-only). A capture with no measured
* `blue_fraction` is a broken analyzer run, not a clean screen — it fails
* closed (never read "unmeasured" as 0). Returns the failing state ids so the
* caller can report *which* screen broke, not just that something did.
*/
export function aggregateVerdict(reports, { blueCeiling = 0.02 } = {}) {
const offenders = [];
for (const r of reports) {
const blue = r.color_fractions?.blue_fraction;
if (!Number.isFinite(blue)) {
offenders.push({
id: r.id,
blue: null,
reason: "blue_fraction not measured",
});
} else if (blue > blueCeiling) {
offenders.push({
id: r.id,
blue,
reason: `blue over ceiling ${blueCeiling}`,
});
}
}
return { pass: offenders.length === 0, offenders, blueCeiling };
}
/**
* Seed-vacuity gate: if the onboarded-seed contract drifts (renamed key, new
* startup boundary), every "seeded" capture silently re-renders the first-run
* surface and the sweep passes on the wrong pixels. Requiring the first-run
* surface and the seeded shell to differ materially per viewport turns that
* silent drift into a hard failure. `deltas` is
* `[{ viewport, changedFraction }]` from `changeMetric`.
*/
export function seedDriftOffenders(deltas, { minChangedFraction = 0.02 } = {}) {
return deltas
.filter((d) => !(d.changedFraction > minChangedFraction))
.map((d) => {
const offender = {
viewport: d.viewport,
changedFraction: d.changedFraction,
};
if (d.reason) offender.reason = d.reason;
return offender;
});
}
export function buildOverallVerdict({
colorVerdict,
captureFailures = [],
driftOffenders = [],
}) {
return {
pass:
Boolean(colorVerdict?.pass) &&
captureFailures.length === 0 &&
driftOffenders.length === 0,
colorPass: Boolean(colorVerdict?.pass),
captureFailures,
driftOffenders,
};
}
function normalizeVisibleText(text) {
return text.replace(/\s+/g, " ").trim();
}
function includesText(haystack, needle) {
return haystack.toLocaleLowerCase().includes(needle.toLocaleLowerCase());
}
function matchesExpectedDomText(state, normalizedDomText) {
const required = state.expectedTextIncludes ?? [];
const any = state.expectedAnyTextIncludes ?? [];
return (
required.every((text) => includesText(normalizedDomText, text)) &&
(any.length === 0 ||
any.some((text) => includesText(normalizedDomText, text)))
);
}
export function evaluateCaptureReadiness({
state,
domText = "",
composerText = "",
minDomTextLength = 12,
}) {
const checks = [];
let pass = true;
const check = (name, ok, detail) => {
checks.push({ name, ok: Boolean(ok), detail });
if (!ok) pass = false;
};
const normalizedDomText = normalizeVisibleText(domText);
check(
"dom:not_blank",
normalizedDomText.length >= minDomTextLength,
`visible DOM text length ${normalizedDomText.length} (min ${minDomTextLength})`,
);
for (const expected of state.expectedTextIncludes ?? []) {
check(
"dom:expected_text",
includesText(normalizedDomText, expected),
`visible DOM text ${includesText(normalizedDomText, expected) ? "contains" : "does not contain"} "${expected}"`,
);
}
const expectedAny = state.expectedAnyTextIncludes ?? [];
if (expectedAny.length) {
const matched = expectedAny.filter((expected) =>
includesText(normalizedDomText, expected),
);
check(
"dom:expected_any_text",
matched.length > 0,
matched.length
? `visible DOM text contains "${matched[0]}"`
: `visible DOM text does not contain any of ${expectedAny
.map((text) => `"${text}"`)
.join(", ")}`,
);
}
if (state.expectedComposerText) {
check(
"composer:typed_text_present",
composerText.includes(state.expectedComposerText),
`composer text ${composerText.includes(state.expectedComposerText) ? "contains" : "does not contain"} the probe`,
);
}
return { pass, checks };
}
async function clickOpenAppIfVisible(page, { timeout = 500 } = {}) {
const candidates = [
page.getByRole("button", { name: /^Open App$/ }).first(),
page.getByText(/^Open App$/).first(),
];
for (const openApp of candidates) {
if (
!(await openApp.isVisible({ timeout }).catch(() => {
// error-policy:J4 absence of the startup recovery affordance simply
// means the screen is either already past that boundary or still
// settling; the composer check remains the authoritative proof.
return false;
}))
) {
continue;
}
await openApp.click({ timeout: 2000 });
await page.waitForTimeout(1000);
return true;
}
return false;
}
async function focusAndType(page) {
await clickOpenAppIfVisible(page);
const box = page.locator('[data-testid="chat-composer-textarea"]').first();
try {
await box.waitFor({ state: "visible", timeout: 5000 });
} catch (error) {
const recovered = await clickOpenAppIfVisible(page, { timeout: 5000 });
if (!recovered) throw error;
await box.waitFor({ state: "visible", timeout: 10000 });
}
await box.click({ timeout: 4000 });
await box.type(COMPOSER_PROBE_TEXT, { delay: 8 });
await page.waitForTimeout(600);
}
async function readVisibleText(page, state, { minLength = 12 } = {}) {
const deadline = Date.now() + 12_000;
let lastText = "";
let lastError = null;
while (Date.now() < deadline) {
try {
lastText = normalizeVisibleText(
await page.locator("body").innerText({ timeout: 1000 }),
);
if (
lastText.length >= minLength &&
matchesExpectedDomText(state, lastText)
) {
return lastText;
}
} catch (error) {
lastError = error;
}
await page.waitForTimeout(250);
}
if (lastError) {
throw new Error(
`Failed to read visible DOM text for ${state.id}: ${lastError?.message ?? lastError}`,
);
}
return lastText;
}
async function readComposerText(page) {
const box = page.locator('[data-testid="chat-composer-textarea"]').first();
await box.waitFor({ state: "visible", timeout: 4000 });
return box.evaluate((el) =>
"value" in el ? String(el.value) : String(el.textContent ?? ""),
);
}
function compactError(error) {
return String(error?.message ?? error)
.replace(/\s+/g, " ")
.slice(0, 500);
}
function buildCaptureFailureEntry({ state, targetUrl, error, failureImage }) {
const detail = compactError(error);
return {
id: state.id,
route: state.route,
seed: state.seed,
target_url: targetUrl,
image: failureImage,
capture_error: detail,
visual_verdict: "fail",
visual_checks: [
{ name: "capture:completed", ok: false, detail },
{
name: "brand:no_blue",
ok: false,
detail: "blue_fraction not measured because capture failed",
},
],
network_idle: "not reached",
dom_checks: [{ name: "capture:completed", ok: false, detail }],
dom_text: "",
ocr_text: "",
ocr_note: "capture failed before OCR",
};
}
function gitValue(args) {
try {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
// error-policy:J7 provenance enrichment must never kill a local visual pass;
// the report records null so reviewers can see metadata was unavailable.
return null;
}
}
function buildRunMeta({ base, outDir, strict, stateCount }) {
return {
createdAt: new Date().toISOString(),
base,
outDir,
strict,
stateCount,
commit: gitValue(["rev-parse", "HEAD"]),
branch: gitValue(["branch", "--show-current"]),
};
}
async function main() {
const args = process.argv.slice(2);
const opt = (flag, dflt) => {
const i = args.indexOf(flag);
return i >= 0 && args[i + 1] ? args[i + 1] : dflt;
};
const base = opt("--base", "http://127.0.0.1:2138");
const outDir = opt(
"--out",
path.join(
__dirname,
"..",
"test",
"ui-smoke",
"output",
"visual-qa-live-output",
),
);
const strict = args.includes("--strict");
mkdirSync(outDir, { recursive: true });
const onboardedSeed = buildOnboardedSeed();
const matrix = buildStateMatrix();
const meta = buildRunMeta({
base,
outDir,
strict,
stateCount: matrix.length,
});
const browser = await chromium.launch();
const reports = [];
const captureFailures = [];
try {
for (const state of matrix) {
const vp = VIEWPORTS[state.viewport];
const ctx = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
isMobile: vp.isMobile,
hasTouch: vp.hasTouch,
deviceScaleFactor: 2,
});
let page = null;
const targetUrl = buildCaptureUrl(base, state.route);
try {
if (state.seed === "onboarded") {
await ctx.addInitScript((seed) => {
for (const [k, v] of Object.entries(seed))
window.localStorage.setItem(k, v);
}, onboardedSeed);
}
page = await ctx.newPage();
let response;
try {
response = await page.goto(targetUrl, {
waitUntil: "domcontentloaded",
timeout: 20000,
});
} catch (error) {
throw new Error(
`Failed to load ${state.id} at ${targetUrl}: ${error?.message ?? error}`,
);
}
if (!response?.ok()) {
throw new Error(
`Failed to load ${state.id} at ${targetUrl}: HTTP ${response?.status() ?? "unknown"}`,
);
}
let networkIdle = "reached";
try {
await page.waitForLoadState("networkidle", { timeout: 5000 });
} catch (error) {
// error-policy:J4 a dev server without an API often keeps failed proxy
// requests noisy; screenshot validity is checked by DOM and image
// analysis below, so a timeout is recorded instead of hidden.
networkIdle = `timeout: ${String(error?.message ?? error).slice(0, 120)}`;
}
await page.waitForTimeout(1500);
let focusError = "";
if (state.focusComposer) {
try {
await focusAndType(page);
} catch (error) {
focusError = String(error?.message ?? error).slice(0, 180);
}
}
const domText = await readVisibleText(page, state, {
minLength: 12,
});
let composerText = "";
if (state.focusComposer && !focusError) {
try {
composerText = await readComposerText(page);
} catch (error) {
focusError = String(error?.message ?? error).slice(0, 180);
}
}
const readiness = evaluateCaptureReadiness({
state,
domText,
composerText,
});
if (focusError) {
readiness.pass = false;
readiness.checks.push({
name: "composer:focus_and_type",
ok: false,
detail: focusError,
});
}
const file = path.join(outDir, `${state.id}.png`);
await page.screenshot({ path: file });
const report = await analyzeScreenshot(file, {});
const entry = {
id: state.id,
route: state.route,
seed: state.seed,
image: file,
size: report.size,
dominant_palette: report.dominant_palette,
color_fractions: report.color_fractions,
visual_verdict: report.verdict,
visual_checks: report.checks,
network_idle: networkIdle,
dom_checks: readiness.checks,
dom_text: domText.slice(0, 240),
ocr_text: (report.ocr_text || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 240),
ocr_note: report.ocr_note,
};
reports.push(entry);
if (!readiness.pass) {
captureFailures.push({
id: state.id,
failedChecks: readiness.checks.filter((c) => !c.ok),
});
}
const measured = entry.color_fractions?.blue_fraction;
const blue = Number.isFinite(measured)
? measured.toFixed(3)
: "unmeasured";
const invalid = readiness.pass ? "" : " INVALID_CAPTURE";
console.log(
`■ ${state.id.padEnd(26)} blue=${blue}${invalid} ${entry.ocr_text.slice(0, 70)}`,
);
} catch (error) {
const failedCheck = {
name: "capture:completed",
ok: false,
detail: compactError(error),
};
const failureFile = path.join(outDir, `${state.id}-failure.png`);
let failureImage = null;
if (page && !page.isClosed()) {
try {
await page.screenshot({ path: failureFile, fullPage: true });
failureImage = existsSync(failureFile) ? failureFile : null;
} catch {
// error-policy:J7 failure evidence is best-effort; the structured
// capture_error below is still the observed diagnostic signal.
}
}
reports.push(
buildCaptureFailureEntry({
state,
targetUrl,
error,
failureImage,
}),
);
captureFailures.push({ id: state.id, failedChecks: [failedCheck] });
console.log(
`x ${state.id.padEnd(26)} capture failed: ${failedCheck.detail}`,
);
} finally {
await ctx.close();
}
}
} finally {
await browser.close();
}
const seedDeltas = [];
for (const vpName of Object.keys(VIEWPORTS)) {
const gate = reports.find((r) => r.id === `${vpName}-onboarding`);
const shell = reports.find((r) => r.id === `${vpName}-shell`);
if (!gate?.image || !shell?.image) {
seedDeltas.push({
viewport: vpName,
changedFraction: undefined,
reason: "missing onboarding or shell capture",
});
continue;
}
try {
const delta = await changeMetric(shell.image, gate.image);
seedDeltas.push({
viewport: vpName,
changedFraction: delta.changed_fraction,
});
} catch (error) {
seedDeltas.push({
viewport: vpName,
changedFraction: undefined,
reason: `change metric failed: ${compactError(error)}`,
});
}
}
const driftOffenders = seedDriftOffenders(seedDeltas);
const verdict = aggregateVerdict(reports);
const overall = buildOverallVerdict({
colorVerdict: verdict,
captureFailures,
driftOffenders,
});
writeFileSync(
path.join(outDir, "report.json"),
JSON.stringify(
{
meta,
overall,
verdict,
seedDeltas,
captureFailures,
driftOffenders,
reports,
},
null,
2,
),
);
console.log(
`\n${overall.pass ? "PASS" : "FAIL"} — live visual QA; no-blue=${verdict.pass ? "pass" : "fail"} (ceiling ${verdict.blueCeiling}); ${reports.length} states → ${outDir}/report.json`,
);
if (verdict.offenders.length)
console.log(
` offenders: ${verdict.offenders
.map(
(o) => `${o.id}=${o.blue == null ? "unmeasured" : o.blue.toFixed(3)}`,
)
.join(", ")}`,
);
if (captureFailures.length)
console.log(
` invalid captures: ${captureFailures
.map((f) => `${f.id} (${f.failedChecks.map((c) => c.name).join(", ")})`)
.join(", ")}`,
);
if (driftOffenders.length)
console.log(
` seed drift: ${driftOffenders
.map((o) => `${o.viewport} changed_fraction=${o.changedFraction}`)
.join(", ")}`,
);
if (strict && !overall.pass) process.exit(1);
}
// Only run when invoked directly, so the pure helpers stay importable by tests.
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
await main();
}