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

1332 lines
41 KiB
TypeScript

import { runCommand } from "citty";
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
const trackCheckReport = vi.fn();
vi.mock("../telemetry/events.js", () => ({
trackCheckReport: (...args: unknown[]) => trackCheckReport(...args),
trackCommandFailure: vi.fn(),
}));
import { contrastRatio, parseColorRGBA } from "./contrast-bg.js";
import { createCheckCommand } from "./check.js";
import {
DEFAULT_CHECK_OPTIONS,
checkExitCode,
findingCropFilename,
runAuditGrid,
runCheckPipeline,
selectContrastTimes,
selectFindingCropRequests,
type AnchoredLayoutIssue,
type CheckAnchor,
type CheckAuditDriver,
type CheckBrowserResult,
type CheckDependencies,
type CheckFinding,
type CheckFindingCropRequest,
type CheckOptions,
type CheckReport,
type ContrastAuditEntry,
type MotionSpecResolution,
} from "../utils/checkPipeline.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
import type {
LayoutIssue,
LayoutIssueCode,
LayoutOverflow,
LayoutRect,
} 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");
const ORIGINAL_EXIT_CODE = process.exitCode;
afterEach(() => {
process.exitCode = ORIGINAL_EXIT_CODE;
trackCheckReport.mockClear();
vi.restoreAllMocks();
});
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",
overrides: { time?: number; code?: AnchoredLayoutIssue["code"] } = {},
): AnchoredLayoutIssue {
const time = overrides.time ?? 0.5;
return {
...anchor("#hero", time),
code: overrides.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 {
// A distinct string per call so the frozen-sweep guard (#U10) never fires
// by accident in unrelated scenarios — tests that want it force a constant
// via `collectLayoutGeometry: vi.fn(async () => "same")`.
let geometryCallCount = 0;
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) => []),
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
collectGeometryCandidates: vi.fn(async () => []),
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,
};
}
interface GeometryFixture {
kind: "text" | "media";
tag: string;
text: string;
selector: string;
rect: LayoutRect;
elementRect?: LayoutRect;
time: number;
overflow?: LayoutOverflow;
}
function geometryCandidate(fixture: GeometryFixture) {
return {
...anchor(fixture.selector, fixture.time),
kind: fixture.kind,
tag: fixture.tag,
text: fixture.text,
rect: fixture.rect,
elementRect: fixture.elementRect ?? fixture.rect,
bbox: {
x: fixture.rect.left,
y: fixture.rect.top,
width: fixture.rect.width,
height: fixture.rect.height,
},
overflow: fixture.overflow,
};
}
function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect {
return { left, top, right: left + width, bottom: top + height, width, height };
}
function checkBrowserSource(): string {
return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8");
}
async function gateCandidates(
time: number,
request: { text: boolean; media: boolean; tolerance: number },
) {
const candidates = [];
if (request.text) {
candidates.push(
geometryCandidate({
kind: "text",
tag: "h2",
text: "Repeated heading",
selector: time === 2 ? "#first-heading" : "#later-heading",
rect: fixtureRect(800, 880, 320, 60),
time,
}),
);
}
if (request.media) {
candidates.push(
geometryCandidate({
kind: "media",
tag: "video",
text: "video",
selector: "#midpoint-video",
rect: fixtureRect(-140, 100, 100, 100),
overflow: { left: 140 },
time,
}),
);
}
return candidates;
}
function noMotion(): MotionSpecResolution {
return { kind: "none" };
}
function heroMotionFrame(time: number, visibleAt: (time: number) => boolean) {
return {
time,
data: {
"#hero": {
rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 },
opacity: visibleAt(time) ? 1 : 0,
visible: visibleAt(time),
},
},
liveness: {},
};
}
function dependencies(
driver: CheckAuditDriver,
options: {
lint?: ProjectLintResult;
motion?: MotionSpecResolution;
runtime?: CheckFinding[];
writeSnapshot?: CheckDependencies["writeSnapshot"];
captureFindingCrops?: CheckDependencies["captureFindingCrops"];
} = {},
): { 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`,
),
),
captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []),
};
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]);
});
});
it("parses the caption-zone grammar and enables the frame gate", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline,
withMeta: (value) => value,
});
await runCommand(command, {
rawArgs: [
"--json",
"--caption-zone",
"x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1",
"--frame-check",
],
});
expect(runPipeline).toHaveBeenCalledWith(
PROJECT,
expect.objectContaining({
captionZone: {
x0: 0,
y0: 0.82,
x1: 1,
y1: 1,
severity: "error",
seek: [0.25, 1],
},
frameCheck: {},
}),
);
});
it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async () => report);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline,
withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
});
await runCommand(command, {
rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"],
});
expect(runPipeline).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(log).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
ok: false,
error: expect.stringContaining("Invalid --caption-zone"),
_meta: { version: "test" },
});
});
it("flags only text whose center is inside the caption band at the default end seek", async () => {
const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({
kind: "text",
tag: "div",
text: "Centered title",
selector: "#centered",
rect: fixtureRect(860, 870, 200, 60),
time,
}),
geometryCandidate({
kind: "text",
tag: "div",
text: "Overlap only",
selector: "#overlap-only",
rect: fixtureRect(860, 830, 200, 60),
time,
}),
]);
const { report } = await runScenario(
fakeDriver({
getDuration: vi.fn(async () => 10),
collectGeometryCandidates,
}),
{
samples: 1,
contrast: false,
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 },
},
);
expect(collectGeometryCandidates).toHaveBeenCalledTimes(1);
expect(collectGeometryCandidates).toHaveBeenCalledWith(10, {
text: true,
media: false,
tolerance: 2,
});
expect(report.layout.samples).toEqual([5, 10]);
expect(report.layout.findings).toEqual([
expect.objectContaining({
code: "caption_zone_collision",
severity: "warning",
selector: "#centered",
text: "Centered title",
time: 10,
}),
]);
expect(report.ok).toBe(true);
});
it("filters caption candidates by the element box while centering the text rect", async () => {
const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({
kind: "text",
tag: "div",
text: "Full-frame wrapper copy",
selector: "#full-frame-wrapper",
rect: fixtureRect(860, 870, 200, 60),
elementRect: fixtureRect(0, 0, 1920, 1080),
time,
}),
geometryCandidate({
kind: "text",
tag: "span",
text: "Tiny wrapper copy",
selector: "#tiny-wrapper",
rect: fixtureRect(860, 870, 200, 60),
elementRect: fixtureRect(860, 870, 3, 3),
time,
}),
]);
const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), {
contrast: false,
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 },
});
expect(report.layout.findings).toEqual([]);
});
it("checks media overflow at the default midpoint and applies warning severity", async () => {
const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({
kind: "media",
tag: "img",
text: "img",
selector: "#hero-image",
rect: fixtureRect(1840, 100, 220, 200),
overflow: { right: 140 },
time,
}),
]);
const { report } = await runScenario(
fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }),
{ samples: 1, contrast: false, frameCheck: {} },
);
expect(collectGeometryCandidates).toHaveBeenCalledWith(5, {
text: false,
media: true,
tolerance: 2,
});
expect(report.layout.findings[0]).toMatchObject({
code: "frame_out_of_frame",
severity: "warning",
selector: "#hero-image",
overflow: { right: 140 },
time: 5,
});
});
it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => {
const collectGeometryCandidates = vi.fn(gateCandidates);
const { report } = await runScenario(
fakeDriver({
getDuration: vi.fn(async () => 8),
collectGeometryCandidates,
}),
{
samples: 1,
contrast: false,
captionZone: {
x0: 0,
y0: 0.8,
x1: 1,
y1: 0.9,
severity: "error",
seek: [0.25, 0.75],
},
frameCheck: { severity: "error" },
},
);
expect(report.layout.samples).toEqual([2, 4, 6]);
expect(collectGeometryCandidates.mock.calls).toEqual([
[2, { text: true, media: false, tolerance: 2 }],
[4, { text: false, media: true, tolerance: 2 }],
[6, { text: true, media: false, tolerance: 2 }],
]);
expect(report.layout.findings).toEqual([
expect.objectContaining({
code: "caption_zone_collision",
severity: "error",
selector: "#first-heading",
time: 2,
}),
expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }),
]);
expect(report.ok).toBe(false);
});
it("does not collect or emit opt-in geometry findings when both flags are off", async () => {
const collectGeometryCandidates = vi.fn(async () => [
geometryCandidate({
kind: "media",
tag: "video",
text: "video",
selector: "#video",
rect: fixtureRect(1900, 0, 200, 200),
overflow: { right: 180 },
time: 4.5,
}),
]);
const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), {
contrast: false,
});
expect(collectGeometryCandidates).not.toHaveBeenCalled();
expect(JSON.stringify(report)).not.toContain("caption_zone_collision");
expect(JSON.stringify(report)).not.toContain("frame_out_of_frame");
});
it("computes caption bands from a portrait composition viewport", async () => {
const viewport = resolveCompositionViewportFromHtml(
'<div data-composition-id="portrait" data-width="1080" data-height="1920"></div>',
);
const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({
kind: "text",
tag: "p",
text: "Portrait caption collision",
selector: "#portrait-copy",
rect: fixtureRect(480, 1570, 120, 60),
time,
}),
]);
const getCanvas = vi.fn(async () => viewport);
const { report } = await runScenario(
fakeDriver({
getDuration: vi.fn(async () => 4),
getCanvas,
collectGeometryCandidates,
}),
{
samples: 1,
contrast: false,
captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 },
},
);
expect(viewport).toEqual({ width: 1080, height: 1920 });
expect(getCanvas).toHaveBeenCalled();
expect(report.layout.findings).toEqual([
expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }),
]);
});
it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => {
const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({
kind: "media",
tag: "canvas",
text: "canvas",
selector: "#under-floor",
rect: fixtureRect(3980, 100, 199, 100),
overflow: { right: 179 },
time,
}),
geometryCandidate({
kind: "media",
tag: "canvas",
text: "canvas",
selector: "#over-floor",
rect: fixtureRect(3980, 300, 201, 100),
overflow: { right: 181 },
time,
}),
]);
const { report } = await runScenario(
fakeDriver({
getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })),
collectGeometryCandidates,
}),
{
contrast: false,
frameCheck: {},
},
);
expect(report.layout.findings).toEqual([
expect.objectContaining({
code: "frame_out_of_frame",
selector: "#over-floor",
overflow: { right: 181 },
}),
]);
});
it("keeps frame findings at distinct rounded positions across requested seeks", async () => {
const collectGeometryCandidates = vi.fn(async (time: number) => [
geometryCandidate({
kind: "media",
tag: "img",
text: "img",
selector: "#moving-image",
rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100),
overflow: { right: 130 },
time,
}),
]);
const { report } = await runScenario(
fakeDriver({
getDuration: vi.fn(async () => 8),
collectGeometryCandidates,
}),
{
samples: 1,
contrast: false,
frameCheck: { seek: [0.25, 0.75] },
},
);
expect(report.layout.findings).toEqual([
expect.objectContaining({ code: "frame_out_of_frame", time: 2 }),
expect.objectContaining({ code: "frame_out_of_frame", time: 6 }),
]);
});
it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => {
const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 }));
const { report } = await runScenario(
fakeDriver({
getDuration: vi.fn(async () => 10),
collectContrast,
}),
{
samples: 1,
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
},
);
expect(report.layout.samples).toEqual([5, 10]);
expect(report.contrast.samples).toEqual([5]);
expect(collectContrast).toHaveBeenCalledTimes(1);
expect(collectContrast).toHaveBeenCalledWith(5);
});
function layoutFindingOf(
code: LayoutIssueCode,
severity: "error" | "warning" | "info",
bbox: { x: number; y: number; width: number; height: number },
time = 1,
): AnchoredLayoutIssue {
return {
code,
severity,
message: code,
...anchor("#el", time),
bbox,
rect: {
left: bbox.x,
top: bbox.y,
right: bbox.x + bbox.width,
bottom: bbox.y + bbox.height,
...bbox,
},
};
}
function checkFindingOf(
code: string,
severity: "error" | "warning" | "info",
bbox: { x: number; y: number; width: number; height: number },
time = 1,
): CheckFinding {
return { code, severity, message: code, ...anchor("#el", time), bbox };
}
function emptySection<T extends CheckFinding>(findings: T[] = []) {
return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings };
}
function reportWithFindings(overrides: Partial<CheckReport> = {}): CheckReport {
return {
ok: true,
strict: false,
lint: { ...emptySection(), filesScanned: 0 },
runtime: emptySection(),
layout: {
...emptySection(),
duration: 10,
samples: [],
transitionSamples: [],
transitionSamplesDropped: 0,
tolerance: 2,
totalIssueCount: 0,
truncated: false,
},
motion: { ...emptySection(), enabled: false, samples: 0 },
contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 },
snapshots: { enabled: false, files: [], times: [], findingFiles: [] },
...overrides,
};
}
describe("selectFindingCropRequests", () => {
const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 };
const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 };
it("filenames a request finding-NN-<code>.png with the finding's time and bbox", () => {
const report = reportWithFindings({
layout: {
...reportWithFindings().layout,
findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)],
},
});
expect(selectFindingCropRequests(report)).toEqual([
{ filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX },
]);
});
it("skips warnings/info and findings without a real bbox", () => {
const report = reportWithFindings({
layout: {
...reportWithFindings().layout,
findings: [
layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX),
layoutFindingOf("clipped_text", "error", ZERO_BBOX),
],
},
runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) },
});
expect(selectFindingCropRequests(report)).toEqual([]);
});
it("caps at 12 requests across sections", () => {
const findings = Array.from({ length: 15 }, (_, index) =>
checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index),
);
const report = reportWithFindings({
runtime: { ...emptySection(findings) },
});
const requests = selectFindingCropRequests(report);
expect(requests).toHaveLength(12);
expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0"));
expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11"));
});
it("sanitizes unusual characters out of the code when building a filename", () => {
expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png");
});
});
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 (time: number) => [layoutIssue("error", { time })]),
}),
);
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 (time: number) => [layoutIssue("error", { time })]),
}),
{},
{ 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) => heroMotionFrame(time, (t) => t >= 0.5)),
});
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("captures finding crops for error findings with bboxes only when --snapshots is set", async () => {
const capture = vi.fn(
async (
_project: ProjectDir,
_options: CheckOptions,
_requests: CheckFindingCropRequest[],
) => ["snapshots/finding-00-clipped_text.png"],
);
const { report } = await runScenario(
fakeDriver({
collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
}),
{ snapshots: true },
{ captureFindingCrops: capture },
);
expect(capture).toHaveBeenCalledTimes(1);
expect(capture.mock.calls[0]?.[2]).toEqual([
{
filename: "finding-00-clipped_text.png",
time: 0.5,
bbox: { x: 10, y: 20, width: 300, height: 80 },
},
]);
expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]);
const withoutSnapshots = vi.fn(async () => ["unused.png"]);
await runScenario(
fakeDriver({
collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
}),
{ snapshots: false },
{ captureFindingCrops: withoutSnapshots },
);
expect(withoutSnapshots).not.toHaveBeenCalled();
const noErrors = vi.fn(async () => ["unused.png"]);
await runScenario(
fakeDriver({
collectLayout: vi.fn(
async (time: number) =>
// container_overflow, not content_overlap: this fixture wants a plain
// warning-severity finding held across the whole run, unaffected by
// content_overlap's #U10 held-duration re-promotion to error.
[layoutIssue("warning", { time, code: "container_overflow" })],
),
}),
{ snapshots: true },
{ captureFindingCrops: noErrors },
);
expect(noErrors).not.toHaveBeenCalled();
});
it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
const warningDriver = () =>
fakeDriver({
collectLayout: vi.fn(
async (time: number) =>
// container_overflow, not content_overlap: this fixture wants a plain
// warning-severity finding held across the whole run, unaffected by
// content_overlap's #U10 held-duration re-promotion to error.
[layoutIssue("warning", { time, code: "container_overflow" })],
),
});
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",
);
await expect(
runAuditGrid(
driver,
{
...DEFAULT_CHECK_OPTIONS,
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
},
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("frozen-sweep guard (#U10)", () => {
it("fails with sweep_static when a 6s composition's geometry never changes across samples", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
collectLayoutGeometry: vi.fn(async () => "frozen"),
});
const { report } = await runScenario(driver);
expect(report.ok).toBe(false);
expect(
report.layout.findings.some(
(finding) =>
finding.code === "sweep_static" &&
finding.severity === "error" &&
finding.message.includes("did not advance"),
),
).toBe(true);
});
it("does not flag a 1.5s static title card — too short for the guard to apply", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 1.5),
collectLayoutGeometry: vi.fn(async () => "frozen"),
});
const { report } = await runScenario(driver);
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
});
it("does not double-report when a motion_frozen finding already covers the same symptom", async () => {
const motion: MotionSpecResolution = {
kind: "valid",
path: "/project/index.motion.json",
spec: { assertions: [{ kind: "keepsMoving" }] },
};
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
collectLayoutGeometry: vi.fn(async () => "frozen"),
collectMotionFrame: vi.fn(async (time: number) => ({
time,
data: {},
liveness: { "*": "unchanging" },
})),
});
const { report } = await runScenario(driver, {}, { motion });
expect(report.motion.findings.some((finding) => finding.code === "motion_frozen")).toBe(true);
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
});
});
});
describe("frame-check flag grammar", () => {
it("keeps bare --frame-check on defaults and parses the value form", async () => {
const { parseFrameCheck } = await import("./check.js");
expect(parseFrameCheck(undefined)).toBeUndefined();
expect(parseFrameCheck(true)).toEqual({});
expect(parseFrameCheck("")).toEqual({});
expect(parseFrameCheck("severity=error;seek=.25,.75;tol=4")).toEqual({
severity: "error",
seek: [0.25, 0.75],
tol: 4,
});
expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check");
});
});
describe("contrast persistence", () => {
it("demotes a single-sample contrast failure to warning but gates held failures", async () => {
const driver = fakeDriver({
collectContrast: vi.fn(async (time: number) => ({
entries: [
// #hero fails at every sample: held, stays an error.
contrastEntry({ time, selector: "#hero" }),
// #entrance fails only at the first sample (mid-entrance): demoted.
...(time < 1
? [contrastEntry({ time, selector: "#entrance", text: "Fading in" })]
: [
contrastEntry({
time,
selector: "#entrance",
text: "Fading in",
ratio: 8,
wcagAA: true,
}),
]),
],
pngBase64: PNG_BASE64,
})),
});
const { report } = await runScenario(driver);
const bySelector = new Map(
report.contrast.findings.map((finding) => [finding.selector, finding.severity]),
);
expect(bySelector.get("#hero")).toBe("error");
expect(bySelector.get("#entrance")).toBe("warning");
expect(checkExitCode(report)).toBe(1);
});
it("keeps full severity when only one sample time exists", async () => {
const driver = fakeDriver({
collectContrast: vi.fn(async (time: number) => ({
entries: [contrastEntry({ time })],
pngBase64: PNG_BASE64,
})),
});
const { report } = await runScenario(driver, { samples: 1, at: [2] });
expect(report.contrast.findings[0]?.severity).toBe("error");
});
});
describe("check report telemetry", () => {
it("reports one clean run with every gate and sampled-point count", async () => {
const motion: MotionSpecResolution = {
kind: "valid",
path: "/project/index.motion.json",
spec: { duration: 1, assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 1 }] },
};
const driver = fakeDriver({
getDuration: vi.fn(async () => 1),
collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, () => true)),
collectContrast: vi.fn(async (time: number) => ({
entries: [contrastEntry({ time, ratio: 7, wcagAA: true })],
pngBase64: PNG_BASE64,
})),
});
const { report } = await runScenario(
driver,
{
samples: 1,
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
frameCheck: {},
snapshots: true,
},
{ motion },
);
expect(trackCheckReport).toHaveBeenCalledTimes(1);
expect(trackCheckReport).toHaveBeenCalledWith(
expect.objectContaining({
contrastGate: true,
motionGate: true,
captionZoneGate: true,
frameCheckGate: true,
snapshotsGate: true,
gridPoints: 2,
contrastPoints: 1,
ok: true,
exitCode: 0,
}),
);
expect(report.ok).toBe(true);
});
it("reports one failing contrast run with its section error count", async () => {
const collectContrast = vi.fn(async (time: number) => ({
entries: time === 0.5 ? [contrastEntry()] : [],
pngBase64: PNG_BASE64,
}));
const { report } = await runScenario(fakeDriver({ collectContrast }));
expect(trackCheckReport).toHaveBeenCalledTimes(1);
expect(trackCheckReport).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
exitCode: 1,
contrastErrors: report.contrast.errorCount,
}),
);
expect(report.contrast.errorCount).toBe(1);
});
it("reports zero browser samples and timings after a lint short circuit", async () => {
const lint = lintWith(
"error",
"root_missing_composition_id",
"Root element needs data-composition-id.",
);
const { report, browser } = await runScenario(fakeDriver(), {}, { lint });
expect(browser).not.toHaveBeenCalled();
expect(trackCheckReport).toHaveBeenCalledTimes(1);
expect(trackCheckReport).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
exitCode: 1,
gridPoints: 0,
contrastPoints: 0,
launchSettleMs: 0,
seekLoopMs: 0,
contrastMs: 0,
}),
);
expect(report.ok).toBe(false);
});
it("matches report counts for mixed findings across classes", async () => {
const lint = lintWith("warning", "lint_warning", "Lint warning.");
const driver = fakeDriver({
collectLayout: vi.fn(async () => [layoutIssue(), layoutIssue("warning")]),
collectContrast: vi.fn(async () => ({
entries: [contrastEntry()],
pngBase64: PNG_BASE64,
})),
});
const { report } = await runScenario(
driver,
{ samples: 1 },
{ lint, runtime: [runtimeError()] },
);
expect(trackCheckReport).toHaveBeenCalledTimes(1);
expect(trackCheckReport).toHaveBeenCalledWith(
expect.objectContaining({
lintErrors: report.lint.errorCount,
lintWarnings: report.lint.warningCount,
runtimeErrors: report.runtime.errorCount,
runtimeWarnings: report.runtime.warningCount,
layoutErrors: report.layout.errorCount,
layoutWarnings: report.layout.warningCount,
motionErrors: report.motion.errorCount,
motionWarnings: report.motion.warningCount,
contrastErrors: report.contrast.errorCount,
contrastWarnings: report.contrast.warningCount,
}),
);
expect(report.lint.warningCount).toBe(1);
expect(report.runtime.errorCount).toBe(1);
expect(report.layout.errorCount).toBe(1);
expect(report.layout.warningCount).toBe(1);
});
it("measures contrast work inside the overall seek loop", async () => {
vi.spyOn(Date, "now")
.mockReturnValueOnce(100)
.mockReturnValueOnce(105)
.mockReturnValueOnce(110)
.mockReturnValueOnce(120);
const result = await runAuditGrid(
fakeDriver(),
{ ...DEFAULT_CHECK_OPTIONS, samples: 1 },
noMotion(),
);
expect(result.timings).toEqual({ launchSettleMs: 0, seekLoopMs: 20, contrastMs: 5 });
});
});
describe("contrast candidate round-trip", () => {
it("passes the browser script's raw candidates back to finish, never the normalized copies", () => {
const source = checkBrowserSource();
// __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\)/);
});
});