refactor(cli): single-source-of-truth pass over the check branch

Every duplicated decision gets one owner: rectToBbox lives in checkTypes
(was verbatim in pipeline and browser layers); the audit seek tuning is
one exported AUDIT_SEEK_OPTIONS consumed by check and the deprecated
inspect path; zoom padding/scale defaults export from the capture module
instead of re-literalized in three files; the optional run_id property
is built by one helper across all three telemetry events; check's
--max-transition-samples parsing reuses its own positiveInteger helper;
validate drops a leftover re-export and redundant explicit-default args
go away.

Tests: the contrast candidate round-trip gains a real integration
anchor (the actual browser script eval'd in-page, a wrapper asserting
finish receives the page-script bbox shape) replacing regex-over-source
as the primary guard; the redundant geometry source-golden and a
duplicated deprecation-envelope assertion are dropped.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:52:31 -04:00
parent cf7c1d7609
commit cea3458016
13 changed files with 171 additions and 66 deletions
+117 -1
View File
@@ -16,7 +16,10 @@ vi.mock("@hyperframes/core/compiler", () => ({
bundleToSingleHtml: vi.fn(async () => "<html></html>"),
}));
vi.mock("../capture/captureCompositionFrame.js", () => ({
vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
// Partial mock: constants (AUDIT_SEEK_OPTIONS, DEFAULT_ZOOM_*) stay real so
// they remain single-sourced; only the browser-touching functions are faked.
...(await importOriginal<typeof import("../capture/captureCompositionFrame.js")>()),
openSettledCompositionPage: vi.fn(),
resolveCliChromeGpuMode: vi.fn(() => "hardware"),
seekCompositionTimeline: vi.fn(async () => undefined),
@@ -38,9 +41,14 @@ const PROJECT: ProjectDir = {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.innerHTML = "";
Reflect.deleteProperty(window, "__hyperframesGeometryCandidates");
Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
Reflect.deleteProperty(window, "__contrastAuditPrepare");
Reflect.deleteProperty(window, "__contrastAuditFinish");
Reflect.deleteProperty(window, "__contrastAuditRestores");
Reflect.deleteProperty(window, "__contrastAuditRestoreIfPending");
});
it("carries raw browser geometry through the page driver and pipeline", async () => {
@@ -94,6 +102,114 @@ it("carries raw browser geometry through the page driver and pipeline", async ()
expect(mocks.serverClose).toHaveBeenCalledOnce();
});
it("round-trips the browser script's raw contrast candidates back into finish", async () => {
// The U2 regression class: Node parses prepare's candidates for reporting,
// but must hand the UNTOUCHED objects back to __contrastAuditFinish — the
// page script samples pixels via its own bbox shape (w/h). A normalized
// candidate (width/height) makes every sample rect NaN and the audit
// silently reports zero checked elements as green.
vi.spyOn(Date, "now").mockReturnValue(100);
document.body.innerHTML = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">
<div id="headline">Readable copy</div>
</div>
`;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
const root = document.querySelector("[data-composition-id]");
const headline = document.querySelector("#headline");
if (!root || !headline) throw new Error("Contrast fixture failed to mount");
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40));
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255,255,255)",
fill: "",
backgroundColor: "rgba(0,0,0,0)",
backgroundImage: "none",
fontSize: "32px",
fontWeight: "700",
}) as unknown as CSSStyleDeclaration,
);
// happy-dom can't decode PNGs: stub Image (sync onload) and the canvas 2D
// context the way layout-audit.browser.test.ts's contrast harness does, so
// the REAL __contrastAuditFinish runs its sampling path end to end.
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 640;
naturalHeight = 360;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal("Image", MockImage);
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage() {},
getImageData() {
return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
},
} as unknown as CanvasRenderingContext2D);
const received: Array<Record<string, unknown>> = [];
const page = fakePage();
const injectScript = page.addScriptTag;
page.addScriptTag = vi.fn(async (arg: { content: string }) => {
await injectScript(arg);
const w = window as unknown as {
__contrastAuditFinish?: ((...args: unknown[]) => Promise<unknown>) & { wrapped?: boolean };
};
const finish = w.__contrastAuditFinish;
if (finish && !finish.wrapped) {
const wrapper = Object.assign(
async (...args: unknown[]) => {
const candidates = args[2];
if (Array.isArray(candidates)) {
received.push(...(candidates as Array<Record<string, unknown>>));
}
return finish(...args);
},
{ wrapped: true },
);
w.__contrastAuditFinish = wrapper;
}
});
page.screenshot = vi.fn(async () => "c3R1Yg==");
const browser = Object.assign(Object.create(null), {
close: vi.fn(async () => undefined),
});
vi.mocked(openSettledCompositionPage).mockImplementation(
async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
await options.beforeNavigate?.(page);
return { page, browser, renderReadyTimedOut: false };
},
);
await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true },
{ kind: "none" },
runAuditGrid,
);
expect(received.length).toBeGreaterThan(0);
for (const candidate of received) {
const bbox = candidate.bbox as Record<string, unknown>;
// The page script's own shape (w/h), not Node's envelope shape (width/height):
expect(typeof bbox.w).toBe("number");
expect(typeof bbox.h).toBe("number");
}
});
describe("captureOverviewShot", () => {
it("injects the annotation overlay before the overview shot and removes it right after", async () => {
const calls: string[] = [];
+10 -22
View File
@@ -2,6 +2,9 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "puppeteer-core";
import {
AUDIT_SEEK_OPTIONS,
DEFAULT_ZOOM_PADDING_PX,
DEFAULT_ZOOM_SCALE,
captureRegionCrop,
openSettledCompositionPage,
padCropRegion,
@@ -15,6 +18,7 @@ import { normalizeErrorMessage } from "./errorMessage.js";
import { ambiguousIssue, type MotionFrame } from "./motionAudit.js";
import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js";
import { serveStaticProjectHtml } from "./staticProjectServer.js";
import { rectToBbox } from "./checkTypes.js";
import type {
AnchoredLayoutIssue,
CheckAnchor,
@@ -35,18 +39,6 @@ import type {
} from "./checkTypes.js";
import type { ProjectDir } from "./project.js";
const SEEK_OPTIONS = {
fallbackToBridgeAndTimelines: true,
animationFrameSettle: "double" as const,
waitForFontsMs: 500,
settleMs: 120,
};
// --zoom's default padding/scale, reused here so finding crops carry the same
// bit of surrounding context an agent gets from `snapshot --zoom`.
const FINDING_CROP_PADDING_PX = 24;
const FINDING_CROP_SCALE = 3;
interface RuntimeDraft {
code: string;
severity: CheckSeverity;
@@ -113,7 +105,7 @@ export async function runBrowserCheck(
});
chromeBrowser = session.browser;
const page = session.page;
await waitForPreferredSeekTarget(page, 500);
await waitForPreferredSeekTarget(page);
const rootAnchor = await resolveRootAnchor(page);
const launchSettleMs = Date.now() - launchSettleStart;
@@ -157,18 +149,18 @@ export async function captureFindingCrops(
});
chromeBrowser = session.browser;
const page = session.page;
await waitForPreferredSeekTarget(page, 500);
await waitForPreferredSeekTarget(page);
const snapshotDir = join(project.dir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
for (const request of requests) {
await seekCompositionTimeline(page, request.time, SEEK_OPTIONS);
await seekCompositionTimeline(page, request.time, AUDIT_SEEK_OPTIONS);
const canvas = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const region = padCropRegion(request.bbox, canvas, FINDING_CROP_PADDING_PX);
const buffer = await captureRegionCrop(page, region, FINDING_CROP_SCALE);
const region = padCropRegion(request.bbox, canvas, DEFAULT_ZOOM_PADDING_PX);
const buffer = await captureRegionCrop(page, region, DEFAULT_ZOOM_SCALE);
writeFileSync(join(snapshotDir, request.filename), buffer);
written.push(join("snapshots", request.filename));
}
@@ -253,7 +245,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
seek: async (time) => {
setTime(time);
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS);
},
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
collectLayoutGeometry: () => collectLayoutGeometry(page),
@@ -899,10 +891,6 @@ function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor {
};
}
function rectToBbox(rect: LayoutRect): CheckBbox {
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}
function parseBbox(value: unknown): CheckBbox | null {
if (!isRecord(value)) return null;
const x = numberValue(value, "x");
+1 -4
View File
@@ -28,6 +28,7 @@ import {
suggestCompliantForegroundColor,
type Rgb,
} from "../commands/contrast-bg.js";
import { rectToBbox } from "./checkTypes.js";
import type {
AnchoredLayoutIssue,
CheckAnnotationBox,
@@ -877,10 +878,6 @@ function failureReport(options: CheckOptions, finding: CheckFinding): CheckRepor
return buildReport(options, lint, browser, { kind: "none" }, [], []);
}
function rectToBbox(rect: LayoutRect): CheckBbox {
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}
function isBbox(value: unknown): value is CheckBbox {
if (typeof value !== "object" || value === null) return false;
return ["x", "y", "width", "height"].every((key) => typeof Reflect.get(value, key) === "number");
+9
View File
@@ -235,3 +235,12 @@ export interface CheckDependencies {
requests: CheckFindingCropRequest[],
): Promise<string[]>;
}
export function rectToBbox(rect: {
left: number;
top: number;
width: number;
height: number;
}): CheckBbox {
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}