feat(cli): scope check to a time range or a set of components (#5516)

* feat(cli): scope check to a time range or a set of components

* fix(cli): keep ranged check gates truthful

Refuse ranged caption and frame gates when none of their seek times
falls inside the requested range. Make ranged motion reporting,
overlap timestamps, filtering tests, and scope documentation match.
This commit is contained in:
Xuanru Li
2026-10-13 03:13:26 +00:00
committed by GitHub
parent 69f3306415
commit 36526bf1a6
7 changed files with 376 additions and 32 deletions
+12 -1
View File
@@ -713,7 +713,7 @@ did, in one command and one browser session.
```bash
npx hyperframes check [dir]
npx hyperframes check [dir] --json # {ok, browserSkipped, lint, runtime, layout, motion, contrast, hdr, snapshots}
npx hyperframes check [dir] --json # {ok, browserSkipped, scope, lint, runtime, layout, motion, contrast, hdr, snapshots}
npx hyperframes check [dir] --snapshots
npx hyperframes check [dir] --at 1.5,4,7.25
npx hyperframes check [dir] --strict # warnings fail too
@@ -729,6 +729,11 @@ When the browser never ran (lint errors, a linter crash, or a browser launch
failure), `browserSkipped` is `true` and the `layout`, `motion` and `contrast`
sections are empty, not clean.
Scoped checks add `scope` to the JSON report with the requested range and
components, hidden-finding count, and scope notes. Component filtering changes
the returned finding arrays only; section counts, `ok`, and the exit code still
cover every finding from the full check.
| Flag | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `--json` | One aggregated envelope. Every finding carries a selector, `data-*` identity, source file, bbox, and sample time. |
@@ -737,6 +742,8 @@ sections are empty, not clean.
| `--at` | Explicit timestamps in seconds, comma-separated |
| `--at-transitions` | Also sample every tween start and end, to catch transient overlaps at transition seams |
| `--max-transition-samples` | Cap the transition-derived samples. When it truncates, the omitted count is reported. Default: unlimited. |
| `--range <start,end>` | Sample only this interval, in global seconds. Lint stays project-wide; whole-timeline motion and `sweep_static` are skipped. |
| `--components <selectors>` | Report only findings that involve these elements or their descendants. Counts, `ok` and the exit code still cover all. |
| `--max-issues` | Findings to print or return after static collapse (default 80) |
| `--collapse-static` / `--no-collapse-static` | Fold a repeated static finding into one row (default: on) |
| `--tolerance` | Allowed overflow in px before reporting (default 2) |
@@ -749,6 +756,10 @@ sections are empty, not clean.
| `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) |
| `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs before checking (default: on) |
`--caption-zone` and `--frame-check` refuse a ranged check when none of their
configured seek samples falls inside `--range`; a requested gate never silently
passes without running.
Contrast failures are errors, and each one reports the sampled foreground and
background colours, the measured ratio against the required one, and a
compliant colour you could use instead.
+111 -1
View File
@@ -9,7 +9,7 @@ vi.mock("../telemetry/events.js", () => ({
}));
import { contrastRatio, parseColorRGBA } from "./contrast-bg.js";
import { createCheckCommand } from "./check.js";
import { createCheckCommand, parseCheckRange } from "./check.js";
import { trackCommandFailures } from "../utils/command-failure-tracking.js";
import type { CommandDef } from "citty";
@@ -2259,3 +2259,113 @@ describe("dense motion-overlap re-sampling", () => {
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
});
});
describe("scoped check", () => {
it("bounds all runtime seeks, including dense overlap and transition samples", async () => {
const times: number[] = [];
const overlapTimes: number[] = [];
const driver = fakeDriver({
getDuration: async () => 10,
getTransitionBoundaries: async () => [1, 7.9, 9],
seek: async (t) => {
times.push(t);
},
seekGeometry: async (t) => {
times.push(t);
overlapTimes.push(t);
},
});
const { report } = await runScenario(driver, { range: [7.8, 8.2], atTransitions: true });
expect(times.length).toBeGreaterThan(3);
expect(times.every((t) => t >= 7.8 && t <= 8.2)).toBe(true);
expect(times).toContain(7.9);
expect(overlapTimes.every((time) => (String(time).split(".")[1]?.length ?? 0) <= 3)).toBe(true);
expect(report.scope?.range).toEqual([7.8, 8.2]);
});
it("keeps the full-check failure when the only error is hidden by filtering", async () => {
const driver = fakeDriver({
collectLayout: async () => [layoutIssue("error")],
matchComponents: async (_c, f) => f.map(() => false),
});
const { report } = await runScenario(driver, {
components: ["#selected"],
collapseStatic: false,
});
expect(report.ok).toBe(false);
expect(report.layout.findings).toHaveLength(0);
expect(report.layout.errorCount).toBe(1);
expect(report.runtime.findings).toHaveLength(0);
expect(report.scope?.hiddenFindings).toBe(1);
});
it.each([
{
flag: "--caption-zone",
options: {
range: [1, 2] as [number, number],
captionZone: { x0: 0, y0: 0.5, x1: 1, y1: 0.7 },
},
},
{
flag: "--frame-check",
options: { range: [0, 1] as [number, number], frameCheck: {} },
},
])(
"refuses $flag when none of its gate samples lie within --range",
async ({ flag, options }) => {
const { report } = await runScenario(fakeDriver({ getDuration: async () => 4 }), options);
expect(report.ok).toBe(false);
expect(report.browserSkipped).toBe(true);
expect(report.runtime.findings).toEqual([
expect.objectContaining({ message: expect.stringContaining(`No ${flag} seek samples`) }),
]);
},
);
it("marks a motion spec disabled when a range skips whole-timeline assertions", async () => {
const motion: MotionSpecResolution = {
kind: "valid",
path: "/project/index.motion.json",
spec: { assertions: [] },
};
const { report } = await runScenario(
fakeDriver({ getDuration: async () => 4 }),
{ range: [1, 2] },
{ motion },
);
expect(report.motion).toEqual(
expect.objectContaining({
enabled: false,
specPath: "/project/index.motion.json",
samples: 0,
}),
);
});
it("rejects a range outside the film rather than passing an empty check", async () => {
const { report } = await runScenario(fakeDriver({ getDuration: async () => 2 }), {
range: [3, 4],
});
expect(report.ok).toBe(false);
expect(report.browserSkipped).toBe(true);
expect(report.runtime.findings[0]?.message).toContain("2 seconds");
});
});
it("validates range syntax before running a check", () => {
expect(parseCheckRange("7.8,8.2")).toEqual([7.8, 8.2]);
expect(parseCheckRange("7.8:8.2")).toEqual([7.8, 8.2]);
for (const value of ["", "1", "1,1", "2,1", "-1,2", "NaN,2", "1,Infinity", ",2", "1,2,3"])
expect(() => parseCheckRange(value)).toThrow();
});
it("applies transition sample limits within the requested range", async () => {
const driver = fakeDriver({
getDuration: async () => 10,
getTransitionBoundaries: async () => [0, 1, 2, 3, 4, 5, 6, 7.8, 7.9, 8, 9, 10],
});
const { report } = await runScenario(driver, {
range: [7.8, 8],
atTransitions: true,
maxTransitionSamples: 2,
});
expect(report.layout.transitionSamples).toEqual([7.8, 8]);
expect(report.layout.transitionSamplesDropped).toBe(3);
});
+45 -3
View File
@@ -49,6 +49,16 @@ const CHECK_COMMAND_ARGS = {
description: "Number of midpoint samples across the duration (default: 9)",
default: "9",
},
range: {
type: "string",
description:
"Runtime sample interval in global seconds: start,end. Static lint remains project-wide.",
},
components: {
type: "string",
description:
"CSS selector list: filter findings involving these components or descendants; preserve full-check status.",
},
at: {
type: "string",
description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
@@ -174,6 +184,8 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0);
return {
samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples),
range: parseCheckRange(args.range),
components: typeof args.components === "string" ? [args.components] : undefined,
at: parseAt(args.at),
atTransitions: args["at-transitions"] === true,
maxTransitionSamples: maxTransitionSamples > 0 ? maxTransitionSamples : undefined,
@@ -192,6 +204,23 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
};
}
export function parseCheckRange(value: unknown): [number, number] | undefined {
if (value === undefined) return undefined;
const parts = String(value).split(/[,:]/);
const start = Number(parts[0]),
end = Number(parts[1]);
if (
parts.length !== 2 ||
parts.some((p) => !p.trim()) ||
!Number.isFinite(start) ||
!Number.isFinite(end) ||
start < 0 ||
end <= start
)
throw new Error("Invalid --range: use non-negative start,end with end > start");
return [start, end];
}
const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]);
const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]);
@@ -392,6 +421,12 @@ function nonNegativeNumber(value: unknown, fallback: number): number {
}
function printHumanReport(report: CheckReport): void {
if (report.scope) {
console.log(
`Scope: ${report.scope.range ? report.scope.range.join("–") + "s" : "full timeline"}; ${report.scope.hiddenFindings} findings hidden by component filter. Counts/status include hidden findings.`,
);
for (const note of report.scope.notes) console.log(note);
}
printSection("Lint", report.lint);
if (report.browserSkipped) {
console.log();
@@ -409,10 +444,17 @@ function printHumanReport(report: CheckReport): void {
console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
}
function isEmptySection(section: CheckSection): boolean {
return (
section.findings.length === 0 &&
section.errorCount + section.warningCount + section.infoCount === 0
);
}
function printSection(title: string, section: CheckSection): void {
console.log();
console.log(c.bold(title));
if (section.findings.length === 0) {
if (isEmptySection(section)) {
console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
return;
}
@@ -423,7 +465,7 @@ function printSection(title: string, section: CheckSection): void {
function printLayoutSection(title: string, section: CheckReport["layout"]): void {
console.log();
console.log(c.bold(title));
if (section.findings.length === 0) {
if (isEmptySection(section)) {
console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`);
} else {
for (const finding of section.findings) {
@@ -447,7 +489,7 @@ function printContrastSection(report: CheckReport): void {
console.log(` ${c.dim("◇")} skipped`);
return;
}
if (section.findings.length === 0) {
if (isEmptySection(section)) {
console.log(
` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`,
);
@@ -10,6 +10,7 @@ import {
} from "../capture/captureCompositionFrame.js";
import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js";
import {
matchComponentFindings,
captureOverviewShot,
keepBrokenImageAborts,
preResolveHostileMediaProxies,
@@ -999,3 +1000,35 @@ function fakePage() {
}),
});
}
describe("component output membership", () => {
it("includes descendants and either side of a collision; keeps unresolved findings", () => {
document.body.innerHTML =
'<section id="selected"><span id="child"></span></section><section id="other"><span id="outside"></span></section>';
const finding = (selector: string, containerSelector?: string) => ({
code: "content_overlap",
severity: "error" as const,
selector,
containerSelector,
sourceFile: "index.html",
dataAttributes: {},
bbox: { x: 0, y: 0, width: 1, height: 1 },
time: 1,
message: "collision",
});
expect(
matchComponentFindings({
components: ["#selected"],
findings: [
finding("#child"),
finding("#outside", "#child"),
finding("#outside", "#other"),
finding("#gone"),
],
}),
).toEqual([true, true, false, true]);
expect(() => matchComponentFindings({ components: ["#missing"], findings: [] })).toThrow(
"matched nothing",
);
});
});
+33
View File
@@ -527,6 +527,8 @@ function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: (
function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver {
return {
initialize: (contrast) => injectAuditScripts(page, contrast),
matchComponents: (components, findings) =>
page.evaluate(matchComponentFindings, { components, findings }),
getDuration: () => getCompositionDuration(page),
hasNoTimelineDeclaration: () => hasNoTimelineDeclaration(page),
getTransitionBoundaries: () => collectTweenBoundaries(page),
@@ -1480,3 +1482,34 @@ function urlPath(url: string): string {
return url;
}
}
export function matchComponentFindings({
components,
findings,
}: {
components: string[];
findings: CheckFinding[];
}): boolean[] {
const roots = components.flatMap((selector) => {
const matches = [...document.querySelectorAll(selector)];
if (!matches.length) throw new Error(`Component selector matched nothing: ${selector}`);
return matches;
});
function matchesSelector(selector: string): boolean | null {
try {
const elements = [...document.querySelectorAll(selector)];
if (!elements.length) return null;
return elements.some((el) => roots.some((root) => root.contains(el)));
} catch {
return null;
}
}
return findings.map((finding) => {
if (!finding.selector || finding.selector === "[data-composition-id]") return true;
const container = Reflect.get(finding, "containerSelector");
return (
matchesSelector(finding.selector) !== false ||
(typeof container === "string" && matchesSelector(container) !== false)
);
});
}
+132 -27
View File
@@ -132,31 +132,74 @@ function gateSampleTimes(
return mergeSampleTimes(fractions.map((fraction) => fraction * duration));
}
function validateSampleRange([start, end]: [number, number], duration: number): void {
if (!Number.isFinite(start + end) || start < 0 || end <= start || end > duration)
throw new Error(`Check range must lie within the composition duration (${duration} seconds)`);
}
function gateSamplesInRange(
flag: "--caption-zone" | "--frame-check",
samples: number[],
inRange: (time: number) => boolean,
range: [number, number] | undefined,
): number[] {
if (!range) return samples;
const selected = samples.filter(inRange);
if (samples.length > 0 && selected.length === 0) {
throw new Error(`No ${flag} seek samples lie within --range`);
}
return selected;
}
function layoutSamplesInRange(options: CheckOptions, duration: number) {
if (!options.range)
return {
inRange: (_t: number) => true,
baseSamples: buildLayoutSampleTimes({ duration, samples: options.samples, at: options.at }),
};
validateSampleRange(options.range, duration);
const [start, end] = options.range;
const inRange = (t: number) => t >= start && t <= end;
const selected = (options.at ?? []).filter(inRange);
if (options.at?.length && !selected.length) throw new Error("No --at samples lie within --range");
const baseSamples = buildLayoutSampleTimes({
duration: end - start,
samples: options.samples,
at: selected.map((t) => t - start),
}).map((t) => t + start);
return { inRange, baseSamples };
}
async function buildSampleGrid(
driver: CheckAuditDriver,
options: CheckOptions,
): Promise<SampleGrid> {
const duration = await driver.getDuration();
const baseSamples = buildLayoutSampleTimes({
duration,
samples: options.samples,
at: options.at,
});
const { inRange, baseSamples } = layoutSamplesInRange(options, duration);
const transitions = options.atTransitions
? buildTransitionSampleTimes({
duration,
boundaries: await driver.getTransitionBoundaries(),
boundaries: (await driver.getTransitionBoundaries()).filter(inRange),
cap: options.maxTransitionSamples,
})
: { times: [], dropped: 0 };
const captionSamples = options.captionZone
? gateSampleTimes(duration, options.captionZone.seek, 1)
: [];
const frameSamples = options.frameCheck
? gateSampleTimes(duration, options.frameCheck.seek, 0.5)
: [];
const auditSamples = mergeSampleTimes(baseSamples, transitions.times);
const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples);
const captionSamples = gateSamplesInRange(
"--caption-zone",
options.captionZone ? gateSampleTimes(duration, options.captionZone.seek, 1) : [],
inRange,
options.range,
);
const frameSamples = gateSamplesInRange(
"--frame-check",
options.frameCheck ? gateSampleTimes(duration, options.frameCheck.seek, 0.5) : [],
inRange,
options.range,
);
transitions.times = transitions.times.filter(inRange);
const auditSamples = mergeSampleTimes(baseSamples, transitions.times).filter(inRange);
const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples).filter(
inRange,
);
if (layoutSamples.length === 0) {
throw new Error("Could not determine composition duration — no layout samples run");
}
@@ -433,7 +476,7 @@ async function collectGridSamples(
collected.screenshots.push({ time, pngBase64: capture.pngBase64 });
}
}
await collectMotionOverlapSamples(driver, grid, collected);
await collectMotionOverlapSamples(driver, grid, collected, options.range);
return collected;
}
@@ -459,9 +502,14 @@ async function collectMotionOverlapSamples(
driver: CheckAuditDriver,
grid: SampleGrid,
collected: GridSamples,
range?: [number, number],
): Promise<void> {
const baseTimes = new Set(grid.layoutSamples);
for (const time of buildOverlapSampleTimes(grid.duration)) {
const overlapTimes = buildOverlapSampleTimes(range ? range[1] - range[0] : grid.duration);
const shiftedTimes = range
? mergeSampleTimes(overlapTimes.map((time) => time + range[0]))
: overlapTimes;
for (const time of shiftedTimes) {
if (baseTimes.has(time)) continue;
// Settle-free seek: collectOverlap reads getBoundingClientRect geometry, valid synchronously after setTime, so the dense pass skips the per-seek paint settle.
await driver.seekGeometry(time);
@@ -1098,7 +1146,11 @@ export async function runAuditGrid(
): Promise<CheckBrowserResult> {
await driver.initialize(options.contrast);
const grid = await buildSampleGrid(driver, options);
const plan = await planMotionSampling(driver, motion, grid.duration);
const plan = await planMotionSampling(
driver,
options.range ? { kind: "none" } : motion,
grid.duration,
);
const seekLoopStart = Date.now();
const collected = await collectGridSamples(driver, options, grid, plan);
const seekLoopMs = Date.now() - seekLoopStart;
@@ -1113,18 +1165,36 @@ export async function runAuditGrid(
motionIssues = [...motionIssues, ...(await driver.anchorMotionIssues(evaluated))];
}
const userPicked = new Set(grid.userPickedSamples);
const sweepFindings = detectSweepStatic(
grid.duration,
collected.layoutStateSignatures.filter((sample) => !userPicked.has(sample.time)),
motionIssues,
await driver.hasNoTimelineDeclaration(),
);
const sweepFindings = options.range
? []
: detectSweepStatic(
grid.duration,
collected.layoutStateSignatures.filter((sample) => !userPicked.has(sample.time)),
motionIssues,
await driver.hasNoTimelineDeclaration(),
);
const rotationFindings = detectRotationPivotDrift(
collected.rotationSamples,
await driver.getCanvas(),
);
const offPivotFindings = detectOffPivotRotation(collected.indicatorFrames);
const contrast = buildContrastResults(collected.contrastEntries);
if (options.components?.length) {
if (!driver.matchComponents)
throw new Error("Component filtering unavailable in this check driver");
const findings = [
...collected.layoutIssues,
...sweepFindings,
...rotationFindings,
...offPivotFindings,
...motionIssues,
...contrast.findings,
];
const matches = await driver.matchComponents(options.components, findings);
findings.forEach((finding, i) => {
finding.componentMatch = matches[i];
});
}
return {
duration: grid.duration,
layoutSamples: grid.layoutSamples,
@@ -1413,8 +1483,11 @@ function buildReport(
hdrPromotion: CheckReport["hdr"]["autoPromotion"] = null,
hdrInspection: CheckReport["hdr"]["inspection"] = "available",
): CheckReport {
const layout = shapeLayoutSection(browser.layoutIssues, browser, options);
const shapedMotion = shapeLayoutFindings(browser.motionIssues, options);
const shaping = options.components?.length
? { ...options, maxIssues: Number.MAX_SAFE_INTEGER }
: options;
const layout = shapeLayoutSection(browser.layoutIssues, browser, shaping);
const shapedMotion = shapeLayoutFindings(browser.motionIssues, shaping);
const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings];
const runtime = section(browser.runtimeFindings);
const motionSection = section(motionFindings);
@@ -1440,7 +1513,7 @@ function buildReport(
layout,
motion: {
...motionSection,
enabled: motion.kind !== "none",
enabled: motion.kind !== "none" && !options.range,
specPath: motion.kind === "none" ? undefined : motion.path,
samples: browser.motionSampleCount,
},
@@ -1461,7 +1534,7 @@ function buildReport(
};
trackCheckReport({
contrastGate: options.contrast,
motionGate: motion.kind !== "none",
motionGate: motion.kind !== "none" && !options.range,
captionZoneGate: options.captionZone !== undefined,
frameCheckGate: options.frameCheck !== undefined,
snapshotsGate: options.snapshots,
@@ -1484,6 +1557,38 @@ function buildReport(
exitCode: checkExitCode(report),
runId: getRunId(),
});
return filterComponentReport(report, options);
}
function filterComponentReport(report: CheckReport, options: CheckOptions): CheckReport {
if (!options.range && !options.components?.length) return report;
{
report.scope = {
range: options.range,
components: options.components,
hiddenFindings: 0,
notes: [
"Static lint and unscoped runtime failures remain project-wide.",
...(options.range
? [
"Whole-timeline motion assertions and frozen-sweep detection are skipped for range checks.",
]
: []),
],
};
for (const group of [report.layout, report.motion, report.contrast]) {
const before = group.findings.length;
group.findings = group.findings.filter((f) => f.componentMatch !== false);
report.scope.hiddenFindings += before - group.findings.length;
if (group !== report.contrast && group.findings.length > options.maxIssues) {
group.findings = group.findings.slice(0, options.maxIssues);
report.scope.notes.push(
"Selected findings exceed --max-issues; increase it to see the remainder.",
);
if (group === report.layout) report.layout.truncated = true;
}
}
}
return report;
}
+10
View File
@@ -8,6 +8,8 @@ import type { HdrAutoPromotion } from "@hyperframes/engine";
export interface CheckOptions {
samples: number;
range?: [number, number];
components?: string[];
at?: number[];
atTransitions: boolean;
maxTransitionSamples?: number;
@@ -70,6 +72,7 @@ export interface CheckAnchor {
bbox: CheckBbox;
time: number;
times?: number[];
componentMatch?: boolean;
}
export interface CheckFinding extends CheckAnchor {
@@ -186,6 +189,7 @@ export type MotionSpecResolution =
export interface CheckAuditDriver {
initialize(contrast: boolean): Promise<void>;
matchComponents?(components: string[], findings: CheckFinding[]): Promise<boolean[]>;
getDuration(): Promise<number>;
/** True when the root composition explicitly declares that it is intentionally timeline-free. */
hasNoTimelineDeclaration(): Promise<boolean>;
@@ -271,6 +275,12 @@ export interface CheckSection<T extends CheckFinding = CheckFinding> {
}
export interface CheckReport {
scope?: {
range?: [number, number];
components?: string[];
hiddenFindings: number;
notes: string[];
};
ok: boolean;
strict: boolean;
browserSkipped: boolean;