Files
heygen-com--hyperframes/packages/cli/src/commands/validate.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

649 lines
24 KiB
TypeScript

// The media-metadata wait exists twice on purpose: once Node-side and once
// inside a page.evaluate() body, which is serialized into the browser and
// cannot import the Node helper. Line-level markers don't survive the clone
// window drifting as the file is edited, hence the file-level suppression.
// fallow-ignore-file code-duplication
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject, type ProjectDir } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { c } from "../ui/colors.js";
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
import {
installPageFunctionGuard,
resolveCliChromeGpuMode,
seekCompositionTimeline,
} from "../capture/captureCompositionFrame.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
interface ConsoleEntry {
level: "error" | "warning";
text: string;
url?: string;
line?: number;
}
interface ContrastEntry {
time: number;
selector: string;
text: string;
ratio: number;
wcagAA: boolean;
large: boolean;
fg: string;
bg: string;
}
const CONTRAST_SAMPLES = 5;
const SEEK_SETTLE_MS = 150;
const PREFERRED_SEEK_TARGET_WAIT_MS = 500;
const MEDIA_EXTENSIONS = /\.(aac|flac|m4a|mov|mp3|mp4|oga|ogg|wav|webm)$/i;
// Floor for the initial page navigation. A blocking external <script> (GSAP
// from a CDN, etc.) delays `domcontentloaded`; the actual render (much larger
// budget) rides it out, so validate's navigation must be at least as patient as
// the user's --timeout, never stuck below this floor.
const NAV_TIMEOUT_FLOOR_MS = 10000;
// Navigation budget = the larger of the floor and the user's --timeout, so
// `--timeout` (already the "wait longer for slow loads" knob for media/settle)
// also extends navigation instead of being ignored by a hardcoded 10s.
export function resolveNavigationTimeoutMs(optTimeout?: number): number {
return Math.max(NAV_TIMEOUT_FLOOR_MS, optTimeout ?? 0);
}
// Turn Puppeteer's opaque "Navigation timeout of Nms exceeded" into an
// actionable message: the usual cause is a blocking CDN <script> that render
// tolerates but validate's tighter budget does not. Returns a replacement Error
// for a navigation timeout, or null for any other error (caller rethrows as-is).
export function navigationTimeoutHint(err: unknown, navTimeoutMs: number): Error | null {
const msg = err instanceof Error ? err.message : String(err);
if (!/navigation timeout/i.test(msg)) return null;
return new Error(
`Page navigation timed out after ${navTimeoutMs}ms. A blocking external <script> ` +
`(e.g. GSAP loaded from a CDN) can delay page load past this budget even when the ` +
`full render succeeds. Vendor the script locally (recommended for deterministic ` +
`renders), or re-run with a longer --timeout.`,
);
}
export function shouldIgnoreRequestFailure(
url: string,
errorText: string | undefined,
resourceType?: string,
): boolean {
if (errorText !== "net::ERR_ABORTED") return false;
if (resourceType === "media") return true;
try {
return MEDIA_EXTENSIONS.test(new URL(url).pathname);
} catch {
return false;
}
}
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
return page.evaluate(() => {
if (window.__hf?.duration && window.__hf.duration > 0) return window.__hf.duration;
const root = document.querySelector("[data-composition-id][data-duration]");
return root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0;
});
}
/**
* Race a media element's `loadedmetadata`/`error` event against a deadline,
* whichever comes first. Already-ready elements resolve immediately.
*
* This is the same wiring as the inline copy inside `auditClipDurations`'s
* `page.evaluate()` below — duplicated, not imported, because Puppeteer
* serializes that closure's source and re-runs it in an isolated browser
* realm with no access to this module's scope. Kept here (duck-typed on
* `EventTarget`-shaped objects, not `HTMLMediaElement`) so the actual
* race/cleanup logic has a real, deterministic unit test — Node's built-in
* `EventTarget` satisfies the same shape without a browser or DOM library.
* If you change one copy, change both.
*/
export function raceMediaReady(
el: EventTarget & { duration: number },
deadlineMs: number,
): Promise<void> {
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
return new Promise<void>((resolve) => {
const onReady = () => {
el.removeEventListener("loadedmetadata", onReady);
el.removeEventListener("error", onReady);
clearTimeout(timer);
resolve();
};
el.addEventListener("loadedmetadata", onReady, { once: true });
el.addEventListener("error", onReady, { once: true });
const timer = setTimeout(onReady, Math.max(0, deadlineMs - Date.now()));
});
}
/**
* Flag `<video>`/`<audio>` clips whose source is meaningfully shorter than their
* `data-duration` slot (the slot gets silently shortened in renders). Runs in
* the live page to read each element's intrinsic `.duration`, which static lint
* can't see.
*/
export async function auditClipDurations(
page: import("puppeteer-core").Page,
analyzeClipMediaFit: typeof import("@hyperframes/engine").analyzeClipMediaFit,
extraWaitMs: number,
): Promise<ConsoleEntry[]> {
// fallow-ignore-next-line complexity
const clips = await page.evaluate(async (maxWaitMs: number) => {
const nodes = Array.from(
document.querySelectorAll("video[data-duration], audio[data-duration]"),
) as HTMLMediaElement[];
// The caller's page-settle sleep is a flat, unconditional wait shared with
// other audits — it isn't aware of how long any given media element takes
// to load metadata. A slow-loading audio file (large narration WAV, remote
// source) can still be mid-fetch when that sleep elapses, which read as
// el.duration === NaN and was misreported as "could not read the duration"
// even though the render pipeline (which properly awaits media readiness)
// handles the same file fine. Give still-loading elements one more real
// chance via loadedmetadata before giving up, instead of a single fixed-time
// snapshot. Elements that already have a duration resolve immediately, so
// this adds no latency in the common case.
const deadline = Date.now() + maxWaitMs;
await Promise.all(
nodes.map((el) => {
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
return new Promise<void>((resolve) => {
const cleanup = () => {
el.removeEventListener("loadedmetadata", onReady);
el.removeEventListener("error", onReady);
clearTimeout(timer);
};
const onReady = () => {
cleanup();
resolve();
};
el.addEventListener("loadedmetadata", onReady, { once: true });
el.addEventListener("error", onReady, { once: true });
const timer = setTimeout(onReady, Math.max(0, deadline - Date.now()));
});
}),
);
const rows: Array<{
id: string;
kind: string;
slot: number;
mediaStart: number;
duration: number;
loop: boolean;
}> = [];
for (const el of nodes) {
const slot = parseFloat(el.getAttribute("data-duration") ?? "");
if (!(slot > 0)) continue;
rows.push({
id: el.id || el.getAttribute("src") || `(${el.tagName.toLowerCase()})`,
kind: el.tagName === "AUDIO" ? "Audio" : "Video",
slot,
mediaStart: parseFloat(el.getAttribute("data-media-start") ?? "0") || 0,
duration: el.duration,
loop: el.loop || el.getAttribute("data-loop") === "true",
});
}
return rows;
}, extraWaitMs);
const warnings: ConsoleEntry[] = [];
const unreadable: string[] = [];
for (const clip of clips) {
if (!Number.isFinite(clip.duration) || clip.duration <= 0) {
// Metadata never loaded (e.g. slow remote source) — record so the gap in
// coverage isn't silent, rather than dropping it.
unreadable.push(clip.id);
continue;
}
const mediaSeconds = Math.max(0, clip.duration - clip.mediaStart);
const fit = analyzeClipMediaFit({ slotSeconds: clip.slot, mediaSeconds, loop: clip.loop });
if (!fit) continue;
warnings.push({
level: "warning",
text:
`${clip.kind} "${clip.id}" is ${mediaSeconds.toFixed(2)}s but its slot (data-duration) ` +
`is ${clip.slot.toFixed(2)}s — the slot is shortened to the media length when rendered. ` +
`Set data-duration to ~${mediaSeconds.toFixed(2)}s if that isn't intended.`,
});
}
if (unreadable.length > 0) {
warnings.push({
level: "warning",
text:
`Could not read the duration of ${unreadable.length} media element(s) within the ` +
`validate timeout (${unreadable.join(", ")}); their slot vs. source fit was not checked. ` +
`Re-run with a longer --timeout if the source is slow to load.`,
});
}
return warnings;
}
interface ContrastCandidate {
selector: string;
text: string;
fg: [number, number, number, number];
fontSize: number;
fontWeight: number;
large: boolean;
bbox: { x: number; y: number; w: number; h: number };
}
async function runContrastAudit(page: import("puppeteer-core").Page): Promise<ContrastEntry[]> {
const duration = await getCompositionDuration(page);
if (duration <= 0) return [];
await page.addScriptTag({ content: loadContrastAuditScript() });
const results: ContrastEntry[] = [];
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
await seekCompositionTimeline(page, t, {
fallbackToBridgeAndTimelines: true,
waitForPreferredSeekTargetMs: PREFERRED_SEEK_TARGET_WAIT_MS,
animationFrameSettle: "none",
settleMs: SEEK_SETTLE_MS,
});
try {
// __contrastAuditPrepare() hides each candidate text element's own
// paint (color/fill → transparent, layout-neutral) so this screenshot
// captures the real pixels behind the glyphs — that's what
// __contrastAuditFinish samples directly instead of a proximity-based
// ring outside the text's bbox. See contrast-audit.browser.js for why:
// it's robust to rounded pills, cross-component panel edges,
// backdrop-filter blur, and partially-overlapping translucent
// decoration in ways a ring isn't.
//
// This call is the FIRST statement inside the try — not before it —
// so if prepare() itself throws partway through hiding elements, the
// finally below still runs and restores whatever it managed to hide.
const candidates = (await page.evaluate(() =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditPrepare === "function"
? (
(window as unknown as Record<string, unknown>).__contrastAuditPrepare as () => unknown
)()
: [],
)) as ContrastCandidate[];
const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
const entries = await page.evaluate(
(b64: string, time: number, cands: ContrastCandidate[]) =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditFinish === "function"
? ((window as unknown as Record<string, unknown>).__contrastAuditFinish as Function)(
b64,
time,
cands,
)
: [],
screenshot,
t,
candidates,
);
results.push(...(entries as ContrastEntry[]));
} finally {
// If prepare(), the screenshot, or finish() above throws, this restores
// any still-hidden text paint so the NEXT sample in the loop doesn't
// audit a page with stale invisible elements. No-op after a normal
// finish() call.
await page.evaluate(() => {
const restore = (window as unknown as Record<string, unknown>)
.__contrastAuditRestoreIfPending;
if (typeof restore === "function") (restore as () => void)();
});
}
}
return results;
}
function loadContrastAuditScript(): string {
const candidates = [
join(__dirname, "contrast-audit.browser.js"),
join(__dirname, "commands", "contrast-audit.browser.js"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
}
throw new Error("Missing contrast audit browser script");
}
/**
* Pull the `missing_or_empty_sub_composition` lint findings out of a
* `lintProject` result and shape them as `ConsoleEntry`s. Extracted as a
* pure function so it's testable without a headless browser or a real
* project directory — see validate.test.ts.
*/
export function extractCompositionErrorsFromLint(
lintResult: Pick<ProjectLintResult, "results">,
): ConsoleEntry[] {
return lintResult.results
.flatMap((r) => r.result.findings)
.filter((f) => f.code === "missing_or_empty_sub_composition" && f.severity === "error")
.map((f) => ({ level: "error" as const, text: f.message }));
}
// Match the render pipeline: localize remote <img>/<video>/<audio>/@font-face
// into a temp dir (served as an extra asset root) so validate resolves them
// same-origin and doesn't false-fail on cross-origin (crossorigin/CORS) fetches
// the real render never makes. Best-effort: no-op on any failure.
async function localizeRemoteAssets(
html: string,
): Promise<{ html: string; assetRoots: string[]; cleanup: () => void }> {
let dir: string | undefined;
try {
const { loadProducer } = await import("../utils/producer.js");
const { localizeRemoteMediaSources, localizeRemoteImageSources, localizeRemoteFontFaces } =
await loadProducer();
dir = mkdtempSync(join(tmpdir(), "hf-validate-assets-"));
const assetDir = dir;
const media = await localizeRemoteMediaSources(html, assetDir);
const images = await localizeRemoteImageSources(media.html, assetDir);
const fonts = await localizeRemoteFontFaces(images.html, assetDir);
const count =
media.remoteMediaAssets.size + images.remoteMediaAssets.size + fonts.remoteMediaAssets.size;
return {
html: fonts.html,
assetRoots: count > 0 ? [assetDir] : [],
cleanup: () => rmSync(assetDir, { recursive: true, force: true }),
};
} catch {
// Best-effort: drop any partial temp dir before falling back to remote URLs.
if (dir) rmSync(dir, { recursive: true, force: true });
return { html, assetRoots: [], cleanup: () => {} };
}
}
async function validateInBrowser(
project: ProjectDir,
opts: { timeout?: number; contrast?: boolean },
): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }> {
const projectDir = project.dir;
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js");
const { lintProject } = await import("../utils/lintProject.js");
// Fail fast on missing/empty/unparsable data-composition-src references
// before spending time bundling and launching a browser. The bundler
// (bundleToSingleHtml → inlineSubCompositions) is intentionally tolerant of
// these — it skips the broken scene and keeps going, silently, with only a
// console.warn — so validate would otherwise report "No console errors"
// for a project that renders a materially broken video. Surface it as a
// real validate failure instead.
const lintResult = await lintProject(projectDir);
const compositionErrors = extractCompositionErrorsFromLint(lintResult);
// `bundleToSingleHtml` now inlines the runtime IIFE by default, so the
// previous post-bundle regex substitution (which matched `src="..."` on the
// runtime tag) is no longer needed — there's no `src` attribute to match.
const html = await bundleToSingleHtml(projectDir);
const localized = await localizeRemoteAssets(html);
const server = await serveStaticProjectHtml(
projectDir,
localized.html,
undefined,
localized.assetRoots,
).catch((err) => {
// Server never started — the finally below won't run, so clean up here.
localized.cleanup();
throw err;
});
const errors: ConsoleEntry[] = [...compositionErrors];
const warnings: ConsoleEntry[] = [];
let contrast: ContrastEntry[] | undefined;
const viewport = resolveCompositionViewportFromHtml(html);
try {
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: buildChromeArgs(
{ ...viewport, captureMode: "screenshot" },
{ browserGpuMode: resolveCliChromeGpuMode() },
),
});
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
await page.setViewport(viewport);
page.on("console", (msg) => {
const type = msg.type();
const loc = msg.location();
const text = msg.text();
if (type === "error") {
if (text.startsWith("Failed to load resource")) return;
errors.push({ level: "error", text, url: loc.url, line: loc.lineNumber });
} else if (type === "warn") {
warnings.push({ level: "warning", text, url: loc.url, line: loc.lineNumber });
}
});
page.on("pageerror", (err) => {
const text = normalizeErrorMessage(err);
// CDN scripts (e.g. GSAP from jsdelivr) returning HTML error pages
// instead of JS produce "Unexpected token '<'" SyntaxErrors. These
// are network failures, not composition authoring errors.
if (text.includes("Unexpected token '<'") || text.includes("Unexpected token '&lt;'")) return;
errors.push({ level: "error", text });
});
page.on("requestfailed", (req) => {
const url = req.url();
if (url.includes("favicon") || url.startsWith("data:")) return;
const failureText = req.failure()?.errorText;
if (shouldIgnoreRequestFailure(url, failureText, req.resourceType())) return;
const path = decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
errors.push({
level: "error",
text: `Failed to load ${path}: ${failureText ?? "net::ERR_FAILED"}`,
url,
});
});
page.on("response", (res) => {
if (res.status() >= 400) {
const url = res.url();
if (url.includes("favicon")) return;
const path = decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
errors.push({ level: "error", text: `${res.status()} loading ${path}`, url });
}
});
const navTimeoutMs = resolveNavigationTimeoutMs(opts.timeout);
try {
await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: navTimeoutMs });
} catch (err) {
const hinted = navigationTimeoutHint(err, navTimeoutMs);
if (hinted) throw hinted;
throw err;
}
await new Promise((r) => setTimeout(r, opts.timeout ?? 3000));
for (const w of await auditClipDurations(page, analyzeClipMediaFit, opts.timeout ?? 3000)) {
warnings.push(w);
}
if (opts.contrast) {
contrast = await runContrastAudit(page);
}
await chromeBrowser.close();
} finally {
await server.close();
localized.cleanup();
}
return { errors, warnings, contrast };
}
function printContrastFailures(failures: ContrastEntry[]) {
console.log();
console.log(` ${c.warn("⚠")} WCAG AA contrast warnings (${failures.length}):`);
for (const cf of failures) {
const threshold = cf.large ? "3" : "4.5";
console.log(
` ${c.warn("·")} ${cf.selector} ${c.dim(`"${cf.text}"`)}${c.warn(cf.ratio + ":1")} ${c.dim(`(need ${threshold}:1, t=${cf.time}s)`)}`,
);
}
}
function emitJsonReport(
errors: ConsoleEntry[],
warnings: ConsoleEntry[],
contrast: ContrastEntry[] | undefined,
contrastFailures: ContrastEntry[],
): void {
console.log(
JSON.stringify(
withMeta(
{
ok: errors.length === 0,
errors,
warnings,
contrast,
contrastFailures: contrastFailures.length,
},
{ deprecated: true },
),
null,
2,
),
);
}
function formatConsoleEntry(prefix: string, e: ConsoleEntry): string {
return ` ${prefix} ${e.text}${e.line ? c.dim(` (line ${e.line})`) : ""}`;
}
function formatTotals(
errors: ConsoleEntry[],
warnings: ConsoleEntry[],
contrastFailures: ContrastEntry[],
): string {
const parts = [`${errors.length} error(s)`, `${warnings.length} warning(s)`];
if (contrastFailures.length > 0) parts.push(`${contrastFailures.length} contrast warning(s)`);
return parts.join(", ");
}
function emitTextReport(
errors: ConsoleEntry[],
warnings: ConsoleEntry[],
contrastFailures: ContrastEntry[],
contrastPassed: ContrastEntry[],
): void {
const hasIssues = errors.length > 0 || warnings.length > 0 || contrastFailures.length > 0;
if (!hasIssues) {
const suffix =
contrastPassed.length > 0 ? ` · ${contrastPassed.length} text elements pass WCAG AA` : "";
console.log(`${c.success("◇")} No console errors${suffix}`);
return;
}
console.log();
for (const e of errors) console.log(formatConsoleEntry(c.error("✗"), e));
for (const w of warnings) console.log(formatConsoleEntry(c.warn("⚠"), w));
if (contrastFailures.length > 0) printContrastFailures(contrastFailures);
console.log();
console.log(`${c.accent("◇")} ${formatTotals(errors, warnings, contrastFailures)}`);
}
function emitFailureReport(message: string, asJson: boolean): void {
if (asJson) {
console.log(
JSON.stringify(
withMeta({ ok: false, error: message, errors: [], warnings: [] }, { deprecated: true }),
null,
2,
),
);
return;
}
console.error(`${c.error("✗")} ${message}`);
}
export default defineCommand({
meta: {
name: "validate",
description: `Load a composition in headless Chrome and report console errors (deprecated, use check)
Examples:
hyperframes validate
hyperframes validate ./my-project
hyperframes validate --json
hyperframes validate --timeout 5000`,
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output as JSON", default: false },
contrast: {
type: "boolean",
description: "WCAG contrast audit (enabled by default)",
default: true,
},
timeout: {
type: "string",
description:
"Ms to wait for scripts to settle and media to load (default: 3000). Also raises the " +
"page-navigation budget above its 10s floor when a slow external <script> needs longer.",
default: "3000",
},
},
async run({ args }) {
printDeprecationNotice("validate");
const project = resolveProject(args.dir);
const timeout = parseInt(args.timeout as string, 10) || 3000;
const useContrast = args.contrast ?? true;
const asJson = Boolean(args.json);
if (!asJson) {
console.log(`${c.accent("◆")} Validating ${c.accent(project.name)} in headless Chrome`);
}
try {
const result = await validateInBrowser(project, { timeout, contrast: useContrast });
const exitCode = printValidationResult(result, asJson);
process.exit(exitCode);
} catch (err: unknown) {
const message = normalizeErrorMessage(err);
emitFailureReport(message, asJson);
process.exit(1);
}
},
});
function printValidationResult(
result: { errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] },
asJson: boolean,
): number {
const { errors, warnings, contrast } = result;
const contrastFailures = (contrast ?? []).filter((e) => !e.wcagAA);
const contrastPassed = (contrast ?? []).filter((e) => e.wcagAA);
if (asJson) {
emitJsonReport(errors, warnings, contrast, contrastFailures);
} else {
emitTextReport(errors, warnings, contrastFailures, contrastPassed);
}
return errors.length > 0 ? 1 : 0;
}