feat(cli): snapshot --zoom and per-finding crops on check --snapshots

snapshot --zoom <selector|x,y,w,h> + --zoom-scale (default 3) crops via
Puppeteer clip at raised deviceScaleFactor — density changes, layout
never does. Selector resolves per frame with 24px padding; no match is
a loud error, and a frame whose clamped region is a sliver (element
collapsed or animated off-canvas) is skipped with a stderr note rather
than written as a useless few-pixel image.

check --snapshots additionally writes finding-NN-<code>.png crops for
error findings with bboxes (cap 12, deterministic re-seek in a second
session) and draws labeled annotation boxes on overview frames via a
transient overlay injected only after audits complete. Skill reference
gains the zoom workflow: check reports a finding, zoom into it, fix,
re-check.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 58f45ef758
commit f4cef54b8b
12 changed files with 856 additions and 31 deletions
+40 -2
View File
@@ -1,11 +1,11 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
openSettledCompositionPage,
type OpenSettledCompositionPageOptions,
} from "../capture/captureCompositionFrame.js";
import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js";
import { runBrowserCheck } from "./checkBrowser.js";
import { captureOverviewShot, runBrowserCheck } from "./checkBrowser.js";
import type { ProjectDir } from "./project.js";
const mocks = vi.hoisted(() => ({
@@ -94,6 +94,44 @@ it("carries raw browser geometry through the page driver and pipeline", async ()
expect(mocks.serverClose).toHaveBeenCalledOnce();
});
describe("captureOverviewShot", () => {
it("injects the annotation overlay before the overview shot and removes it right after", async () => {
const calls: string[] = [];
const evaluate = vi.fn(async (fn: unknown, ...args: unknown[]) => {
calls.push("evaluate");
return typeof fn === "function" ? Reflect.apply(fn, undefined, args) : undefined;
});
const screenshot = vi.fn(async () => {
calls.push("screenshot");
return "annotated-base64";
});
const page = Object.assign(Object.create(null), { evaluate, screenshot });
const result = await captureOverviewShot(
page,
[{ label: "1 clipped_text", bbox: { x: 0, y: 0, width: 10, height: 10 } }],
"measurement-base64",
);
// inject overlay -> take the shot -> remove overlay, in that order —
// never present while any audit (which runs before this is called) collects.
expect(calls).toEqual(["evaluate", "screenshot", "evaluate"]);
expect(result).toBe("annotated-base64");
});
it("skips the overlay entirely and returns the plain screenshot when there's nothing to annotate", async () => {
const evaluate = vi.fn();
const screenshot = vi.fn();
const page = Object.assign(Object.create(null), { evaluate, screenshot });
const result = await captureOverviewShot(page, [], "measurement-base64");
expect(evaluate).not.toHaveBeenCalled();
expect(screenshot).not.toHaveBeenCalled();
expect(result).toBe("measurement-base64");
});
});
function installRects(): void {
const root = document.querySelector("[data-composition-id]");
const image = document.querySelector("#hero-image");
+150 -6
View File
@@ -1,6 +1,10 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "puppeteer-core";
import {
captureRegionCrop,
openSettledCompositionPage,
padCropRegion,
resolveCliChromeGpuMode,
seekCompositionTimeline,
waitForPreferredSeekTarget,
@@ -14,10 +18,12 @@ import { serveStaticProjectHtml } from "./staticProjectServer.js";
import type {
AnchoredLayoutIssue,
CheckAnchor,
CheckAnnotationBox,
CheckAuditDriver,
CheckBbox,
CheckBrowserResult,
CheckFinding,
CheckFindingCropRequest,
CheckGeometryCandidate,
CheckOptions,
CheckSeverity,
@@ -36,6 +42,11 @@ const SEEK_OPTIONS = {
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;
@@ -121,6 +132,53 @@ export async function runBrowserCheck(
}
}
/**
* `check --snapshots`'s per-finding evidence crops. Opens its own session
* (the main grid session already closed by the time findings are shaped) and
* re-seeks to each finding's sample time — renders are deterministic, so a
* fresh page at the same time reproduces the same pixels the grid audited.
*/
export async function captureFindingCrops(
project: ProjectDir,
options: CheckOptions,
requests: CheckFindingCropRequest[],
): Promise<string[]> {
if (requests.length === 0) return [];
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(project.dir);
const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
let chromeBrowser: import("puppeteer-core").Browser | undefined;
const written: string[] = [];
try {
const session = await openSettledCompositionPage(html, server.url, {
renderReadyTimeoutMs: options.timeout,
renderReadyWarningSuffix: "capturing finding crops",
browserGpuMode: resolveCliChromeGpuMode(),
});
chromeBrowser = session.browser;
const page = session.page;
await waitForPreferredSeekTarget(page, 500);
const snapshotDir = join(project.dir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
for (const request of requests) {
await seekCompositionTimeline(page, request.time, 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);
writeFileSync(join(snapshotDir, request.filename), buffer);
written.push(join("snapshots", request.filename));
}
return written;
} finally {
await chromeBrowser?.close().catch(() => undefined);
await server.close();
}
}
function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
page.on("console", (message) => {
const type = message.type();
@@ -202,7 +260,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
collectMotionFrame: (time, selectors, scopes) =>
collectMotionFrame(page, time, selectors, scopes),
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
collectContrast: (time) => collectContrast(page, time),
collectContrast: (time, annotations) => collectContrast(page, time, annotations),
};
}
@@ -430,20 +488,36 @@ async function compositionBbox(page: Page): Promise<CheckBbox> {
});
}
async function collectContrast(page: Page, time: number): Promise<ContrastCapture> {
async function collectContrast(
page: Page,
time: number,
layoutAnnotations: CheckAnnotationBox[] = [],
): Promise<ContrastCapture> {
let prepared: PreparedContrast[] = [];
try {
prepared = parsePreparedContrast(await prepareContrast(page, time));
const screenshot = await page.screenshot({ encoding: "base64", type: "png" });
if (typeof screenshot !== "string") throw new Error("Contrast screenshot was not base64");
// This screenshot is the one contrast math is sampled from below — it must
// stay untouched by the annotation overlay (finishContrast reads real
// painted pixels), so annotation only ever happens on a SECOND shot.
const measurementShot = await page.screenshot({ encoding: "base64", type: "png" });
if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64");
const raw = await finishContrast(
page,
screenshot,
measurementShot,
time,
prepared.map((entry) => entry.raw),
);
const finished = raw.flatMap(parseFinishedContrast);
return { entries: joinContrastEntries(finished, prepared), pngBase64: screenshot };
const entries = joinContrastEntries(finished, prepared);
// Contrast failures are only known once measurement above completes, so
// they're appended to the layout-derived annotations passed in by the
// pipeline rather than being requested up front.
const annotations = [
...layoutAnnotations,
...contrastFailureAnnotations(entries, layoutAnnotations.length),
];
const pngBase64 = await captureOverviewShot(page, annotations, measurementShot);
return { entries, pngBase64 };
} finally {
await page
.evaluate(() => {
@@ -454,6 +528,76 @@ async function collectContrast(page: Page, time: number): Promise<ContrastCaptur
}
}
function contrastFailureAnnotations(
entries: ContrastAuditEntry[],
labelOffset: number,
): CheckAnnotationBox[] {
return entries
.filter((entry) => !entry.wcagAA)
.map((entry, index) => ({
label: `${labelOffset + index + 1} contrast_aa_failure`,
bbox: entry.bbox,
}));
}
const ANNOTATION_OVERLAY_ID = "__hyperframesCheckAnnotations";
/**
* `check --snapshots`'s overview-frame annotation: every audit for this
* sample time has already run (layout/geometry findings arrive via
* `annotations`; contrast failures were just measured above), so it's safe
* to draw labeled boxes and take one more shot without perturbing anything
* audits read. No-op (returns the plain screenshot) when there's nothing to
* annotate — the common case stays exactly as before this feature existed.
*/
export async function captureOverviewShot(
page: Page,
annotations: CheckAnnotationBox[],
fallbackScreenshot: string,
): Promise<string> {
if (annotations.length === 0) return fallbackScreenshot;
await injectAnnotationOverlay(page, annotations);
try {
const shot = await page.screenshot({ encoding: "base64", type: "png" });
return typeof shot === "string" ? shot : fallbackScreenshot;
} finally {
await removeAnnotationOverlay(page);
}
}
/** One small, self-contained DOM overlay: fixed-position labeled boxes over
* each finding's bbox. Injected immediately before the annotated overview
* shot and torn down immediately after (see `captureOverviewShot`), so it
* never leaks into any audit's DOM reads. */
async function injectAnnotationOverlay(page: Page, boxes: CheckAnnotationBox[]): Promise<void> {
await page.evaluate(
(items: CheckAnnotationBox[], overlayId: string) => {
const root = document.createElement("div");
root.id = overlayId;
root.style.cssText = "position:fixed;inset:0;z-index:2147483647;pointer-events:none;";
for (const item of items) {
const box = document.createElement("div");
box.style.cssText = `position:fixed;left:${item.bbox.x}px;top:${item.bbox.y}px;width:${item.bbox.width}px;height:${item.bbox.height}px;border:2px solid #ff2d55;box-sizing:border-box;`;
const label = document.createElement("div");
label.textContent = item.label;
label.style.cssText =
"position:absolute;top:-18px;left:0;background:#ff2d55;color:#fff;font:11px/16px monospace;padding:0 4px;white-space:nowrap;";
box.appendChild(label);
root.appendChild(box);
}
document.body.appendChild(root);
},
boxes,
ANNOTATION_OVERLAY_ID,
);
}
async function removeAnnotationOverlay(page: Page): Promise<void> {
await page.evaluate((overlayId: string) => {
document.getElementById(overlayId)?.remove();
}, ANNOTATION_OVERLAY_ID);
}
async function prepareContrast(page: Page, time: number): Promise<unknown[]> {
// Candidate-to-element provenance must be captured while the prepare restore list is live.
// fallow-ignore-next-line complexity
+125 -17
View File
@@ -30,12 +30,14 @@ import {
} from "../commands/contrast-bg.js";
import type {
AnchoredLayoutIssue,
CheckAnnotationBox,
CheckAuditDriver,
CheckBbox,
CheckBrowserResult,
CheckContrastFinding,
CheckDependencies,
CheckFinding,
CheckFindingCropRequest,
CheckGeometryCandidate,
CheckOptions,
CheckReport,
@@ -54,6 +56,7 @@ export type {
CheckBrowserResult,
CheckDependencies,
CheckFinding,
CheckFindingCropRequest,
CheckOptions,
CheckReport,
CheckSection,
@@ -356,13 +359,26 @@ async function collectGridSamples(
};
for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
await driver.seek(time);
// Findings collected for THIS sample time, so the overview overlay (below)
// only ever annotates a frame with defects that are actually valid at
// that render time — never a stale bbox from an earlier/later sample.
const issuesAtTime: AnchoredLayoutIssue[] = [];
if (layoutSet.has(time)) {
collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance)));
const layoutIssues = await driver.collectLayout(time, options.tolerance);
collected.layoutIssues.push(...layoutIssues);
issuesAtTime.push(...layoutIssues);
}
if (canvas) {
collected.layoutIssues.push(
...(await collectGeometryAt(driver, options, grid, canvas, time, geometrySeen)),
const geometryIssues = await collectGeometryAt(
driver,
options,
grid,
canvas,
time,
geometrySeen,
);
collected.layoutIssues.push(...geometryIssues);
issuesAtTime.push(...geometryIssues);
}
if (motionSet.has(time)) {
collected.motionFrames.push(
@@ -371,7 +387,12 @@ async function collectGridSamples(
}
if (contrastSet.has(time)) {
const contrastStart = Date.now();
const capture = await driver.collectContrast(time);
// Annotation is a --snapshots-only nicety — skip building it (and the
// driver's extra overlay screenshot) when nothing will use it; the call
// shape without --snapshots stays exactly what it was before this existed.
const capture = options.snapshots
? await driver.collectContrast(time, annotationBoxesFrom(issuesAtTime))
: await driver.collectContrast(time);
collected.contrastMs += Date.now() - contrastStart;
collected.contrastEntries.push(...capture.entries);
collected.screenshots.push({ time, pngBase64: capture.pngBase64 });
@@ -380,6 +401,15 @@ async function collectGridSamples(
return collected;
}
/** Error-severity findings with real geometry become labeled overview boxes.
* Contrast failures are annotated separately by the driver itself, since
* they're only known once contrast measurement for this sample completes. */
function annotationBoxesFrom(issues: AnchoredLayoutIssue[]): CheckAnnotationBox[] {
return issues
.filter((issue) => issue.severity === "error" && issue.bbox.width > 0 && issue.bbox.height > 0)
.map((issue, index) => ({ label: `${index + 1} ${issue.code}`, bbox: issue.bbox }));
}
export async function runAuditGrid(
driver: CheckAuditDriver,
options: CheckOptions,
@@ -456,21 +486,87 @@ export async function runCheckPipeline(
browser.runtimeFindings.push(runtimeFailure(error));
}
const snapshotFiles: string[] = [];
if (options.snapshots) {
for (let index = 0; index < browser.screenshots.length; index += 1) {
const shot = browser.screenshots[index];
if (!shot) continue;
try {
snapshotFiles.push(
await dependencies.writeSnapshot(project.dir, index, shot.time, shot.pngBase64),
);
} catch (error) {
browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed"));
}
const snapshotFiles = options.snapshots
? await writeContrastSnapshots(dependencies, project.dir, browser)
: [];
const report = buildReport(options, lint, browser, motion, [], snapshotFiles);
return options.snapshots
? await withFindingCrops(dependencies, project, options, report)
: report;
}
/** Persists the contrast pass's already-captured overview PNGs (or the
* annotated versions — see `collectContrast`'s overlay). A write failure
* becomes a runtime finding rather than aborting the whole report. */
async function writeContrastSnapshots(
dependencies: CheckDependencies,
projectDir: string,
browser: CheckBrowserResult,
): Promise<string[]> {
const files: string[] = [];
for (let index = 0; index < browser.screenshots.length; index += 1) {
const shot = browser.screenshots[index];
if (!shot) continue;
try {
files.push(await dependencies.writeSnapshot(projectDir, index, shot.time, shot.pngBase64));
} catch (error) {
browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed"));
}
}
return buildReport(options, lint, browser, motion, [], snapshotFiles);
return files;
}
/** Finding crops are bonus evidence, not gating — no eligible finding, or a
* capture failure (e.g. a second Chrome launch failing), returns the report
* unchanged rather than sinking an otherwise-good run. */
async function withFindingCrops(
dependencies: CheckDependencies,
project: ProjectDir,
options: CheckOptions,
report: CheckReport,
): Promise<CheckReport> {
const cropRequests = selectFindingCropRequests(report);
if (cropRequests.length === 0) return report;
try {
const findingFiles = await dependencies.captureFindingCrops(project, options, cropRequests);
return { ...report, snapshots: { ...report.snapshots, findingFiles } };
} catch {
return report;
}
}
const MAX_FINDING_CROPS = 12;
/** Which error findings get a `finding-NN-<code>.png` crop for `check --snapshots`:
* error severity, a real (non-zero) bbox, capped at 12. Pure and order-preserving
* so it's directly unit-testable without a browser. */
export function selectFindingCropRequests(report: CheckReport): CheckFindingCropRequest[] {
const candidates: CheckFinding[] = [
...report.layout.findings,
...report.motion.findings,
...report.contrast.findings,
...report.runtime.findings,
];
const requests: CheckFindingCropRequest[] = [];
for (const finding of candidates) {
if (requests.length >= MAX_FINDING_CROPS) break;
if (finding.severity !== "error" || !hasRealBbox(finding.bbox)) continue;
requests.push({
filename: findingCropFilename(requests.length, finding.code),
time: finding.time,
bbox: finding.bbox,
});
}
return requests;
}
function hasRealBbox(bbox: CheckBbox): boolean {
return bbox.width > 0 && bbox.height > 0;
}
export function findingCropFilename(index: number, code: string): string {
const safeCode = code.replace(/[^a-zA-Z0-9_-]/g, "_");
return `finding-${String(index).padStart(2, "0")}-${safeCode}.png`;
}
export function checkExitCode(report: CheckReport): 0 | 1 {
@@ -587,6 +683,7 @@ function buildReport(
enabled: options.snapshots,
files: snapshotFiles,
times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [],
findingFiles: [],
},
};
trackCheckReport({
@@ -771,9 +868,20 @@ async function writeSnapshot(
return join("snapshots", filename);
}
async function captureFindingCrops(
project: ProjectDir,
options: CheckOptions,
requests: CheckFindingCropRequest[],
): Promise<string[]> {
const module = await import("./checkBrowser.js");
// Handed over the same way runBrowserCheck is (checkBrowser never imports this module back).
return module.captureFindingCrops(project, options, requests);
}
const DEFAULT_DEPENDENCIES: CheckDependencies = {
lintProject,
resolveMotionSpec,
runBrowserCheck,
writeSnapshot,
captureFindingCrops,
};
+23 -2
View File
@@ -93,6 +93,22 @@ export interface GeometryCandidateRequest {
tolerance: number;
}
/** A labeled rectangle drawn on an overview frame's annotation overlay
* (`check --snapshots`) so one screenshot orients an agent across every
* error finding at that sample time. */
export interface CheckAnnotationBox {
label: string;
bbox: CheckBbox;
}
/** A single crop to capture for `check --snapshots`'s per-finding evidence
* PNGs filename and bbox already resolved by the pipeline. */
export interface CheckFindingCropRequest {
filename: string;
time: number;
bbox: CheckBbox;
}
export interface CheckGeometryCandidate extends CheckAnchor {
kind: "text" | "media";
tag: string;
@@ -125,7 +141,7 @@ export interface CheckAuditDriver {
livenessScopes: string[],
): Promise<MotionFrame>;
anchorMotionIssues(issues: LayoutIssue[]): Promise<AnchoredLayoutIssue[]>;
collectContrast(time: number): Promise<ContrastCapture>;
collectContrast(time: number, annotations?: CheckAnnotationBox[]): Promise<ContrastCapture>;
}
export interface CheckScreenshot {
@@ -192,7 +208,7 @@ export interface CheckReport {
checked: number;
passed: number;
};
snapshots: { enabled: boolean; files: string[]; times: number[] };
snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] };
}
export interface CheckDependencies {
@@ -209,4 +225,9 @@ export interface CheckDependencies {
time: number,
pngBase64: string,
): Promise<string>;
captureFindingCrops(
project: ProjectDir,
options: CheckOptions,
requests: CheckFindingCropRequest[],
): Promise<string[]>;
}