mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(cli): add check — single-session verification gate
One command, one Chrome boot: in-process lint gate (browser skipped on
lint errors), passive runtime capture wired before navigation, layout +
motion + contrast audits over one seek grid, optional --snapshots
persisting the contrast-pass screenshots. Aggregated --json envelope
{ok, lint, runtime, layout, motion, contrast, snapshots}; findings carry
selector/data-*/source-file/bbox/time anchors, contrast findings include
fg/bg, measured vs required ratio, and a compliant color suggestion.
Contrast AA failures gate the exit code (they were warning-only in
validate); --strict gates warnings.
Contrast candidates round-trip verbatim between __contrastAuditPrepare
and __contrastAuditFinish: the page script owns their shape (bbox w/h),
and normalizing them Node-side made every sample rect NaN — the audit
reported zero checked elements as green. Regression-pinned in
check.test.ts; E2E on a low-contrast fixture now exits 1 with 8 findings.
Measured on kinetic-type: check 5.6s vs 23.0s for sequential
validate + inspect + snapshot.
This commit is contained in:
@@ -171,7 +171,12 @@ describe("screenshot Chrome arguments", () => {
|
||||
);
|
||||
const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(captureSource).toMatch(defaultScreenshotArgs);
|
||||
// openSettledCompositionPage threads the caller's optional browserGpuMode;
|
||||
// callers that omit it (snapshot, compare) fall through to the engine's
|
||||
// software default for screenshot capture.
|
||||
expect(captureSource).toMatch(
|
||||
/args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/,
|
||||
);
|
||||
expect(layoutSource).toMatch(defaultScreenshotArgs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface SettledCompositionPage {
|
||||
export interface OpenSettledCompositionPageOptions {
|
||||
renderReadyTimeoutMs: number;
|
||||
renderReadyWarningSuffix: string;
|
||||
// Screenshot paths take the engine's software-GPU default; validate/check
|
||||
// thread the PRODUCER_BROWSER_GPU_MODE opt-in through here.
|
||||
browserGpuMode?: "software" | "hardware";
|
||||
// Runs after the page exists but before page.goto, so console/pageerror/
|
||||
// request listeners can attach without missing load-time events.
|
||||
beforeNavigate?: (page: Page) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface FfmpegRunResult {
|
||||
@@ -132,11 +138,15 @@ export async function openSettledCompositionPage(
|
||||
chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }),
|
||||
args: buildChromeArgs(
|
||||
{ ...viewport, captureMode: "screenshot" },
|
||||
{ browserGpuMode: options.browserGpuMode },
|
||||
),
|
||||
});
|
||||
|
||||
const page = await chromeBrowser.newPage();
|
||||
await page.setViewport(viewport);
|
||||
await options.beforeNavigate?.(page);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
|
||||
return { browser: chromeBrowser, page, renderReadyTimedOut };
|
||||
|
||||
@@ -119,6 +119,7 @@ const commandLoaders = {
|
||||
publish: () => import("./commands/publish.js").then((m) => m.default),
|
||||
render: () => import("./commands/render.js").then((m) => m.default),
|
||||
lint: () => import("./commands/lint.js").then((m) => m.default),
|
||||
check: () => import("./commands/check.js").then((m) => m.default),
|
||||
beats: () => import("./commands/beats.js").then((m) => m.default),
|
||||
inspect: () => import("./commands/inspect.js").then((m) => m.default),
|
||||
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import { runCommand } from "citty";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { contrastRatio, parseColorRGBA } from "./contrast-bg.js";
|
||||
import { createCheckCommand } from "./check.js";
|
||||
import {
|
||||
DEFAULT_CHECK_OPTIONS,
|
||||
checkExitCode,
|
||||
runAuditGrid,
|
||||
runCheckPipeline,
|
||||
selectContrastTimes,
|
||||
type AnchoredLayoutIssue,
|
||||
type CheckAnchor,
|
||||
type CheckAuditDriver,
|
||||
type CheckBrowserResult,
|
||||
type CheckDependencies,
|
||||
type CheckFinding,
|
||||
type CheckOptions,
|
||||
type CheckReport,
|
||||
type ContrastAuditEntry,
|
||||
type MotionSpecResolution,
|
||||
} from "../utils/checkPipeline.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
import type { LayoutIssue } from "../utils/layoutAudit.js";
|
||||
import type { ProjectDir } from "../utils/project.js";
|
||||
|
||||
const PROJECT: ProjectDir = {
|
||||
dir: "/project",
|
||||
name: "project",
|
||||
indexPath: "/project/index.html",
|
||||
};
|
||||
const PNG_BASE64 = Buffer.from("png-bytes").toString("base64");
|
||||
|
||||
function cleanLint(): ProjectLintResult {
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
file: "index.html",
|
||||
result: {
|
||||
ok: true,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
findings: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
totalErrors: 0,
|
||||
totalWarnings: 0,
|
||||
totalInfos: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function lintWith(
|
||||
severity: "error" | "warning" | "info",
|
||||
code: string,
|
||||
message: string,
|
||||
): ProjectLintResult {
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
file: "index.html",
|
||||
result: {
|
||||
ok: severity !== "error",
|
||||
errorCount: severity === "error" ? 1 : 0,
|
||||
warningCount: severity === "warning" ? 1 : 0,
|
||||
infoCount: severity === "info" ? 1 : 0,
|
||||
findings: [{ severity, code, message }],
|
||||
},
|
||||
},
|
||||
],
|
||||
totalErrors: severity === "error" ? 1 : 0,
|
||||
totalWarnings: severity === "warning" ? 1 : 0,
|
||||
totalInfos: severity === "info" ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function anchor(selector: string, time: number): CheckAnchor {
|
||||
return {
|
||||
selector,
|
||||
dataAttributes: { "data-layout-name": "hero" },
|
||||
sourceFile: "compositions/scene.html",
|
||||
bbox: { x: 10, y: 20, width: 300, height: 80 },
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
function layoutIssue(severity: "error" | "warning" | "info" = "error"): AnchoredLayoutIssue {
|
||||
return {
|
||||
...anchor("#hero", 0.5),
|
||||
code: severity === "warning" ? "content_overlap" : "clipped_text",
|
||||
severity,
|
||||
text: "Hero",
|
||||
message: severity === "warning" ? "Text may overlap." : "Text is clipped.",
|
||||
rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 },
|
||||
};
|
||||
}
|
||||
|
||||
function contrastEntry(overrides: Partial<ContrastAuditEntry> = {}): ContrastAuditEntry {
|
||||
return {
|
||||
...anchor("#hero", 0.5),
|
||||
text: "Body text",
|
||||
ratio: 2.5,
|
||||
wcagAA: false,
|
||||
large: false,
|
||||
fg: "rgb(110,110,110)",
|
||||
bg: "rgb(30,30,30)",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver {
|
||||
return {
|
||||
initialize: vi.fn(async (_contrast: boolean) => undefined),
|
||||
getDuration: vi.fn(async () => 9),
|
||||
getTransitionBoundaries: vi.fn(async () => []),
|
||||
getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })),
|
||||
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
|
||||
seek: vi.fn(async (_time: number) => undefined),
|
||||
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
||||
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
||||
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
||||
issues.map((issue) => ({
|
||||
...issue,
|
||||
...anchor(issue.selector, issue.time),
|
||||
})),
|
||||
),
|
||||
collectContrast: vi.fn(async (_time: number) => ({ entries: [], pngBase64: PNG_BASE64 })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function noMotion(): MotionSpecResolution {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
driver: CheckAuditDriver,
|
||||
options: {
|
||||
lint?: ProjectLintResult;
|
||||
motion?: MotionSpecResolution;
|
||||
runtime?: CheckFinding[];
|
||||
writeSnapshot?: CheckDependencies["writeSnapshot"];
|
||||
} = {},
|
||||
): { deps: CheckDependencies; runBrowserCheck: ReturnType<typeof vi.fn> } {
|
||||
const runBrowserCheck = vi.fn(
|
||||
async (
|
||||
_project: ProjectDir,
|
||||
checkOptions: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
): Promise<CheckBrowserResult> => {
|
||||
const result = await runAuditGrid(driver, checkOptions, motion);
|
||||
return { ...result, runtimeFindings: options.runtime ?? [] };
|
||||
},
|
||||
);
|
||||
const deps: CheckDependencies = {
|
||||
lintProject: vi.fn(async () => options.lint ?? cleanLint()),
|
||||
resolveMotionSpec: vi.fn(() => options.motion ?? noMotion()),
|
||||
runBrowserCheck,
|
||||
writeSnapshot:
|
||||
options.writeSnapshot ??
|
||||
vi.fn((_projectDir: string, index: number, time: number, _pngBase64: string) =>
|
||||
Promise.resolve(
|
||||
`snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
|
||||
),
|
||||
),
|
||||
};
|
||||
return { deps, runBrowserCheck };
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
driver: CheckAuditDriver,
|
||||
optionOverrides: Partial<CheckOptions> = {},
|
||||
dependencyOverrides: Parameters<typeof dependencies>[1] = {},
|
||||
): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType<typeof vi.fn> }> {
|
||||
const { deps, runBrowserCheck } = dependencies(driver, dependencyOverrides);
|
||||
const report = await runCheckPipeline(
|
||||
PROJECT,
|
||||
{ ...DEFAULT_CHECK_OPTIONS, ...optionOverrides },
|
||||
deps,
|
||||
);
|
||||
return { report, deps, browser: runBrowserCheck };
|
||||
}
|
||||
|
||||
function runtimeError(): CheckFinding {
|
||||
return {
|
||||
code: "console_error",
|
||||
severity: "error",
|
||||
message: "boom",
|
||||
...anchor("[data-composition-id]", 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("contrast sample selection", () => {
|
||||
it("chooses five evenly distributed grid points including both ends", () => {
|
||||
expect(selectContrastTimes([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5])).toEqual([
|
||||
0.5, 2.5, 4.5, 6.5, 8.5,
|
||||
]);
|
||||
expect(selectContrastTimes([1, 2, 3])).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("check pipeline", () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits one clean JSON envelope with every section and exit 0", async () => {
|
||||
const { report } = await runScenario(fakeDriver());
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const command = createCheckCommand({
|
||||
resolveProject: () => PROJECT,
|
||||
runPipeline: vi.fn(async () => report),
|
||||
withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
|
||||
});
|
||||
|
||||
await runCommand(command, { rawArgs: ["--json"] });
|
||||
|
||||
expect(report.ok).toBe(true);
|
||||
expect(checkExitCode(report)).toBe(0);
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
const output = log.mock.calls[0]?.[0];
|
||||
expect(typeof output).toBe("string");
|
||||
if (typeof output !== "string") throw new Error("expected JSON output");
|
||||
const envelope = JSON.parse(output);
|
||||
expect(envelope).toMatchObject({
|
||||
ok: true,
|
||||
lint: { ok: true },
|
||||
runtime: { ok: true },
|
||||
layout: { ok: true },
|
||||
motion: { ok: true },
|
||||
contrast: { ok: true },
|
||||
snapshots: { enabled: false },
|
||||
_meta: { version: "test" },
|
||||
});
|
||||
});
|
||||
|
||||
it("short-circuits on lint errors without launching a browser", async () => {
|
||||
const lint = lintWith(
|
||||
"error",
|
||||
"root_missing_composition_id",
|
||||
"Root element needs data-composition-id.",
|
||||
);
|
||||
const { report, browser } = await runScenario(fakeDriver(), {}, { lint });
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(checkExitCode(report)).toBe(1);
|
||||
expect(report.lint.findings).toHaveLength(1);
|
||||
expect(browser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gates AA contrast failures and --no-contrast skips the pass", async () => {
|
||||
const failingContrast = vi.fn(async (time: number) => ({
|
||||
entries: time === 0.5 ? [contrastEntry()] : [],
|
||||
pngBase64: PNG_BASE64,
|
||||
}));
|
||||
const { report } = await runScenario(fakeDriver({ collectContrast: failingContrast }));
|
||||
expect(report.ok).toBe(false);
|
||||
expect(checkExitCode(report)).toBe(1);
|
||||
expect(report.contrast.errorCount).toBe(1);
|
||||
|
||||
const skippedContrast = vi.fn(async () => ({
|
||||
entries: [contrastEntry()],
|
||||
pngBase64: PNG_BASE64,
|
||||
}));
|
||||
const skipped = await runScenario(fakeDriver({ collectContrast: skippedContrast }), {
|
||||
contrast: false,
|
||||
});
|
||||
expect(skipped.report.ok).toBe(true);
|
||||
expect(checkExitCode(skipped.report)).toBe(0);
|
||||
expect(skipped.report.contrast.enabled).toBe(false);
|
||||
expect(skippedContrast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes measured colors, thresholds, and a passing palette-direction suggestion", async () => {
|
||||
const { report } = await runScenario(
|
||||
fakeDriver({
|
||||
collectContrast: vi.fn(async () => ({
|
||||
entries: [contrastEntry()],
|
||||
pngBase64: PNG_BASE64,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
const finding = report.contrast.findings[0];
|
||||
expect(finding).toMatchObject({
|
||||
fg: "rgb(110,110,110)",
|
||||
bg: "rgb(30,30,30)",
|
||||
ratio: 2.5,
|
||||
requiredRatio: 4.5,
|
||||
});
|
||||
if (!finding) throw new Error("expected contrast finding");
|
||||
const suggested = parseColorRGBA(finding.suggestedColor);
|
||||
const background = parseColorRGBA(finding.bg);
|
||||
expect(suggested).not.toBeNull();
|
||||
expect(background).not.toBeNull();
|
||||
if (!suggested || !background) throw new Error("expected parseable colors");
|
||||
expect(
|
||||
contrastRatio(
|
||||
[suggested[0], suggested[1], suggested[2]],
|
||||
[background[0], background[1], background[2]],
|
||||
),
|
||||
).toBeGreaterThanOrEqual(finding.requiredRatio);
|
||||
expect(suggested[0]).toBeGreaterThan(110);
|
||||
});
|
||||
|
||||
it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => {
|
||||
const { report } = await runScenario(
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
||||
);
|
||||
expect(report.layout.findings[0]).toMatchObject({
|
||||
selector: "#hero",
|
||||
dataAttributes: { "data-layout-name": "hero" },
|
||||
sourceFile: "compositions/scene.html",
|
||||
bbox: { x: 10, y: 20, width: 300, height: 80 },
|
||||
time: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports layout and runtime errors from one browser session", async () => {
|
||||
const { report, browser } = await runScenario(
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
||||
{},
|
||||
{ runtime: [runtimeError()] },
|
||||
);
|
||||
expect(report.runtime.errorCount).toBe(1);
|
||||
expect(report.layout.errorCount).toBe(1);
|
||||
expect(browser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a failing appearsBy sidecar as motion_appears_late", async () => {
|
||||
const motion: MotionSpecResolution = {
|
||||
kind: "valid",
|
||||
path: "/project/index.motion.json",
|
||||
spec: { assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 0.2 }] },
|
||||
};
|
||||
const driver = fakeDriver({
|
||||
getDuration: vi.fn(async () => 1),
|
||||
collectMotionFrame: vi.fn(async (time: number) => ({
|
||||
time,
|
||||
data: {
|
||||
"#hero": {
|
||||
rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 },
|
||||
opacity: time >= 0.5 ? 1 : 0,
|
||||
visible: time >= 0.5,
|
||||
},
|
||||
},
|
||||
liveness: {},
|
||||
})),
|
||||
});
|
||||
const { report } = await runScenario(driver, {}, { motion });
|
||||
|
||||
expect(report.motion.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "motion_appears_late",
|
||||
severity: "error",
|
||||
selector: "#hero",
|
||||
}),
|
||||
]);
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("writes cached contrast PNGs only with --snapshots at the contrast timestamps", async () => {
|
||||
const writer = vi.fn(
|
||||
async (_projectDir: string, index: number, time: number, _pngBase64: string) =>
|
||||
`snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
|
||||
);
|
||||
const captured = fakeDriver({
|
||||
collectContrast: vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })),
|
||||
});
|
||||
const { report } = await runScenario(captured, { snapshots: true }, { writeSnapshot: writer });
|
||||
|
||||
expect(report.snapshots.times).toEqual([0.5, 2.5, 4.5, 6.5, 8.5]);
|
||||
expect(report.snapshots.files).toEqual([
|
||||
"snapshots/frame-00-at-0.5s.png",
|
||||
"snapshots/frame-01-at-2.5s.png",
|
||||
"snapshots/frame-02-at-4.5s.png",
|
||||
"snapshots/frame-03-at-6.5s.png",
|
||||
"snapshots/frame-04-at-8.5s.png",
|
||||
]);
|
||||
expect(writer).toHaveBeenCalledTimes(5);
|
||||
|
||||
const absentWriter = vi.fn(async () => "unused.png");
|
||||
await runScenario(fakeDriver(), { snapshots: false }, { writeSnapshot: absentWriter });
|
||||
expect(absentWriter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
|
||||
const warningDriver = () =>
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) });
|
||||
const normal = await runScenario(warningDriver(), { strict: false });
|
||||
const strict = await runScenario(warningDriver(), { strict: true });
|
||||
|
||||
expect(checkExitCode(normal.report)).toBe(0);
|
||||
expect(checkExitCode(strict.report)).toBe(1);
|
||||
});
|
||||
|
||||
it("fails clearly without samples when no timeline duration is available, without hanging", async () => {
|
||||
const driver = fakeDriver({ getDuration: vi.fn(async () => 0) });
|
||||
await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow(
|
||||
"Could not determine composition duration — no layout samples run",
|
||||
);
|
||||
|
||||
const { report, browser } = await runScenario(driver);
|
||||
expect(browser).toHaveBeenCalledTimes(1);
|
||||
expect(report.runtime.findings[0]?.message).toContain(
|
||||
"Could not determine composition duration — no layout samples run",
|
||||
);
|
||||
expect(checkExitCode(report)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrast candidate round-trip", () => {
|
||||
it("passes the browser script's raw candidates back to finish, never the normalized copies", () => {
|
||||
const source = readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8");
|
||||
|
||||
// __contrastAuditFinish samples pixels via the page script's own bbox
|
||||
// shape ({x, y, w, h}); sending the Node-normalized candidate
|
||||
// ({width, height}) makes every sample rect NaN and the audit silently
|
||||
// reports zero checked elements. The raw object must round-trip verbatim.
|
||||
expect(source).toMatch(/prepared\.map\(\(entry\) => entry\.raw\)/);
|
||||
expect(source).toMatch(/raw: unknown;/);
|
||||
expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { parseAt } from "./layout.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { formatLayoutIssue } from "../utils/layoutAudit.js";
|
||||
import { resolveProject, type ProjectDir } from "../utils/project.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
DEFAULT_CHECK_OPTIONS,
|
||||
checkExitCode,
|
||||
runCheckPipeline,
|
||||
type CheckFinding,
|
||||
type CheckOptions,
|
||||
type CheckReport,
|
||||
type CheckSection,
|
||||
} from "../utils/checkPipeline.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Run the full verification gate", "hyperframes check"],
|
||||
["Output one agent-readable envelope", "hyperframes check --json"],
|
||||
["Persist the five audited contrast frames", "hyperframes check --snapshots"],
|
||||
["Also fail on warnings", "hyperframes check --strict"],
|
||||
];
|
||||
|
||||
export interface CheckCommandDependencies {
|
||||
resolveProject(dir: string | undefined): ProjectDir;
|
||||
runPipeline(project: ProjectDir, options: CheckOptions): Promise<CheckReport>;
|
||||
withMeta(value: object): object;
|
||||
}
|
||||
|
||||
const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
|
||||
resolveProject,
|
||||
runPipeline: runCheckPipeline,
|
||||
withMeta,
|
||||
};
|
||||
|
||||
export function createCheckCommand(
|
||||
dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES,
|
||||
) {
|
||||
return defineCommand({
|
||||
meta: {
|
||||
name: "check",
|
||||
description:
|
||||
"Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session",
|
||||
},
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
json: { type: "boolean", description: "Output agent-readable JSON", default: false },
|
||||
samples: {
|
||||
type: "string",
|
||||
description: "Number of midpoint samples across the duration (default: 9)",
|
||||
default: "9",
|
||||
},
|
||||
at: {
|
||||
type: "string",
|
||||
description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
|
||||
},
|
||||
"at-transitions": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
|
||||
default: false,
|
||||
},
|
||||
"max-transition-samples": {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
|
||||
},
|
||||
"max-issues": {
|
||||
type: "string",
|
||||
description: "Maximum issues to print or return after static collapse (default: 80)",
|
||||
default: "80",
|
||||
},
|
||||
"collapse-static": {
|
||||
type: "boolean",
|
||||
description: "Collapse repeated static issues across samples (default: true)",
|
||||
default: true,
|
||||
},
|
||||
tolerance: {
|
||||
type: "string",
|
||||
description: "Allowed pixel overflow before reporting an issue (default: 2)",
|
||||
default: "2",
|
||||
},
|
||||
timeout: {
|
||||
type: "string",
|
||||
description: "Ms to wait for scripts and media to settle initially (default: 3000)",
|
||||
default: "3000",
|
||||
},
|
||||
contrast: {
|
||||
type: "boolean",
|
||||
description: "Run the WCAG AA contrast pass (enabled by default)",
|
||||
default: true,
|
||||
},
|
||||
strict: {
|
||||
type: "boolean",
|
||||
description: "Exit non-zero on warnings too",
|
||||
default: false,
|
||||
},
|
||||
snapshots: {
|
||||
type: "boolean",
|
||||
description: "Save the five contrast-pass PNGs under snapshots/",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = dependencies.resolveProject(args.dir);
|
||||
const options = parseCheckOptions(args);
|
||||
const asJson = args.json === true;
|
||||
if (!asJson) {
|
||||
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await dependencies.runPipeline(project, options);
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
|
||||
} else {
|
||||
printHumanReport(report);
|
||||
}
|
||||
process.exitCode = checkExitCode(report);
|
||||
} catch (error) {
|
||||
const message = normalizeErrorMessage(error);
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2),
|
||||
);
|
||||
} else {
|
||||
console.error(`${c.error("✗")} Check failed: ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
|
||||
const maxTransitionSamplesRaw = parseInt(String(args["max-transition-samples"] ?? ""), 10);
|
||||
return {
|
||||
samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples),
|
||||
at: parseAt(args.at),
|
||||
atTransitions: args["at-transitions"] === true,
|
||||
maxTransitionSamples:
|
||||
Number.isFinite(maxTransitionSamplesRaw) && maxTransitionSamplesRaw > 0
|
||||
? maxTransitionSamplesRaw
|
||||
: undefined,
|
||||
maxIssues: positiveInteger(args["max-issues"], DEFAULT_CHECK_OPTIONS.maxIssues),
|
||||
collapseStatic: args["collapse-static"] !== false,
|
||||
tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance),
|
||||
timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)),
|
||||
contrast: args.contrast !== false,
|
||||
strict: args.strict === true,
|
||||
snapshots: args.snapshots === true,
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number): number {
|
||||
const parsed = parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value: unknown, fallback: number): number {
|
||||
const parsed = parseFloat(String(value ?? ""));
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function printHumanReport(report: CheckReport): void {
|
||||
printSection("Lint", report.lint);
|
||||
printSection("Runtime", report.runtime);
|
||||
printLayoutSection("Layout", report.layout);
|
||||
printSection("Motion", report.motion);
|
||||
printContrastSection(report);
|
||||
printSnapshotSection(report);
|
||||
console.log();
|
||||
const label = report.ok ? c.success("Check passed") : c.error("Check failed");
|
||||
console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
|
||||
}
|
||||
|
||||
function printSection(title: string, section: CheckSection): void {
|
||||
console.log();
|
||||
console.log(c.bold(title));
|
||||
if (section.findings.length === 0) {
|
||||
console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
|
||||
return;
|
||||
}
|
||||
for (const finding of section.findings) printFinding(finding);
|
||||
printCounts(section);
|
||||
}
|
||||
|
||||
function printLayoutSection(title: string, section: CheckReport["layout"]): void {
|
||||
console.log();
|
||||
console.log(c.bold(title));
|
||||
if (section.findings.length === 0) {
|
||||
console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`);
|
||||
} else {
|
||||
for (const finding of section.findings) {
|
||||
const formatted = formatLayoutIssue(finding).replace(/\n/g, "\n ");
|
||||
console.log(` ${findingIcon(finding)} ${formatted}`);
|
||||
}
|
||||
printCounts(section);
|
||||
}
|
||||
if (section.transitionSamplesDropped > 0) {
|
||||
console.log(
|
||||
` ${c.warn("⚠")} ${section.transitionSamplesDropped} transition sample(s) omitted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printContrastSection(report: CheckReport): void {
|
||||
const section = report.contrast;
|
||||
console.log();
|
||||
console.log(c.bold("Contrast"));
|
||||
if (!section.enabled) {
|
||||
console.log(` ${c.dim("◇")} skipped`);
|
||||
return;
|
||||
}
|
||||
if (section.findings.length === 0) {
|
||||
console.log(
|
||||
` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const finding of section.findings) {
|
||||
console.log(
|
||||
` ${c.error("✗")} ${finding.selector} ${finding.ratio}:1 (need ${finding.requiredRatio}:1, t=${finding.time}s)`,
|
||||
);
|
||||
console.log(` ${c.dim(`Try ${finding.suggestedColor}; source ${finding.sourceFile}`)}`);
|
||||
}
|
||||
printCounts(section);
|
||||
}
|
||||
|
||||
function printSnapshotSection(report: CheckReport): void {
|
||||
console.log();
|
||||
console.log(c.bold("Snapshots"));
|
||||
if (!report.snapshots.enabled) {
|
||||
console.log(` ${c.dim("◇")} disabled`);
|
||||
} else {
|
||||
console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
|
||||
for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printFinding(finding: CheckFinding): void {
|
||||
const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`;
|
||||
console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`);
|
||||
console.log(` ${c.dim(where)}`);
|
||||
if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
|
||||
}
|
||||
|
||||
function findingIcon(finding: CheckFinding): string {
|
||||
if (finding.severity === "error") return c.error("✗");
|
||||
if (finding.severity === "warning") return c.warn("⚠");
|
||||
return c.dim("ℹ");
|
||||
}
|
||||
|
||||
function printCounts(section: CheckSection): void {
|
||||
console.log(
|
||||
` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default createCheckCommand();
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js";
|
||||
import {
|
||||
contrastRatio,
|
||||
parseColorRGBA,
|
||||
pickOpaqueBackground,
|
||||
relativeLuminance,
|
||||
requiredContrastRatio,
|
||||
suggestCompliantForegroundColor,
|
||||
} from "./contrast-bg.js";
|
||||
|
||||
const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" });
|
||||
|
||||
@@ -53,3 +60,51 @@ describe("pickOpaqueBackground", () => {
|
||||
expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relativeLuminance", () => {
|
||||
it("uses the WCAG sRGB transfer function", () => {
|
||||
expect(relativeLuminance([0, 0, 0])).toBe(0);
|
||||
expect(relativeLuminance([255, 255, 255])).toBe(1);
|
||||
expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrastRatio", () => {
|
||||
it("is symmetric and reaches 21:1 for black and white", () => {
|
||||
expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21);
|
||||
expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredContrastRatio", () => {
|
||||
it("requires 3:1 for large text and 4.5:1 otherwise", () => {
|
||||
expect(requiredContrastRatio(true)).toBe(3);
|
||||
expect(requiredContrastRatio(false)).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestCompliantForegroundColor", () => {
|
||||
it("brightens a failing foreground on a dark background until it passes", () => {
|
||||
const background: [number, number, number] = [20, 20, 20];
|
||||
const foreground: [number, number, number] = [80, 80, 80];
|
||||
const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
|
||||
|
||||
expect(suggested[0]).toBeGreaterThan(foreground[0]);
|
||||
expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("darkens a failing foreground on a light background until it passes", () => {
|
||||
const background: [number, number, number] = [245, 245, 245];
|
||||
const foreground: [number, number, number] = [180, 180, 180];
|
||||
const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
|
||||
|
||||
expect(suggested[0]).toBeLessThan(foreground[0]);
|
||||
expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("preserves a foreground that already passes", () => {
|
||||
expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([
|
||||
255, 255, 255,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,59 @@
|
||||
export type Rgb = [number, number, number];
|
||||
export type Rgba = [number, number, number, number];
|
||||
|
||||
/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */
|
||||
export function relativeLuminance(color: Rgb): number {
|
||||
const channel = (value: number) => {
|
||||
const srgb = value / 255;
|
||||
return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]);
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio between two opaque sRGB colors. */
|
||||
export function contrastRatio(first: Rgb, second: Rgb): number {
|
||||
const firstLuminance = relativeLuminance(first);
|
||||
const secondLuminance = relativeLuminance(second);
|
||||
const lighter = Math.max(firstLuminance, secondLuminance);
|
||||
const darker = Math.min(firstLuminance, secondLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** WCAG AA minimum contrast for body or large text. */
|
||||
export function requiredContrastRatio(large: boolean): number {
|
||||
return large ? 3 : 4.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest passing foreground on the line toward the higher-contrast
|
||||
* pole: white for a dark background, black for a light background.
|
||||
*/
|
||||
export function suggestCompliantForegroundColor(
|
||||
foreground: Rgb,
|
||||
background: Rgb,
|
||||
requiredRatio: number,
|
||||
): Rgb {
|
||||
if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground];
|
||||
|
||||
const black: Rgb = [0, 0, 0];
|
||||
const white: Rgb = [255, 255, 255];
|
||||
const target =
|
||||
contrastRatio(white, background) >= contrastRatio(black, background) ? white : black;
|
||||
|
||||
for (let step = 1; step <= 255; step += 1) {
|
||||
const amount = step / 255;
|
||||
const candidate: Rgb = [
|
||||
Math.round(foreground[0] + (target[0] - foreground[0]) * amount),
|
||||
Math.round(foreground[1] + (target[1] - foreground[1]) * amount),
|
||||
Math.round(foreground[2] + (target[2] - foreground[2]) * amount),
|
||||
];
|
||||
if (contrastRatio(candidate, background) >= requiredRatio) return candidate;
|
||||
}
|
||||
|
||||
return [...target];
|
||||
}
|
||||
|
||||
/** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */
|
||||
export function parseColorRGBA(color: string | null | undefined): Rgba | null {
|
||||
const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1];
|
||||
|
||||
@@ -269,7 +269,7 @@ async function runLayoutAudit(
|
||||
}
|
||||
}
|
||||
|
||||
function loadBrowserScript(name: string): string {
|
||||
export function loadBrowserScript(name: string): string {
|
||||
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
|
||||
@@ -408,7 +408,7 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseAt(value: unknown): number[] | undefined {
|
||||
export function parseAt(value: unknown): number[] | undefined {
|
||||
if (!value) return undefined;
|
||||
const times = String(value)
|
||||
.split(",")
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import type { Page } from "puppeteer-core";
|
||||
import {
|
||||
openSettledCompositionPage,
|
||||
resolveCliChromeGpuMode,
|
||||
seekCompositionTimeline,
|
||||
waitForPreferredSeekTarget,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
import { shouldIgnoreRequestFailure } from "../commands/validate.js";
|
||||
import { loadBrowserScript } from "../commands/layout.js";
|
||||
import { normalizeErrorMessage } from "./errorMessage.js";
|
||||
import { ambiguousIssue, type MotionFrame } from "./motionAudit.js";
|
||||
import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js";
|
||||
import { serveStaticProjectHtml } from "./staticProjectServer.js";
|
||||
import type {
|
||||
AnchoredLayoutIssue,
|
||||
CheckAnchor,
|
||||
CheckAuditDriver,
|
||||
CheckBbox,
|
||||
CheckBrowserResult,
|
||||
CheckFinding,
|
||||
CheckOptions,
|
||||
CheckSeverity,
|
||||
ContrastAuditEntry,
|
||||
ContrastCapture,
|
||||
MotionSpecResolution,
|
||||
RunAuditGrid,
|
||||
} from "./checkTypes.js";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
|
||||
const SEEK_OPTIONS = {
|
||||
fallbackToBridgeAndTimelines: true,
|
||||
animationFrameSettle: "double" as const,
|
||||
waitForFontsMs: 500,
|
||||
settleMs: 120,
|
||||
};
|
||||
|
||||
interface RuntimeDraft {
|
||||
code: string;
|
||||
severity: CheckSeverity;
|
||||
message: string;
|
||||
time: number;
|
||||
url?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
interface AnchorRequest {
|
||||
selector: string;
|
||||
time: number;
|
||||
bbox: CheckBbox;
|
||||
}
|
||||
|
||||
interface ContrastCandidate {
|
||||
selector: string;
|
||||
text: string;
|
||||
fg: [number, number, number, number];
|
||||
large: boolean;
|
||||
bbox: CheckBbox;
|
||||
}
|
||||
|
||||
interface PreparedContrast {
|
||||
// The untouched candidate object from __contrastAuditPrepare. It round-trips
|
||||
// back into __contrastAuditFinish verbatim — the browser script owns its
|
||||
// shape (e.g. bbox uses w/h, not width/height), so Node must not normalize
|
||||
// what it sends back. `candidate` is the parsed copy for Node-side reporting.
|
||||
raw: unknown;
|
||||
candidate: ContrastCandidate;
|
||||
anchor: CheckAnchor;
|
||||
}
|
||||
|
||||
interface FinishedContrast {
|
||||
selector: string;
|
||||
text: string;
|
||||
ratio: number;
|
||||
wcagAA: boolean;
|
||||
large: boolean;
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
export async function runBrowserCheck(
|
||||
project: ProjectDir,
|
||||
options: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
runGrid: RunAuditGrid,
|
||||
): Promise<CheckBrowserResult> {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const html = await bundleToSingleHtml(project.dir);
|
||||
const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
|
||||
const drafts: RuntimeDraft[] = [];
|
||||
let currentTime = 0;
|
||||
let chromeBrowser: import("puppeteer-core").Browser | undefined;
|
||||
|
||||
try {
|
||||
const session = await openSettledCompositionPage(html, server.url, {
|
||||
renderReadyTimeoutMs: options.timeout,
|
||||
renderReadyWarningSuffix: "checking the current page state",
|
||||
browserGpuMode: resolveCliChromeGpuMode(),
|
||||
beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime),
|
||||
});
|
||||
chromeBrowser = session.browser;
|
||||
const page = session.page;
|
||||
await waitForPreferredSeekTarget(page, 500);
|
||||
|
||||
const rootAnchor = await resolveRootAnchor(page);
|
||||
const driver = createPageDriver(page, (time) => {
|
||||
currentTime = time;
|
||||
});
|
||||
const result = await runGrid(driver, options, motion);
|
||||
return {
|
||||
...result,
|
||||
runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)),
|
||||
};
|
||||
} finally {
|
||||
await chromeBrowser?.close().catch(() => undefined);
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
|
||||
page.on("console", (message) => {
|
||||
const type = message.type();
|
||||
const text = message.text();
|
||||
if (type === "error" && !text.startsWith("Failed to load resource")) {
|
||||
const location = message.location();
|
||||
drafts.push({
|
||||
code: "console_error",
|
||||
severity: "error",
|
||||
message: text,
|
||||
time: currentTime(),
|
||||
url: location.url,
|
||||
line: location.lineNumber,
|
||||
});
|
||||
} else if (type === "warn") {
|
||||
const location = message.location();
|
||||
drafts.push({
|
||||
code: "console_warning",
|
||||
severity: "warning",
|
||||
message: text,
|
||||
time: currentTime(),
|
||||
url: location.url,
|
||||
line: location.lineNumber,
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
const message = normalizeErrorMessage(error);
|
||||
if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) {
|
||||
return;
|
||||
}
|
||||
drafts.push({ code: "page_error", severity: "error", message, time: currentTime() });
|
||||
});
|
||||
wireNetworkListeners(page, drafts, currentTime);
|
||||
}
|
||||
|
||||
function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("favicon") || url.startsWith("data:")) return;
|
||||
const failure = request.failure()?.errorText;
|
||||
if (shouldIgnoreRequestFailure(url, failure, request.resourceType())) return;
|
||||
drafts.push({
|
||||
code: "request_failed",
|
||||
severity: "error",
|
||||
message: `Failed to load ${urlPath(url)}: ${failure ?? "net::ERR_FAILED"}`,
|
||||
time: currentTime(),
|
||||
url,
|
||||
});
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() < 400) return;
|
||||
const url = response.url();
|
||||
if (url.includes("favicon")) return;
|
||||
drafts.push({
|
||||
code: "http_error",
|
||||
severity: "error",
|
||||
message: `${response.status()} loading ${urlPath(url)}`,
|
||||
time: currentTime(),
|
||||
url,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver {
|
||||
return {
|
||||
initialize: (contrast) => injectAuditScripts(page, contrast),
|
||||
getDuration: () => getCompositionDuration(page),
|
||||
getTransitionBoundaries: () => collectTweenBoundaries(page),
|
||||
getCanvas: () =>
|
||||
page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
|
||||
seek: async (time) => {
|
||||
setTime(time);
|
||||
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
|
||||
},
|
||||
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
||||
collectMotionFrame: (time, selectors, scopes) =>
|
||||
collectMotionFrame(page, time, selectors, scopes),
|
||||
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
|
||||
collectContrast: (time) => collectContrast(page, time),
|
||||
};
|
||||
}
|
||||
|
||||
async function injectAuditScripts(page: Page, contrast: boolean): Promise<void> {
|
||||
await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") });
|
||||
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
|
||||
if (contrast) {
|
||||
await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") });
|
||||
}
|
||||
}
|
||||
|
||||
async function getCompositionDuration(page: Page): Promise<number> {
|
||||
// Duration resolution is serialized into the page and must remain self-contained.
|
||||
// fallow-ignore-next-line complexity
|
||||
return page.evaluate(() => {
|
||||
const value = (target: unknown, key: string): unknown =>
|
||||
typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined;
|
||||
const positive = (candidate: unknown): number | null =>
|
||||
typeof candidate === "number" && candidate > 0 ? candidate : null;
|
||||
const callDuration = (target: unknown): number | null => {
|
||||
const duration = value(target, "duration");
|
||||
if (typeof duration === "function") {
|
||||
const result = Reflect.apply(duration, target, []);
|
||||
return positive(result);
|
||||
}
|
||||
return positive(duration);
|
||||
};
|
||||
const hfDuration = positive(value(Reflect.get(window, "__hf"), "duration"));
|
||||
if (hfDuration) return hfDuration;
|
||||
const playerDuration = callDuration(Reflect.get(window, "__player"));
|
||||
if (playerDuration) return playerDuration;
|
||||
const root = document.querySelector("[data-composition-id][data-duration]");
|
||||
const authored = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0;
|
||||
if (authored > 0) return authored;
|
||||
const timelines = Reflect.get(window, "__timelines");
|
||||
if (typeof timelines !== "object" || timelines === null) return 0;
|
||||
for (const key of Object.keys(timelines)) {
|
||||
const duration = callDuration(Reflect.get(timelines, key));
|
||||
if (duration) return duration;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function collectTweenBoundaries(page: Page): Promise<number[]> {
|
||||
// GSAP getter binding and parent-time conversion form one serialized algorithm.
|
||||
// fallow-ignore-next-line complexity
|
||||
return page.evaluate(() => {
|
||||
const property = (target: unknown, key: string): unknown =>
|
||||
(typeof target === "object" && target !== null) || typeof target === "function"
|
||||
? Reflect.get(target, key)
|
||||
: undefined;
|
||||
const numberCall = (target: unknown, key: string, fallback: number): number => {
|
||||
const method = property(target, key);
|
||||
if (typeof method !== "function") return fallback;
|
||||
const result = Reflect.apply(method, target, []);
|
||||
return typeof result === "number" ? result : fallback;
|
||||
};
|
||||
const rootTime = (root: unknown, animation: unknown, local: number): number => {
|
||||
let time = local;
|
||||
let node = animation;
|
||||
while (node && node !== root) {
|
||||
time = numberCall(node, "startTime", 0) + time / (numberCall(node, "timeScale", 1) || 1);
|
||||
node = property(node, "parent");
|
||||
}
|
||||
return time;
|
||||
};
|
||||
const timelines = Reflect.get(window, "__timelines");
|
||||
if (typeof timelines !== "object" || timelines === null) return [];
|
||||
const boundaries: number[] = [];
|
||||
for (const key of Object.keys(timelines)) {
|
||||
const timeline = Reflect.get(timelines, key);
|
||||
const getChildren = property(timeline, "getChildren");
|
||||
if (typeof getChildren !== "function") continue;
|
||||
try {
|
||||
const children = Reflect.apply(getChildren, timeline, [true, true, false]);
|
||||
if (!Array.isArray(children)) continue;
|
||||
for (const child of children) {
|
||||
const duration = numberCall(child, "duration", Number.NaN);
|
||||
if (!Number.isFinite(duration)) continue;
|
||||
boundaries.push(rootTime(timeline, child, 0), rootTime(timeline, child, duration));
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return boundaries.filter(Number.isFinite);
|
||||
});
|
||||
}
|
||||
|
||||
async function collectLayout(
|
||||
page: Page,
|
||||
time: number,
|
||||
tolerance: number,
|
||||
): Promise<AnchoredLayoutIssue[]> {
|
||||
const raw = await page.evaluate(
|
||||
(options: { time: number; tolerance: number }) => {
|
||||
const audit = Reflect.get(window, "__hyperframesLayoutAudit");
|
||||
if (typeof audit !== "function") return [];
|
||||
const result = Reflect.apply(audit, window, [options]);
|
||||
return Array.isArray(result) ? result : [];
|
||||
},
|
||||
{ time, tolerance },
|
||||
);
|
||||
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
|
||||
}
|
||||
|
||||
async function findAmbiguousSelectors(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
): Promise<AnchoredLayoutIssue[]> {
|
||||
const ambiguous = await page.evaluate(
|
||||
(values: string[]) =>
|
||||
values.filter((selector) => {
|
||||
try {
|
||||
return document.querySelectorAll(selector).length > 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
selectors,
|
||||
);
|
||||
return anchorLayoutIssues(page, ambiguous.map(ambiguousIssue));
|
||||
}
|
||||
|
||||
async function collectMotionFrame(
|
||||
page: Page,
|
||||
time: number,
|
||||
selectors: string[],
|
||||
livenessScopes: string[],
|
||||
): Promise<MotionFrame> {
|
||||
const raw = await page.evaluate(
|
||||
(options: { selectors: string[]; livenessScopes: string[] }) => {
|
||||
const sample = Reflect.get(window, "__hyperframesMotionSample");
|
||||
if (typeof sample !== "function") return null;
|
||||
return Reflect.apply(sample, window, [options]);
|
||||
},
|
||||
{ selectors, livenessScopes },
|
||||
);
|
||||
return parseMotionFrame(raw, time, selectors, livenessScopes);
|
||||
}
|
||||
|
||||
async function anchorLayoutIssues(
|
||||
page: Page,
|
||||
issues: LayoutIssue[],
|
||||
): Promise<AnchoredLayoutIssue[]> {
|
||||
const requests = issues.map((issue) => ({
|
||||
selector: issue.selector,
|
||||
time: issue.time,
|
||||
bbox: rectToBbox(issue.rect),
|
||||
}));
|
||||
const anchors = await resolveAnchors(page, requests);
|
||||
return issues.map((issue, index) => ({
|
||||
...issue,
|
||||
...(anchors[index] ?? fallbackAnchor(requests[index])),
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise<CheckAnchor[]> {
|
||||
if (requests.length === 0) return [];
|
||||
return page.evaluate((values: AnchorRequest[]) => {
|
||||
const root = document.querySelector("[data-composition-id]");
|
||||
return values.map((request) => {
|
||||
let element: Element | null = null;
|
||||
try {
|
||||
element = document.querySelector(request.selector);
|
||||
} catch {
|
||||
element = null;
|
||||
}
|
||||
element ??= root;
|
||||
// Clones the anchor-extraction block in prepareContrast's evaluate() below;
|
||||
// both run inside separate serialized browser closures and can't share a
|
||||
// Node-side helper.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const dataAttributes: Record<string, string> = {};
|
||||
for (const attribute of Array.from(element?.attributes ?? [])) {
|
||||
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
|
||||
}
|
||||
const source = element
|
||||
?.closest("[data-composition-file]")
|
||||
?.getAttribute("data-composition-file");
|
||||
return {
|
||||
selector: element ? request.selector : "[data-composition-id]",
|
||||
dataAttributes,
|
||||
sourceFile: source || "index.html",
|
||||
bbox: request.bbox,
|
||||
time: request.time,
|
||||
};
|
||||
});
|
||||
}, requests);
|
||||
}
|
||||
|
||||
async function resolveRootAnchor(page: Page): Promise<CheckAnchor> {
|
||||
const anchors = await resolveAnchors(page, [
|
||||
{ selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) },
|
||||
]);
|
||||
return anchors[0] ?? fallbackAnchor(undefined);
|
||||
}
|
||||
|
||||
async function compositionBbox(page: Page): Promise<CheckBbox> {
|
||||
return page.evaluate(() => {
|
||||
const element = document.querySelector("[data-composition-id]");
|
||||
const rect = element?.getBoundingClientRect();
|
||||
return rect
|
||||
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
: { x: 0, y: 0, width: 0, height: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
async function collectContrast(page: Page, time: number): Promise<ContrastCapture> {
|
||||
let prepared: PreparedContrast[] = [];
|
||||
try {
|
||||
prepared = parsePreparedContrast(await prepareContrast(page, time));
|
||||
const screenshot = await page.screenshot({ encoding: "base64", type: "png" });
|
||||
if (typeof screenshot !== "string") throw new Error("Contrast screenshot was not base64");
|
||||
const raw = await finishContrast(
|
||||
page,
|
||||
screenshot,
|
||||
time,
|
||||
prepared.map((entry) => entry.raw),
|
||||
);
|
||||
const finished = raw.flatMap(parseFinishedContrast);
|
||||
return { entries: joinContrastEntries(finished, prepared), pngBase64: screenshot };
|
||||
} finally {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const restore = Reflect.get(window, "__contrastAuditRestoreIfPending");
|
||||
if (typeof restore === "function") Reflect.apply(restore, window, []);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareContrast(page: Page, time: number): Promise<unknown[]> {
|
||||
// Candidate-to-element provenance must be captured while the prepare restore list is live.
|
||||
// fallow-ignore-next-line complexity
|
||||
return page.evaluate((sampleTime: number) => {
|
||||
const prepare = Reflect.get(window, "__contrastAuditPrepare");
|
||||
const candidates = typeof prepare === "function" ? Reflect.apply(prepare, window, []) : [];
|
||||
if (!Array.isArray(candidates)) return [];
|
||||
const restores = Reflect.get(window, "__contrastAuditRestores");
|
||||
const restoreList = Array.isArray(restores) ? restores : [];
|
||||
const escape = (value: string) =>
|
||||
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
||||
? CSS.escape(value)
|
||||
: value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
const selectorFor = (element: Element | null, fallback: string): string => {
|
||||
if (!element) return fallback;
|
||||
if (element.id) return `#${escape(element.id)}`;
|
||||
const parts: string[] = [];
|
||||
for (
|
||||
let current: Element | null = element;
|
||||
current && current !== document.body;
|
||||
current = current.parentElement
|
||||
) {
|
||||
const tag = current.tagName.toLowerCase();
|
||||
const siblings = current.parentElement
|
||||
? Array.from(current.parentElement.children).filter(
|
||||
(item) => item.tagName === current?.tagName,
|
||||
)
|
||||
: [];
|
||||
parts.push(
|
||||
siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag,
|
||||
);
|
||||
}
|
||||
return parts.reverse().join(" > ") || fallback;
|
||||
};
|
||||
// Part of the serialized evaluate body above; cannot delegate to Node helpers.
|
||||
// fallow-ignore-next-line complexity
|
||||
return candidates.map((candidate, index) => {
|
||||
const restore = restoreList[index];
|
||||
const candidateObject = typeof candidate === "object" && candidate !== null ? candidate : {};
|
||||
const elementValue =
|
||||
typeof restore === "object" && restore !== null ? Reflect.get(restore, "el") : null;
|
||||
const element = elementValue instanceof Element ? elementValue : null;
|
||||
const fallback = Reflect.get(candidateObject, "selector");
|
||||
const selector = selectorFor(
|
||||
element,
|
||||
typeof fallback === "string" ? fallback : "[data-composition-id]",
|
||||
);
|
||||
const dataAttributes: Record<string, string> = {};
|
||||
for (const attribute of Array.from(element?.attributes ?? [])) {
|
||||
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
|
||||
}
|
||||
const source = element
|
||||
?.closest("[data-composition-file]")
|
||||
?.getAttribute("data-composition-file");
|
||||
return {
|
||||
candidate: { ...candidateObject, selector },
|
||||
anchor: {
|
||||
selector,
|
||||
dataAttributes,
|
||||
sourceFile: source || "index.html",
|
||||
bbox: Reflect.get(candidateObject, "bbox"),
|
||||
time: sampleTime,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, time);
|
||||
}
|
||||
|
||||
async function finishContrast(
|
||||
page: Page,
|
||||
screenshot: string,
|
||||
time: number,
|
||||
candidates: unknown[],
|
||||
): Promise<unknown[]> {
|
||||
return page.evaluate(
|
||||
async (payload: { screenshot: string; time: number; candidates: unknown[] }) => {
|
||||
const finish = Reflect.get(window, "__contrastAuditFinish");
|
||||
if (typeof finish !== "function") return [];
|
||||
const result = await Reflect.apply(finish, window, [
|
||||
payload.screenshot,
|
||||
payload.time,
|
||||
payload.candidates,
|
||||
]);
|
||||
return Array.isArray(result) ? result : [];
|
||||
},
|
||||
{ screenshot, time, candidates },
|
||||
);
|
||||
}
|
||||
|
||||
function parsePreparedContrast(raw: unknown[]): PreparedContrast[] {
|
||||
return raw.flatMap((value) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const raw = Reflect.get(value, "candidate");
|
||||
const candidate = parseContrastCandidate(raw);
|
||||
const anchor = parseAnchor(Reflect.get(value, "anchor"));
|
||||
return candidate && anchor ? [{ raw, candidate, anchor }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function parseContrastCandidate(value: unknown): ContrastCandidate | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const selector = stringValue(value, "selector");
|
||||
const text = stringValue(value, "text");
|
||||
const fg = rgbaValue(Reflect.get(value, "fg"));
|
||||
const large = booleanValue(value, "large");
|
||||
const bbox = parseBbox(Reflect.get(value, "bbox"));
|
||||
return selector && text !== null && fg && large !== null && bbox
|
||||
? { selector, text, fg, large, bbox }
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseFinishedContrast(value: unknown): FinishedContrast[] {
|
||||
if (!isRecord(value)) return [];
|
||||
const selector = stringValue(value, "selector");
|
||||
const text = stringValue(value, "text");
|
||||
const ratio = numberValue(value, "ratio");
|
||||
const wcagAA = booleanValue(value, "wcagAA");
|
||||
const large = booleanValue(value, "large");
|
||||
const fg = stringValue(value, "fg");
|
||||
const bg = stringValue(value, "bg");
|
||||
return selector &&
|
||||
text !== null &&
|
||||
ratio !== null &&
|
||||
wcagAA !== null &&
|
||||
large !== null &&
|
||||
fg &&
|
||||
bg
|
||||
? [{ selector, text, ratio, wcagAA, large, fg, bg }]
|
||||
: [];
|
||||
}
|
||||
|
||||
function joinContrastEntries(
|
||||
finished: FinishedContrast[],
|
||||
prepared: PreparedContrast[],
|
||||
): ContrastAuditEntry[] {
|
||||
const remaining = [...prepared];
|
||||
return finished.flatMap((entry) => {
|
||||
const index = remaining.findIndex(
|
||||
(candidate) =>
|
||||
candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text,
|
||||
);
|
||||
const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined;
|
||||
return match ? [{ ...entry, ...match.anchor }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function parseLayoutIssue(value: unknown): LayoutIssue[] {
|
||||
if (!isRecord(value)) return [];
|
||||
const code = layoutCodeValue(Reflect.get(value, "code"));
|
||||
const severity = severityValue(Reflect.get(value, "severity"));
|
||||
const time = numberValue(value, "time");
|
||||
const selector = stringValue(value, "selector");
|
||||
const message = stringValue(value, "message");
|
||||
const rect = parseRect(Reflect.get(value, "rect"));
|
||||
if (!code || !severity || time === null || !selector || !message || !rect) return [];
|
||||
const issue: LayoutIssue = { code, severity, time, selector, message, rect };
|
||||
assignOptionalLayoutFields(issue, value);
|
||||
return [issue];
|
||||
}
|
||||
|
||||
function assignOptionalLayoutFields(issue: LayoutIssue, value: Record<string, unknown>): void {
|
||||
assignOptionalString(issue, value, "containerSelector");
|
||||
assignOptionalString(issue, value, "text");
|
||||
assignOptionalString(issue, value, "fixHint");
|
||||
const containerRect = parseRect(Reflect.get(value, "containerRect"));
|
||||
if (containerRect) issue.containerRect = containerRect;
|
||||
const overflow = parseOverflow(Reflect.get(value, "overflow"));
|
||||
if (overflow) issue.overflow = overflow;
|
||||
}
|
||||
|
||||
function recordField(value: unknown, key: string): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const field = Reflect.get(value, key);
|
||||
return isRecord(field) ? field : null;
|
||||
}
|
||||
|
||||
function parseMotionFrame(
|
||||
value: unknown,
|
||||
time: number,
|
||||
selectors: string[],
|
||||
scopes: string[],
|
||||
): MotionFrame {
|
||||
const rawData = recordField(value, "data");
|
||||
const rawLiveness = recordField(value, "liveness");
|
||||
const data: MotionFrame["data"] = {};
|
||||
for (const selector of selectors) {
|
||||
data[selector] = rawData ? parseFrameSample(Reflect.get(rawData, selector)) : null;
|
||||
}
|
||||
const liveness: Record<string, string> = {};
|
||||
for (const scope of scopes) {
|
||||
const signature = rawLiveness ? Reflect.get(rawLiveness, scope) : "";
|
||||
liveness[scope] = typeof signature === "string" ? signature : "";
|
||||
}
|
||||
return { time, data, liveness };
|
||||
}
|
||||
|
||||
function parseFrameSample(value: unknown): MotionFrame["data"][string] {
|
||||
if (!isRecord(value)) return null;
|
||||
const rect = parseRect(Reflect.get(value, "rect"));
|
||||
const opacity = numberValue(value, "opacity");
|
||||
const visible = booleanValue(value, "visible");
|
||||
return rect && opacity !== null && visible !== null ? { rect, opacity, visible } : null;
|
||||
}
|
||||
|
||||
function parseAnchor(value: unknown): CheckAnchor | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const selector = stringValue(value, "selector");
|
||||
const sourceFile = stringValue(value, "sourceFile");
|
||||
const time = numberValue(value, "time");
|
||||
const bbox = parseBbox(Reflect.get(value, "bbox"));
|
||||
const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes"));
|
||||
return selector && sourceFile && time !== null && bbox && dataAttributes
|
||||
? { selector, sourceFile, time, bbox, dataAttributes }
|
||||
: null;
|
||||
}
|
||||
|
||||
function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding {
|
||||
return {
|
||||
code: draft.code,
|
||||
severity: draft.severity,
|
||||
message: draft.message,
|
||||
selector: root.selector,
|
||||
dataAttributes: root.dataAttributes,
|
||||
sourceFile: root.sourceFile,
|
||||
bbox: root.bbox,
|
||||
time: draft.time,
|
||||
url: draft.url,
|
||||
line: draft.line,
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor {
|
||||
return {
|
||||
selector: request?.selector ?? "[data-composition-id]",
|
||||
dataAttributes: {},
|
||||
sourceFile: "index.html",
|
||||
bbox: request?.bbox ?? { x: 0, y: 0, width: 0, height: 0 },
|
||||
time: request?.time ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rectToBbox(rect: LayoutRect): CheckBbox {
|
||||
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function parseBbox(value: unknown): CheckBbox | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const x = numberValue(value, "x");
|
||||
const y = numberValue(value, "y");
|
||||
const width = numberValue(value, "width") ?? numberValue(value, "w");
|
||||
const height = numberValue(value, "height") ?? numberValue(value, "h");
|
||||
return x !== null && y !== null && width !== null && height !== null
|
||||
? { x, y, width, height }
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseRect(value: unknown): LayoutRect | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const left = numberValue(value, "left");
|
||||
const top = numberValue(value, "top");
|
||||
const right = numberValue(value, "right");
|
||||
const bottom = numberValue(value, "bottom");
|
||||
const width = numberValue(value, "width");
|
||||
const height = numberValue(value, "height");
|
||||
return left !== null &&
|
||||
top !== null &&
|
||||
right !== null &&
|
||||
bottom !== null &&
|
||||
width !== null &&
|
||||
height !== null
|
||||
? { left, top, right, bottom, width, height }
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseOverflow(value: unknown): LayoutIssue["overflow"] | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const overflow: LayoutIssue["overflow"] = {};
|
||||
for (const side of ["left", "right", "top", "bottom"] as const) {
|
||||
const amount = numberValue(value, side);
|
||||
if (amount !== null) overflow[side] = amount;
|
||||
}
|
||||
return Object.keys(overflow).length > 0 ? overflow : null;
|
||||
}
|
||||
|
||||
const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
|
||||
"text_box_overflow",
|
||||
"clipped_text",
|
||||
"canvas_overflow",
|
||||
"container_overflow",
|
||||
"content_overlap",
|
||||
"text_occluded",
|
||||
"motion_appears_late",
|
||||
"motion_out_of_order",
|
||||
"motion_off_frame",
|
||||
"motion_frozen",
|
||||
"motion_selector_missing",
|
||||
"motion_selector_ambiguous",
|
||||
];
|
||||
|
||||
function layoutCodeValue(value: unknown): LayoutIssueCode | null {
|
||||
return LAYOUT_ISSUE_CODES.find((code) => code === value) ?? null;
|
||||
}
|
||||
|
||||
function severityValue(value: unknown): CheckSeverity | null {
|
||||
return value === "error" || value === "warning" || value === "info" ? value : null;
|
||||
}
|
||||
|
||||
function rgbaValue(value: unknown): [number, number, number, number] | null {
|
||||
if (!Array.isArray(value) || value.length < 4) return null;
|
||||
const [red, green, blue, alpha] = value;
|
||||
return [red, green, blue, alpha].every((channel) => typeof channel === "number")
|
||||
? [red, green, blue, alpha]
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringRecord(value: unknown): Record<string, string> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const record: Record<string, string> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const entry = Reflect.get(value, key);
|
||||
if (typeof entry !== "string") return null;
|
||||
record[key] = entry;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function assignOptionalString(
|
||||
issue: LayoutIssue,
|
||||
source: Record<string, unknown>,
|
||||
key: "containerSelector" | "text" | "fixHint",
|
||||
): void {
|
||||
const value = stringValue(source, key);
|
||||
if (value !== null) issue[key] = value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: Record<string, unknown>, key: string): string | null {
|
||||
const entry = Reflect.get(value, key);
|
||||
return typeof entry === "string" ? entry : null;
|
||||
}
|
||||
|
||||
function numberValue(value: Record<string, unknown>, key: string): number | null {
|
||||
const entry = Reflect.get(value, key);
|
||||
return typeof entry === "number" && Number.isFinite(entry) ? entry : null;
|
||||
}
|
||||
|
||||
function booleanValue(value: Record<string, unknown>, key: string): boolean | null {
|
||||
const entry = Reflect.get(value, key);
|
||||
return typeof entry === "boolean" ? entry : null;
|
||||
}
|
||||
|
||||
function urlPath(url: string): string {
|
||||
try {
|
||||
return decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
import { lintProject, shouldBlockRender, type ProjectLintResult } from "./lintProject.js";
|
||||
import {
|
||||
buildLayoutSampleTimes,
|
||||
buildTransitionSampleTimes,
|
||||
collapseStaticLayoutIssues,
|
||||
dedupeLayoutIssues,
|
||||
limitLayoutIssues,
|
||||
mergeSampleTimes,
|
||||
type LayoutIssue,
|
||||
type LayoutRect,
|
||||
} from "./layoutAudit.js";
|
||||
import { collectSamplingTargets, evaluateMotion, type MotionFrame } from "./motionAudit.js";
|
||||
import { findMotionSpec, readMotionSpec } from "./motionSpec.js";
|
||||
import { normalizeErrorMessage } from "./errorMessage.js";
|
||||
import {
|
||||
parseColorRGBA,
|
||||
requiredContrastRatio,
|
||||
suggestCompliantForegroundColor,
|
||||
type Rgb,
|
||||
} from "../commands/contrast-bg.js";
|
||||
import type {
|
||||
AnchoredLayoutIssue,
|
||||
CheckAuditDriver,
|
||||
CheckBbox,
|
||||
CheckBrowserResult,
|
||||
CheckContrastFinding,
|
||||
CheckDependencies,
|
||||
CheckFinding,
|
||||
CheckOptions,
|
||||
CheckReport,
|
||||
CheckScreenshot,
|
||||
CheckSection,
|
||||
CheckSeverity,
|
||||
ContrastAuditEntry,
|
||||
MotionSpecResolution,
|
||||
} from "./checkTypes.js";
|
||||
|
||||
export type {
|
||||
AnchoredLayoutIssue,
|
||||
CheckAnchor,
|
||||
CheckAuditDriver,
|
||||
CheckBrowserResult,
|
||||
CheckDependencies,
|
||||
CheckFinding,
|
||||
CheckOptions,
|
||||
CheckReport,
|
||||
CheckSection,
|
||||
ContrastAuditEntry,
|
||||
MotionSpecResolution,
|
||||
} from "./checkTypes.js";
|
||||
|
||||
const MOTION_FPS = 20;
|
||||
const MOTION_MAX_SAMPLES = 300;
|
||||
const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
export const DEFAULT_CHECK_OPTIONS: CheckOptions = {
|
||||
samples: 9,
|
||||
atTransitions: false,
|
||||
maxIssues: 80,
|
||||
collapseStatic: true,
|
||||
tolerance: 2,
|
||||
timeout: 3000,
|
||||
contrast: true,
|
||||
strict: false,
|
||||
snapshots: false,
|
||||
};
|
||||
|
||||
/** Pick at most five evenly-strided points from the already-merged layout grid. */
|
||||
export function selectContrastTimes(grid: number[]): number[] {
|
||||
if (grid.length <= 5) return [...grid];
|
||||
return Array.from({ length: 5 }, (_, index) => {
|
||||
const selected = Math.floor((index * (grid.length - 1)) / 4);
|
||||
return grid[selected] ?? grid[0] ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
function buildMotionSampleTimes(duration: number): number[] {
|
||||
if (!Number.isFinite(duration) || duration <= 0) return [];
|
||||
const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
|
||||
const step = duration / (count - 1);
|
||||
return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
|
||||
}
|
||||
|
||||
interface SampleGrid {
|
||||
duration: number;
|
||||
layoutSamples: number[];
|
||||
transitionSamples: number[];
|
||||
transitionSamplesDropped: number;
|
||||
contrastSamples: number[];
|
||||
}
|
||||
|
||||
async function buildSampleGrid(
|
||||
driver: CheckAuditDriver,
|
||||
options: CheckOptions,
|
||||
): Promise<SampleGrid> {
|
||||
const duration = await driver.getDuration();
|
||||
const baseSamples = buildLayoutSampleTimes({
|
||||
duration,
|
||||
samples: options.samples,
|
||||
at: options.at,
|
||||
});
|
||||
const transitions = options.atTransitions
|
||||
? buildTransitionSampleTimes({
|
||||
duration,
|
||||
boundaries: await driver.getTransitionBoundaries(),
|
||||
cap: options.maxTransitionSamples,
|
||||
})
|
||||
: { times: [], dropped: 0 };
|
||||
const layoutSamples = mergeSampleTimes(baseSamples, transitions.times);
|
||||
if (layoutSamples.length === 0) {
|
||||
throw new Error("Could not determine composition duration — no layout samples run");
|
||||
}
|
||||
return {
|
||||
duration,
|
||||
layoutSamples,
|
||||
transitionSamples: transitions.times,
|
||||
transitionSamplesDropped: transitions.dropped,
|
||||
contrastSamples: options.contrast ? selectContrastTimes(layoutSamples) : [],
|
||||
};
|
||||
}
|
||||
|
||||
interface MotionPlan {
|
||||
times: number[];
|
||||
selectors: string[];
|
||||
livenessScopes: string[];
|
||||
preflightIssues: AnchoredLayoutIssue[];
|
||||
}
|
||||
|
||||
async function planMotionSampling(
|
||||
driver: CheckAuditDriver,
|
||||
motion: MotionSpecResolution,
|
||||
duration: number,
|
||||
): Promise<MotionPlan> {
|
||||
if (motion.kind !== "valid") {
|
||||
return { times: [], selectors: [], livenessScopes: [], preflightIssues: [] };
|
||||
}
|
||||
const targets = collectSamplingTargets(motion.spec.assertions);
|
||||
const preflightIssues = await driver.findAmbiguousSelectors(targets.selectors);
|
||||
const times =
|
||||
preflightIssues.length === 0 ? buildMotionSampleTimes(motion.spec.duration ?? duration) : [];
|
||||
return { times, ...targets, preflightIssues };
|
||||
}
|
||||
|
||||
interface GridSamples {
|
||||
layoutIssues: AnchoredLayoutIssue[];
|
||||
motionFrames: MotionFrame[];
|
||||
contrastEntries: ContrastAuditEntry[];
|
||||
screenshots: CheckScreenshot[];
|
||||
}
|
||||
|
||||
async function collectGridSamples(
|
||||
driver: CheckAuditDriver,
|
||||
options: CheckOptions,
|
||||
grid: SampleGrid,
|
||||
motion: MotionPlan,
|
||||
): Promise<GridSamples> {
|
||||
const layoutSet = new Set(grid.layoutSamples);
|
||||
const motionSet = new Set(motion.times);
|
||||
const contrastSet = new Set(grid.contrastSamples);
|
||||
const collected: GridSamples = {
|
||||
layoutIssues: [],
|
||||
motionFrames: [],
|
||||
contrastEntries: [],
|
||||
screenshots: [],
|
||||
};
|
||||
for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
|
||||
await driver.seek(time);
|
||||
if (layoutSet.has(time)) {
|
||||
collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance)));
|
||||
}
|
||||
if (motionSet.has(time)) {
|
||||
collected.motionFrames.push(
|
||||
await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes),
|
||||
);
|
||||
}
|
||||
if (contrastSet.has(time)) {
|
||||
const capture = await driver.collectContrast(time);
|
||||
collected.contrastEntries.push(...capture.entries);
|
||||
collected.screenshots.push({ time, pngBase64: capture.pngBase64 });
|
||||
}
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
export async function runAuditGrid(
|
||||
driver: CheckAuditDriver,
|
||||
options: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
): Promise<CheckBrowserResult> {
|
||||
await driver.initialize(options.contrast);
|
||||
const grid = await buildSampleGrid(driver, options);
|
||||
const plan = await planMotionSampling(driver, motion, grid.duration);
|
||||
const collected = await collectGridSamples(driver, options, grid, plan);
|
||||
|
||||
let motionIssues = plan.preflightIssues;
|
||||
if (motion.kind === "valid" && motionIssues.length === 0 && collected.motionFrames.length > 0) {
|
||||
const evaluated = evaluateMotion(
|
||||
collected.motionFrames,
|
||||
motion.spec.assertions,
|
||||
await driver.getCanvas(),
|
||||
);
|
||||
motionIssues = await driver.anchorMotionIssues(evaluated);
|
||||
}
|
||||
const contrast = buildContrastResults(collected.contrastEntries);
|
||||
return {
|
||||
duration: grid.duration,
|
||||
layoutSamples: grid.layoutSamples,
|
||||
transitionSamples: grid.transitionSamples,
|
||||
transitionSamplesDropped: grid.transitionSamplesDropped,
|
||||
runtimeFindings: [],
|
||||
layoutIssues: collected.layoutIssues,
|
||||
motionIssues,
|
||||
motionSampleCount: collected.motionFrames.length,
|
||||
contrastSamples: grid.contrastSamples,
|
||||
contrastFindings: contrast.findings,
|
||||
contrastChecked: collected.contrastEntries.length,
|
||||
contrastPassed: contrast.passed,
|
||||
screenshots: collected.screenshots,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCheckPipeline(
|
||||
project: ProjectDir,
|
||||
options: CheckOptions,
|
||||
dependencies: CheckDependencies = DEFAULT_DEPENDENCIES,
|
||||
): Promise<CheckReport> {
|
||||
let lintResult: ProjectLintResult;
|
||||
try {
|
||||
lintResult = await dependencies.lintProject(project.dir);
|
||||
} catch (error) {
|
||||
return failureReport(options, runtimeFailure(error));
|
||||
}
|
||||
|
||||
const lint = buildLintSection(lintResult);
|
||||
if (shouldBlockRender(true, false, lintResult.totalErrors, lintResult.totalWarnings)) {
|
||||
return buildReport(options, lint, emptyBrowserResult(), { kind: "none" }, [], []);
|
||||
}
|
||||
|
||||
const motion = dependencies.resolveMotionSpec(project.dir);
|
||||
if (motion.kind === "invalid") {
|
||||
const finding = findingAtRoot(
|
||||
"motion_spec_invalid",
|
||||
"error",
|
||||
motion.message,
|
||||
relative(project.dir, motion.path) || "index.motion.json",
|
||||
);
|
||||
return buildReport(options, lint, emptyBrowserResult(), motion, [finding], []);
|
||||
}
|
||||
|
||||
let browser: CheckBrowserResult;
|
||||
try {
|
||||
browser = await dependencies.runBrowserCheck(project, options, motion);
|
||||
} catch (error) {
|
||||
browser = emptyBrowserResult();
|
||||
browser.runtimeFindings.push(runtimeFailure(error));
|
||||
}
|
||||
|
||||
const snapshotFiles: string[] = [];
|
||||
if (options.snapshots) {
|
||||
for (let index = 0; index < browser.screenshots.length; index += 1) {
|
||||
const shot = browser.screenshots[index];
|
||||
if (!shot) continue;
|
||||
try {
|
||||
snapshotFiles.push(
|
||||
await dependencies.writeSnapshot(project.dir, index, shot.time, shot.pngBase64),
|
||||
);
|
||||
} catch (error) {
|
||||
browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildReport(options, lint, browser, motion, [], snapshotFiles);
|
||||
}
|
||||
|
||||
export function checkExitCode(report: CheckReport): 0 | 1 {
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
function buildContrastResults(entries: ContrastAuditEntry[]): {
|
||||
findings: CheckContrastFinding[];
|
||||
passed: number;
|
||||
} {
|
||||
const findings: CheckContrastFinding[] = [];
|
||||
let passed = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.wcagAA) {
|
||||
passed += 1;
|
||||
continue;
|
||||
}
|
||||
const requiredRatio = requiredContrastRatio(entry.large);
|
||||
findings.push({
|
||||
code: "contrast_aa_failure",
|
||||
severity: "error",
|
||||
message: `Contrast is ${entry.ratio}:1; WCAG AA requires ${requiredRatio}:1.`,
|
||||
text: entry.text,
|
||||
fg: entry.fg,
|
||||
bg: entry.bg,
|
||||
ratio: entry.ratio,
|
||||
requiredRatio,
|
||||
suggestedColor: suggestedColor(entry.fg, entry.bg, requiredRatio),
|
||||
large: entry.large,
|
||||
selector: entry.selector,
|
||||
dataAttributes: entry.dataAttributes,
|
||||
sourceFile: entry.sourceFile,
|
||||
bbox: entry.bbox,
|
||||
time: entry.time,
|
||||
});
|
||||
}
|
||||
return { findings, passed };
|
||||
}
|
||||
|
||||
function suggestedColor(fg: string, bg: string, requiredRatio: number): string {
|
||||
const foreground = parseColorRGBA(fg);
|
||||
const background = parseColorRGBA(bg);
|
||||
if (!foreground || !background) return fg;
|
||||
const fgRgb: Rgb = [foreground[0], foreground[1], foreground[2]];
|
||||
const bgRgb: Rgb = [background[0], background[1], background[2]];
|
||||
const suggested = suggestCompliantForegroundColor(fgRgb, bgRgb, requiredRatio);
|
||||
return `rgb(${suggested[0]},${suggested[1]},${suggested[2]})`;
|
||||
}
|
||||
|
||||
function buildLintSection(result: ProjectLintResult): CheckReport["lint"] {
|
||||
const findings = result.results.flatMap(({ file, result: fileResult }) =>
|
||||
fileResult.findings.map((finding) => ({
|
||||
code: finding.code,
|
||||
severity: finding.severity,
|
||||
message: finding.message,
|
||||
selector:
|
||||
finding.selector ?? (finding.elementId ? `#${finding.elementId}` : "[data-composition-id]"),
|
||||
dataAttributes: {},
|
||||
sourceFile: finding.file ?? file,
|
||||
bbox: ZERO_BBOX,
|
||||
time: 0,
|
||||
fixHint: finding.fixHint,
|
||||
})),
|
||||
);
|
||||
return { ...section(findings), filesScanned: result.results.length };
|
||||
}
|
||||
|
||||
function buildReport(
|
||||
options: CheckOptions,
|
||||
lint: CheckReport["lint"],
|
||||
browser: CheckBrowserResult,
|
||||
motion: MotionSpecResolution,
|
||||
extraMotionFindings: CheckFinding[],
|
||||
snapshotFiles: string[],
|
||||
): CheckReport {
|
||||
const layout = shapeLayoutSection(browser.layoutIssues, browser, options);
|
||||
const shapedMotion = shapeLayoutFindings(browser.motionIssues, options);
|
||||
const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings];
|
||||
const runtime = section(browser.runtimeFindings);
|
||||
const motionSection = section(motionFindings);
|
||||
const contrastSection = section(browser.contrastFindings);
|
||||
const warningCount =
|
||||
lint.warningCount +
|
||||
runtime.warningCount +
|
||||
layout.warningCount +
|
||||
motionSection.warningCount +
|
||||
contrastSection.warningCount;
|
||||
const errorCount =
|
||||
lint.errorCount +
|
||||
runtime.errorCount +
|
||||
layout.errorCount +
|
||||
motionSection.errorCount +
|
||||
contrastSection.errorCount;
|
||||
return {
|
||||
ok: errorCount === 0 && (!options.strict || warningCount === 0),
|
||||
strict: options.strict,
|
||||
lint,
|
||||
runtime,
|
||||
layout,
|
||||
motion: {
|
||||
...motionSection,
|
||||
enabled: motion.kind !== "none",
|
||||
specPath: motion.kind === "none" ? undefined : motion.path,
|
||||
samples: browser.motionSampleCount,
|
||||
},
|
||||
contrast: {
|
||||
...contrastSection,
|
||||
enabled: options.contrast,
|
||||
samples: browser.contrastSamples,
|
||||
checked: browser.contrastChecked,
|
||||
passed: browser.contrastPassed,
|
||||
},
|
||||
snapshots: {
|
||||
enabled: options.snapshots,
|
||||
files: snapshotFiles,
|
||||
times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shapeLayoutSection(
|
||||
issues: AnchoredLayoutIssue[],
|
||||
browser: CheckBrowserResult,
|
||||
options: CheckOptions,
|
||||
): CheckReport["layout"] {
|
||||
const shaped = shapeLayoutFindings(issues, options);
|
||||
return {
|
||||
...section(shaped.findings),
|
||||
duration: browser.duration,
|
||||
samples: browser.layoutSamples,
|
||||
transitionSamples: browser.transitionSamples,
|
||||
transitionSamplesDropped: browser.transitionSamplesDropped,
|
||||
tolerance: options.tolerance,
|
||||
totalIssueCount: shaped.totalIssueCount,
|
||||
truncated: shaped.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function shapeLayoutFindings(
|
||||
issues: AnchoredLayoutIssue[],
|
||||
options: CheckOptions,
|
||||
): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } {
|
||||
const deduped = dedupeLayoutIssues(issues);
|
||||
const all = options.collapseStatic ? collapseStaticLayoutIssues(deduped) : deduped;
|
||||
const limited = limitLayoutIssues(all, options.maxIssues);
|
||||
return {
|
||||
findings: limited.issues.map(ensureAnchoredLayoutIssue),
|
||||
totalIssueCount: limited.totalIssueCount,
|
||||
truncated: limited.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureAnchoredLayoutIssue(issue: LayoutIssue): AnchoredLayoutIssue {
|
||||
const sourceFile = Reflect.get(issue, "sourceFile");
|
||||
const dataAttributes = Reflect.get(issue, "dataAttributes");
|
||||
const bbox = Reflect.get(issue, "bbox");
|
||||
if (typeof sourceFile === "string" && isStringRecord(dataAttributes) && isBbox(bbox)) {
|
||||
return { ...issue, sourceFile, dataAttributes, bbox };
|
||||
}
|
||||
return {
|
||||
...issue,
|
||||
sourceFile: "index.html",
|
||||
dataAttributes: {},
|
||||
bbox: rectToBbox(issue.rect),
|
||||
};
|
||||
}
|
||||
|
||||
function section<T extends CheckFinding>(findings: T[]): CheckSection<T> {
|
||||
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
||||
const warningCount = findings.filter((finding) => finding.severity === "warning").length;
|
||||
const infoCount = findings.filter((finding) => finding.severity === "info").length;
|
||||
return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings };
|
||||
}
|
||||
|
||||
function emptyBrowserResult(): CheckBrowserResult {
|
||||
return {
|
||||
duration: 0,
|
||||
layoutSamples: [],
|
||||
transitionSamples: [],
|
||||
transitionSamplesDropped: 0,
|
||||
runtimeFindings: [],
|
||||
layoutIssues: [],
|
||||
motionIssues: [],
|
||||
motionSampleCount: 0,
|
||||
contrastSamples: [],
|
||||
contrastFindings: [],
|
||||
contrastChecked: 0,
|
||||
contrastPassed: 0,
|
||||
screenshots: [],
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeFailure(error: unknown, code = "check_runtime_failure"): CheckFinding {
|
||||
return findingAtRoot(code, "error", normalizeErrorMessage(error), "index.html");
|
||||
}
|
||||
|
||||
function findingAtRoot(
|
||||
code: string,
|
||||
severity: CheckSeverity,
|
||||
message: string,
|
||||
sourceFile: string,
|
||||
): CheckFinding {
|
||||
return {
|
||||
code,
|
||||
severity,
|
||||
message,
|
||||
selector: "[data-composition-id]",
|
||||
dataAttributes: {},
|
||||
sourceFile,
|
||||
bbox: ZERO_BBOX,
|
||||
time: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function failureReport(options: CheckOptions, finding: CheckFinding): CheckReport {
|
||||
const lint = { ...section([]), filesScanned: 0 };
|
||||
const browser = emptyBrowserResult();
|
||||
browser.runtimeFindings.push(finding);
|
||||
return buildReport(options, lint, browser, { kind: "none" }, [], []);
|
||||
}
|
||||
|
||||
function rectToBbox(rect: LayoutRect): CheckBbox {
|
||||
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function isBbox(value: unknown): value is CheckBbox {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return ["x", "y", "width", "height"].every((key) => typeof Reflect.get(value, key) === "number");
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
return Object.keys(value).every((key) => typeof Reflect.get(value, key) === "string");
|
||||
}
|
||||
|
||||
function resolveMotionSpec(projectDir: string): MotionSpecResolution {
|
||||
const path = findMotionSpec(projectDir);
|
||||
if (!path) return { kind: "none" };
|
||||
const result = readMotionSpec(path);
|
||||
return result.ok
|
||||
? { kind: "valid", path, spec: result.spec }
|
||||
: {
|
||||
kind: "invalid",
|
||||
path,
|
||||
message: `Invalid motion spec ${path}: ${result.errors.join("; ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBrowserCheck(
|
||||
project: ProjectDir,
|
||||
options: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
): Promise<CheckBrowserResult> {
|
||||
const module = await import("./checkBrowser.js");
|
||||
// runAuditGrid is handed over as a callback so checkBrowser never imports
|
||||
// this module back (no import cycle).
|
||||
return module.runBrowserCheck(project, options, motion, runAuditGrid);
|
||||
}
|
||||
|
||||
async function writeSnapshot(
|
||||
projectDir: string,
|
||||
index: number,
|
||||
time: number,
|
||||
pngBase64: string,
|
||||
): Promise<string> {
|
||||
const snapshotDir = join(projectDir, "snapshots");
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
const filename = `frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`;
|
||||
const path = join(snapshotDir, filename);
|
||||
writeFileSync(path, Buffer.from(pngBase64, "base64"));
|
||||
return join("snapshots", filename);
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: CheckDependencies = {
|
||||
lintProject,
|
||||
resolveMotionSpec,
|
||||
runBrowserCheck,
|
||||
writeSnapshot,
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ProjectLintResult } from "./lintProject.js";
|
||||
import type { LayoutIssue } from "./layoutAudit.js";
|
||||
import type { Canvas, MotionFrame } from "./motionAudit.js";
|
||||
import type { MotionSpec } from "./motionSpec.js";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
|
||||
export interface CheckOptions {
|
||||
samples: number;
|
||||
at?: number[];
|
||||
atTransitions: boolean;
|
||||
maxTransitionSamples?: number;
|
||||
maxIssues: number;
|
||||
collapseStatic: boolean;
|
||||
tolerance: number;
|
||||
timeout: number;
|
||||
contrast: boolean;
|
||||
strict: boolean;
|
||||
snapshots: boolean;
|
||||
}
|
||||
|
||||
export type CheckSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface CheckBbox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CheckAnchor {
|
||||
selector: string;
|
||||
dataAttributes: Record<string, string>;
|
||||
sourceFile: string;
|
||||
bbox: CheckBbox;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface CheckFinding extends CheckAnchor {
|
||||
code: string;
|
||||
severity: CheckSeverity;
|
||||
message: string;
|
||||
text?: string;
|
||||
fixHint?: string;
|
||||
url?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface AnchoredLayoutIssue extends LayoutIssue, CheckAnchor {}
|
||||
|
||||
export interface ContrastAuditEntry extends CheckAnchor {
|
||||
text: string;
|
||||
ratio: number;
|
||||
wcagAA: boolean;
|
||||
large: boolean;
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
export interface CheckContrastFinding extends CheckFinding {
|
||||
fg: string;
|
||||
bg: string;
|
||||
ratio: number;
|
||||
requiredRatio: number;
|
||||
suggestedColor: string;
|
||||
large: boolean;
|
||||
}
|
||||
|
||||
export interface ContrastCapture {
|
||||
entries: ContrastAuditEntry[];
|
||||
pngBase64: string;
|
||||
}
|
||||
|
||||
export type MotionSpecResolution =
|
||||
| { kind: "none" }
|
||||
| { kind: "valid"; path: string; spec: MotionSpec }
|
||||
| { kind: "invalid"; path: string; message: string };
|
||||
|
||||
export interface CheckAuditDriver {
|
||||
initialize(contrast: boolean): Promise<void>;
|
||||
getDuration(): Promise<number>;
|
||||
getTransitionBoundaries(): Promise<number[]>;
|
||||
getCanvas(): Promise<Canvas>;
|
||||
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
||||
seek(time: number): Promise<void>;
|
||||
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
||||
collectMotionFrame(
|
||||
time: number,
|
||||
selectors: string[],
|
||||
livenessScopes: string[],
|
||||
): Promise<MotionFrame>;
|
||||
anchorMotionIssues(issues: LayoutIssue[]): Promise<AnchoredLayoutIssue[]>;
|
||||
collectContrast(time: number): Promise<ContrastCapture>;
|
||||
}
|
||||
|
||||
export interface CheckScreenshot {
|
||||
time: number;
|
||||
pngBase64: string;
|
||||
}
|
||||
|
||||
export interface CheckBrowserResult {
|
||||
duration: number;
|
||||
layoutSamples: number[];
|
||||
transitionSamples: number[];
|
||||
transitionSamplesDropped: number;
|
||||
runtimeFindings: CheckFinding[];
|
||||
layoutIssues: AnchoredLayoutIssue[];
|
||||
motionIssues: AnchoredLayoutIssue[];
|
||||
motionSampleCount: number;
|
||||
contrastSamples: number[];
|
||||
contrastFindings: CheckContrastFinding[];
|
||||
contrastChecked: number;
|
||||
contrastPassed: number;
|
||||
screenshots: CheckScreenshot[];
|
||||
}
|
||||
|
||||
/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */
|
||||
export type RunAuditGrid = (
|
||||
driver: CheckAuditDriver,
|
||||
options: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
) => Promise<CheckBrowserResult>;
|
||||
|
||||
export interface CheckSection<T extends CheckFinding = CheckFinding> {
|
||||
ok: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
findings: T[];
|
||||
}
|
||||
|
||||
export interface CheckReport {
|
||||
ok: boolean;
|
||||
strict: boolean;
|
||||
lint: CheckSection & { filesScanned: number };
|
||||
runtime: CheckSection;
|
||||
layout: CheckSection<AnchoredLayoutIssue> & {
|
||||
duration: number;
|
||||
samples: number[];
|
||||
transitionSamples: number[];
|
||||
transitionSamplesDropped: number;
|
||||
tolerance: number;
|
||||
totalIssueCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
motion: CheckSection & { enabled: boolean; specPath?: string; samples: number };
|
||||
contrast: CheckSection<CheckContrastFinding> & {
|
||||
enabled: boolean;
|
||||
samples: number[];
|
||||
checked: number;
|
||||
passed: number;
|
||||
};
|
||||
snapshots: { enabled: boolean; files: string[]; times: number[] };
|
||||
}
|
||||
|
||||
export interface CheckDependencies {
|
||||
lintProject(projectDir: string): Promise<ProjectLintResult>;
|
||||
resolveMotionSpec(projectDir: string): MotionSpecResolution;
|
||||
runBrowserCheck(
|
||||
project: ProjectDir,
|
||||
options: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
): Promise<CheckBrowserResult>;
|
||||
writeSnapshot(
|
||||
projectDir: string,
|
||||
index: number,
|
||||
time: number,
|
||||
pngBase64: string,
|
||||
): Promise<string>;
|
||||
}
|
||||
Reference in New Issue
Block a user