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 { 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 { // 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 } { const runBrowserCheck = vi.fn( async ( _project: ProjectDir, checkOptions: CheckOptions, motion: MotionSpecResolution, ): Promise => { 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 = {}, dependencyOverrides: Parameters[1] = {}, ): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType }> { 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( '
', ); 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(findings: T[] = []) { return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings }; } function reportWithFindings(overrides: Partial = {}): 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-.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\)/); }); });