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
1332 lines
41 KiB
TypeScript
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\)/);
|
|
});
|
|
});
|