mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -3,7 +3,12 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
captureRegionCrop,
|
||||
clampCropRegion,
|
||||
padCropRegion,
|
||||
parseZoomTarget,
|
||||
resolveCliChromeGpuMode,
|
||||
resolveCropRegion,
|
||||
runFfmpegOnce,
|
||||
seekCompositionTimeline,
|
||||
type CompositionSeekPage,
|
||||
@@ -181,6 +186,130 @@ describe("screenshot Chrome arguments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZoomTarget", () => {
|
||||
it("parses four comma-separated numbers as an exact region", () => {
|
||||
expect(parseZoomTarget("100,50,400,300")).toEqual({
|
||||
kind: "region",
|
||||
region: { x: 100, y: 50, width: 400, height: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats anything else as a CSS selector", () => {
|
||||
expect(parseZoomTarget("#headline")).toEqual({ kind: "selector", selector: "#headline" });
|
||||
expect(parseZoomTarget(".card:nth-of-type(2)")).toEqual({
|
||||
kind: "selector",
|
||||
selector: ".card:nth-of-type(2)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampCropRegion / padCropRegion", () => {
|
||||
it("clamps a region to the canvas bounds", () => {
|
||||
expect(
|
||||
clampCropRegion({ x: -10, y: -10, width: 50, height: 50 }, { width: 30, height: 30 }),
|
||||
).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("pads a region on every side when it fits within the canvas", () => {
|
||||
expect(
|
||||
padCropRegion({ x: 500, y: 500, width: 100, height: 40 }, { width: 1920, height: 1080 }, 24),
|
||||
).toEqual({ x: 476, y: 476, width: 148, height: 88 });
|
||||
});
|
||||
|
||||
it("pads then clamps when padding would spill outside the canvas", () => {
|
||||
expect(
|
||||
padCropRegion({ x: 10, y: 10, width: 20, height: 20 }, { width: 200, height: 200 }, 24),
|
||||
).toEqual({ x: 0, y: 0, width: 54, height: 54 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCropRegion", () => {
|
||||
it("resolves a selector to its bbox, padded 24px and clamped", async () => {
|
||||
const page = { evaluate: vi.fn(async () => ({ x: 500, y: 500, width: 100, height: 40 })) };
|
||||
|
||||
const region = await resolveCropRegion(
|
||||
page,
|
||||
{ kind: "selector", selector: "#headline" },
|
||||
{ width: 1920, height: 1080 },
|
||||
);
|
||||
|
||||
expect(region).toEqual({ x: 476, y: 476, width: 148, height: 88 });
|
||||
});
|
||||
|
||||
it("crops a region exactly, without padding, when it already fits the canvas", async () => {
|
||||
const page = { evaluate: vi.fn() };
|
||||
|
||||
const region = await resolveCropRegion(
|
||||
page,
|
||||
{ kind: "region", region: { x: 100, y: 50, width: 400, height: 300 } },
|
||||
{ width: 1920, height: 1080 },
|
||||
);
|
||||
|
||||
expect(region).toEqual({ x: 100, y: 50, width: 400, height: 300 });
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws a clear, loud error when the selector matches nothing", async () => {
|
||||
const page = { evaluate: vi.fn(async () => null) };
|
||||
|
||||
await expect(
|
||||
resolveCropRegion(
|
||||
page,
|
||||
{ kind: "selector", selector: "#missing" },
|
||||
{ width: 640, height: 360 },
|
||||
),
|
||||
).rejects.toThrow("--zoom selector matched no element: #missing");
|
||||
});
|
||||
|
||||
it("returns null when the clamped region is a sliver (element animated off-canvas)", async () => {
|
||||
// Element slid past the right edge: raw bbox is large, but clamping the
|
||||
// padded region to the canvas leaves ~1px — a useless crop.
|
||||
const page = { evaluate: vi.fn(async () => ({ x: 2500, y: 400, width: 600, height: 250 })) };
|
||||
|
||||
const region = await resolveCropRegion(
|
||||
page,
|
||||
{ kind: "selector", selector: "#gone-by-now" },
|
||||
{ width: 1920, height: 1080 },
|
||||
);
|
||||
|
||||
expect(region).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureRegionCrop", () => {
|
||||
it("raises deviceScaleFactor for the clip shot, then restores the original viewport", async () => {
|
||||
const original = { width: 1920, height: 1080, deviceScaleFactor: 1 };
|
||||
const setViewport = vi.fn(async () => undefined);
|
||||
const screenshot = vi.fn(async () => new Uint8Array([1, 2, 3]));
|
||||
const page = { viewport: () => original, setViewport, screenshot };
|
||||
const region = { x: 10, y: 20, width: 100, height: 50 };
|
||||
|
||||
const buffer = await captureRegionCrop(page, region, 3);
|
||||
|
||||
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 });
|
||||
expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" });
|
||||
expect(setViewport).toHaveBeenNthCalledWith(2, original);
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(Array.from(buffer)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("honors an explicit scale other than the default", async () => {
|
||||
const original = { width: 800, height: 600 };
|
||||
const setViewport = vi.fn(async () => undefined);
|
||||
const screenshot = vi.fn(async () => new Uint8Array());
|
||||
const page = { viewport: () => original, setViewport, screenshot };
|
||||
|
||||
await captureRegionCrop(page, { x: 0, y: 0, width: 10, height: 10 }, 2);
|
||||
|
||||
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpegOnce", () => {
|
||||
it("returns the process exit code and collected stderr", async () => {
|
||||
const dir = tempDir();
|
||||
|
||||
@@ -286,6 +286,141 @@ export async function waitForCompositionFonts(
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CropCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ZoomTarget =
|
||||
| { kind: "selector"; selector: string }
|
||||
| { kind: "region"; region: CropRegion };
|
||||
|
||||
// Four bare comma-separated numbers is unambiguous — no valid CSS selector
|
||||
// parses as that shape — so it always means "exact pixel region".
|
||||
const ZOOM_REGION_PATTERN = /^-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){3}$/;
|
||||
const DEFAULT_ZOOM_PADDING_PX = 24;
|
||||
|
||||
/** Parse `snapshot --zoom` into either a CSS selector or an exact pixel region "x,y,w,h". */
|
||||
export function parseZoomTarget(value: string): ZoomTarget {
|
||||
const trimmed = value.trim();
|
||||
if (ZOOM_REGION_PATTERN.test(trimmed)) {
|
||||
const [x, y, width, height] = trimmed.split(",").map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return { kind: "region", region: { x, y, width, height } };
|
||||
}
|
||||
return { kind: "selector", selector: trimmed };
|
||||
}
|
||||
|
||||
/** Clamp a region to the canvas bounds — Puppeteer's clip screenshot rejects a
|
||||
* region that spills outside the viewport. Keeps at least 1px on each side. */
|
||||
export function clampCropRegion(region: CropRegion, canvas: CropCanvas): CropRegion {
|
||||
const x = Math.max(0, Math.min(region.x, canvas.width));
|
||||
const y = Math.max(0, Math.min(region.y, canvas.height));
|
||||
const x2 = Math.max(x + 1, Math.min(region.x + region.width, canvas.width));
|
||||
const y2 = Math.max(y + 1, Math.min(region.y + region.height, canvas.height));
|
||||
return { x, y, width: x2 - x, height: y2 - y };
|
||||
}
|
||||
|
||||
/** Pad a region on every side (context around a zoomed element), then clamp. */
|
||||
export function padCropRegion(
|
||||
region: CropRegion,
|
||||
canvas: CropCanvas,
|
||||
paddingPx: number,
|
||||
): CropRegion {
|
||||
return clampCropRegion(
|
||||
{
|
||||
x: region.x - paddingPx,
|
||||
y: region.y - paddingPx,
|
||||
width: region.width + paddingPx * 2,
|
||||
height: region.height + paddingPx * 2,
|
||||
},
|
||||
canvas,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ZoomSelectorPage {
|
||||
evaluate(
|
||||
pageFunction: (selector: string) => CropRegion | null,
|
||||
selector: string,
|
||||
): Promise<CropRegion | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `--zoom` target to a concrete crop region. A selector resolves to
|
||||
* its live bbox (padded ~24px, then clamped); an explicit region is used
|
||||
* as-is (clamped only, never padded — region form crops exactly). A selector
|
||||
* matching nothing throws: a loud error beats a silent full-frame fallback.
|
||||
*/
|
||||
// A selector can match an element whose visible box is gone at the sampled
|
||||
// time — collapsed (display:none mid-timeline) or animated off-canvas, where
|
||||
// clamping leaves a pixel-wide remnant. Either way the crop would be a sliver
|
||||
// that tells an agent nothing, so the final clamped region is what's guarded
|
||||
// and callers skip the frame on null. Explicit x,y,w,h regions stay literal.
|
||||
const MIN_CROP_REGION_PX = 8;
|
||||
|
||||
export async function resolveCropRegion(
|
||||
page: ZoomSelectorPage,
|
||||
target: ZoomTarget,
|
||||
canvas: CropCanvas,
|
||||
paddingPx = DEFAULT_ZOOM_PADDING_PX,
|
||||
): Promise<CropRegion | null> {
|
||||
if (target.kind === "region") return clampCropRegion(target.region, canvas);
|
||||
const bbox = await page.evaluate((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}, target.selector);
|
||||
if (!bbox) throw new Error(`--zoom selector matched no element: ${target.selector}`);
|
||||
const region = padCropRegion(bbox, canvas, paddingPx);
|
||||
if (region.width < MIN_CROP_REGION_PX || region.height < MIN_CROP_REGION_PX) return null;
|
||||
return region;
|
||||
}
|
||||
|
||||
export interface CropCapturePage {
|
||||
viewport(): { width: number; height: number; deviceScaleFactor?: number } | null;
|
||||
setViewport(viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
}): Promise<void>;
|
||||
screenshot(options: { clip: CropRegion; type: "png" }): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a high-density crop of `region`: raise `deviceScaleFactor` to
|
||||
* `scale`, take a clip screenshot, then restore the original viewport.
|
||||
* Deliberately NOT CSS zoom or a viewport resize — DSF only changes how
|
||||
* densely Chrome rasterizes the existing CSS-pixel layout, so the
|
||||
* composition's layout (and its render determinism) is untouched. The PNG
|
||||
* comes out at `region.width * scale` real device pixels, not an upscale.
|
||||
*/
|
||||
export async function captureRegionCrop(
|
||||
page: CropCapturePage,
|
||||
region: CropRegion,
|
||||
scale: number,
|
||||
): Promise<Buffer> {
|
||||
const original = page.viewport();
|
||||
if (original) await page.setViewport({ ...original, deviceScaleFactor: scale });
|
||||
try {
|
||||
const shot = await page.screenshot({ clip: region, type: "png" });
|
||||
return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
|
||||
} finally {
|
||||
if (original) await page.setViewport(original);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFfmpegOnce(
|
||||
ffmpegPath: string,
|
||||
args: readonly string[],
|
||||
|
||||
@@ -13,15 +13,18 @@ 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,
|
||||
@@ -29,7 +32,12 @@ import {
|
||||
} from "../utils/checkPipeline.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js";
|
||||
import type {
|
||||
LayoutIssue,
|
||||
LayoutIssueCode,
|
||||
LayoutOverflow,
|
||||
LayoutRect,
|
||||
} from "../utils/layoutAudit.js";
|
||||
import type { ProjectDir } from "../utils/project.js";
|
||||
|
||||
const PROJECT: ProjectDir = {
|
||||
@@ -241,6 +249,7 @@ function dependencies(
|
||||
motion?: MotionSpecResolution;
|
||||
runtime?: CheckFinding[];
|
||||
writeSnapshot?: CheckDependencies["writeSnapshot"];
|
||||
captureFindingCrops?: CheckDependencies["captureFindingCrops"];
|
||||
} = {},
|
||||
): { deps: CheckDependencies; runBrowserCheck: ReturnType<typeof vi.fn> } {
|
||||
const runBrowserCheck = vi.fn(
|
||||
@@ -264,6 +273,7 @@ function dependencies(
|
||||
`snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
|
||||
),
|
||||
),
|
||||
captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []),
|
||||
};
|
||||
return { deps, runBrowserCheck };
|
||||
}
|
||||
@@ -655,6 +665,115 @@ it("keeps contrast and snapshot sampling on the pre-gate layout grid", async ()
|
||||
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;
|
||||
|
||||
@@ -833,6 +952,47 @@ describe("check pipeline", () => {
|
||||
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 () => [layoutIssue()]) }),
|
||||
{ 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 () => [layoutIssue()]) }),
|
||||
{ snapshots: false },
|
||||
{ captureFindingCrops: withoutSnapshots },
|
||||
);
|
||||
expect(withoutSnapshots).not.toHaveBeenCalled();
|
||||
|
||||
const noErrors = vi.fn(async () => ["unused.png"]);
|
||||
await runScenario(
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }),
|
||||
{ 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 () => [layoutIssue("warning")]) });
|
||||
|
||||
@@ -339,6 +339,12 @@ function printSnapshotSection(report: CheckReport): void {
|
||||
} else {
|
||||
console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
|
||||
for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
|
||||
if (report.snapshots.findingFiles.length > 0) {
|
||||
console.log(
|
||||
` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`,
|
||||
);
|
||||
for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
|
||||
import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js";
|
||||
|
||||
// --zoom's crop-region math (selector bbox + padding + clamp, exact region
|
||||
// form, no-match error) is owned by and tested in
|
||||
// ../capture/captureCompositionFrame.test.ts alongside its implementation.
|
||||
|
||||
describe("tailFrameTime", () => {
|
||||
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
|
||||
@@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
|
||||
expect(appendedTail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZoomScale (--zoom-scale)", () => {
|
||||
it("defaults to 3 when unset", () => {
|
||||
expect(parseZoomScale(undefined)).toBe(3);
|
||||
});
|
||||
|
||||
it("honors an explicit scale", () => {
|
||||
expect(parseZoomScale("2")).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to the default for invalid or non-positive input", () => {
|
||||
expect(parseZoomScale("abc")).toBe(3);
|
||||
expect(parseZoomScale("0")).toBe(3);
|
||||
expect(parseZoomScale("-1")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,13 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, isAbsolute, basename } from "node:path";
|
||||
import {
|
||||
captureRegionCrop,
|
||||
openSettledCompositionPage,
|
||||
parseZoomTarget,
|
||||
resolveCropRegion,
|
||||
runFfmpegOnce,
|
||||
seekCompositionTimeline,
|
||||
type ZoomTarget,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
@@ -104,8 +108,20 @@ export const examples: Example[] = [
|
||||
["Capture 5 key frames from a composition", "snapshot capture"],
|
||||
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
|
||||
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
|
||||
["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"],
|
||||
[
|
||||
"Zoom into an exact pixel region at 2x density",
|
||||
"snapshot --zoom 100,50,400,300 --zoom-scale 2",
|
||||
],
|
||||
];
|
||||
|
||||
/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3;
|
||||
* falls back to the default for anything that doesn't parse as a positive number. */
|
||||
export function parseZoomScale(value: unknown): number {
|
||||
const parsed = parseFloat(String(value ?? ""));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
|
||||
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
|
||||
@@ -172,6 +188,8 @@ async function captureSnapshots(
|
||||
outputDir?: string;
|
||||
angle?: Camera;
|
||||
includeEnd?: boolean;
|
||||
zoom?: ZoomTarget;
|
||||
zoomScale?: number;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
@@ -425,7 +443,25 @@ async function captureSnapshots(
|
||||
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
|
||||
const framePath = join(snapshotDir, filename);
|
||||
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
if (opts.zoom) {
|
||||
// Clip screenshot at a raised deviceScaleFactor — never CSS zoom or
|
||||
// viewport resizing — so the composition's own layout is untouched.
|
||||
const canvas = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
const region = await resolveCropRegion(page, opts.zoom, canvas);
|
||||
if (!region) {
|
||||
console.error(
|
||||
` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const buffer = await captureRegionCrop(page, region, opts.zoomScale ?? 3);
|
||||
writeFileSync(framePath, buffer);
|
||||
} else {
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
}
|
||||
const rel = relative(projectDir, framePath);
|
||||
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
|
||||
}
|
||||
@@ -480,6 +516,16 @@ export default defineCommand({
|
||||
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
|
||||
default: true,
|
||||
},
|
||||
zoom: {
|
||||
type: "string",
|
||||
description:
|
||||
"Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.",
|
||||
},
|
||||
"zoom-scale": {
|
||||
type: "string",
|
||||
description: "Device-scale-factor density for --zoom crops (default: 3)",
|
||||
default: "3",
|
||||
},
|
||||
describe: {
|
||||
type: "string",
|
||||
description:
|
||||
@@ -508,6 +554,8 @@ export default defineCommand({
|
||||
: String(args.describe);
|
||||
|
||||
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
|
||||
const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined;
|
||||
const zoomScale = parseZoomScale(args["zoom-scale"]);
|
||||
|
||||
const label = atTimestamps
|
||||
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
|
||||
@@ -529,6 +577,8 @@ export default defineCommand({
|
||||
outputDir: snapshotDir,
|
||||
angle: camera,
|
||||
includeEnd: args.end !== false,
|
||||
zoom: zoomTarget,
|
||||
zoomScale,
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"files": 99
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "8ca3bef87f169ba1",
|
||||
"hash": "9544d2cee786ebaf",
|
||||
"files": 7
|
||||
},
|
||||
"hyperframes-core": {
|
||||
|
||||
@@ -119,3 +119,17 @@ npx hyperframes snapshot --frames 10 # evenly-spaced N frames
|
||||
```
|
||||
|
||||
Captures still PNGs from the composition for visual diffing, thumbnails, or attaching to a PR. Faster than rendering a video when you only need a few hero frames. Output lands in the project's snapshots directory.
|
||||
|
||||
### Zooming into a reported finding
|
||||
|
||||
`hyperframes check --snapshots` already writes a `finding-NN-<code>.png` crop for every error finding that carries a bbox, but the same zoom is available standalone once you know what to look at:
|
||||
|
||||
```bash
|
||||
npx hyperframes check --snapshots # reports a finding, e.g. content_overlap on "#cta"
|
||||
npx hyperframes snapshot --zoom "#cta" # crop the element to verify the defect, at 3x density
|
||||
npx hyperframes snapshot --zoom "100,50,400,300" --zoom-scale 2 # or an exact pixel region
|
||||
# fix the composition HTML, then re-check:
|
||||
npx hyperframes check
|
||||
```
|
||||
|
||||
`--zoom` takes a CSS selector or an exact `x,y,w,h` pixel region and always produces a real high-density crop (a raised `deviceScaleFactor`, never CSS zoom or a viewport resize), so the composition's layout — and its render determinism — is untouched. A selector matching nothing is a loud error, not a silent full-frame fallback.
|
||||
|
||||
Reference in New Issue
Block a user