From ead11996fbb6c5cc2d8b0029122ea9b41956e372 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 20 Oct 2026 18:23:59 -0700 Subject: [PATCH] feat(cli): tell authors in check why motion blur is off Blur that turns itself off is silent in the output, so an author who asked for data-motion-blur="on" cannot tell. Have hyperframes check read the runtime's decision through probe() and the reason codes from the previous change. check reports motion_blur_off as a warning with a fix hint for a root that asked for blur and got none (an iframe in the root gets its own hint), motion_blur_no_movers when nothing moves enough to blur, and motion_blur_unverified when the check browser has no html-in-canvas to decide with. The console echo of the same message is dropped so a finding is not shown twice. check asks the runtime which root wants blur instead of reading attributes itself, so data-root="true" is honoured, and after probing it suspends blur painting for the rest of the session: its audits read plain DOM, which removes the roughly 10x slowdown of auditing a wrapped composition. It never sets the page's off switch, which would change the probe's answer. Tests drive a finding from the page to the check output, with its fix and without its echo. Folded review fixes: R9 (check hint), R12, R13 (check half), T6. Co-Authored-By: Claude Sonnet 5.5 --- packages/cli/src/utils/checkBrowser.test.ts | 153 ++++++++++++++++++ packages/cli/src/utils/checkBrowser.ts | 22 ++- .../src/utils/checkMotionBlur.page.test.ts | 65 ++++++++ .../cli/src/utils/checkMotionBlur.test.ts | 75 +++++++++ packages/cli/src/utils/checkMotionBlur.ts | 116 +++++++++++++ 5 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/utils/checkMotionBlur.page.test.ts create mode 100644 packages/cli/src/utils/checkMotionBlur.test.ts create mode 100644 packages/cli/src/utils/checkMotionBlur.ts diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index aaf70343c7b..7e898b3c9ed 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1380,3 +1380,156 @@ describe("component output membership", () => { ); }); }); + +describe("motion blur findings in the check output (T6)", () => { + type Handler = (message: ReturnType) => void; + const ACCELERATED = { + state: "off", + reason: "a WebGL canvas cannot be captured", + reasonCode: "accelerated-canvas", + movers: 0, + }; + + afterEach(() => { + Reflect.deleteProperty(window, "__hfMotionBlur"); + }); + + async function checkWith( + report: object, + options: { cores?: number; echo?: [string, string][]; mount?: () => void } = {}, + ) { + vi.mocked(os.availableParallelism).mockReturnValue(options.cores ?? 1); + mountCanvasFixture(`

Hi

`); + options.mount?.(); + const handlers: Handler[] = []; + const log = (type: string, text: string) => + handlers.forEach((handler) => handler(fakeConsoleMessage(type, text))); + Reflect.set(window, "__hfMotionBlur", { + probe: () => { + for (const [type, text] of options.echo ?? []) log(type, text); + return report; + }, + suspend: vi.fn(), + }); + const browser = Object.assign(Object.create(null), { + close: vi.fn(async () => undefined), + process: () => ({ exitCode: 0, signalCode: null }), + }); + let opened = 0; + vi.mocked(openSettledCompositionPage).mockImplementation( + async (_html: string, _url: string, settle: OpenSettledCompositionPageOptions) => { + const page = fakePage(); + const first = ++opened === 1; + page.on = vi.fn((event: string, handler: Handler) => { + if (event === "console" && first) handlers.push(handler); + }); + settle.onBrowser?.(browser); + await settle.beforeNavigate?.(page); + return { page, browser, renderReadyTimedOut: false }; + }, + ); + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, contrast: false }, + { kind: "none" }, + runAuditGrid, + ); + return { findings: result.runtimeFindings, pages: opened }; + } + const blurCodes = (findings: { code: string }[]) => + findings.map((finding) => finding.code).filter((code) => code.startsWith("motion_blur")); + + it("carries a blur finding, its fix and its anchor into the output", async () => { + const { findings } = await checkWith(ACCELERATED); + const [finding, ...rest] = findings.filter(({ code }) => code === "motion_blur_off"); + expect(rest).toEqual([]); + expect(finding).toMatchObject({ + severity: "warning", + message: expect.stringContaining("a WebGL canvas cannot be captured"), + selector: expect.any(String), + fixHint: expect.stringMatching(/WebGL\/WebGPU.*sub-composition/), + }); + }); + + it("anchors the finding on the root the runtime picks, not the first composition (W4)", async () => { + const mount = (): void => { + document.body.innerHTML = ` +
+
`; + const rect = { x: 3, y: 4, width: 640, height: 360 } as DOMRect; + const real = document.getElementById("real"); + if (real) real.getBoundingClientRect = () => rect; + }; + const { findings } = await checkWith(ACCELERATED, { mount }); + expect(findings.find(({ code }) => code === "motion_blur_off")).toMatchObject({ + selector: '[data-composition-id][data-root="true"]', + dataAttributes: expect.objectContaining({ + "data-root": "true", + "data-composition-id": "main", + }), + bbox: { x: 3, y: 4, width: 640, height: 360 }, + }); + }); + + it("still carries it, once, when a second Chrome shares the sampling", async () => { + const { findings, pages } = await checkWith(ACCELERATED, { cores: 4 }); + expect(pages).toBe(2); + expect(blurCodes(findings)).toEqual(["motion_blur_off"]); + }); + + it("keeps an unrelated console warning and drops the runtime's duplicate of the finding", async () => { + const { findings } = await checkWith(ACCELERATED, { + echo: [ + ["warn", "[motion-blur] off: a WebGL canvas cannot be captured"], + ["warn", "your own warning"], + ], + }); + const warnings = findings.filter(({ code }) => code === "console_warning"); + expect(warnings.map(({ message }) => message)).toEqual(["your own warning"]); + expect(blurCodes(findings)).toEqual(["motion_blur_off"]); + }); + + it("keeps the runtime's off-line when there is no finding to replace it", async () => { + const { findings } = await checkWith( + { state: "on", reason: null, reasonCode: null, movers: 2 }, + { echo: [["warn", "[motion-blur] off: later, for a reason the probe did not see"]] }, + ); + expect(findings.map(({ code }) => code)).toContain("console_warning"); + expect(blurCodes(findings)).toEqual([]); + }); + + it("does not drop an error that merely starts like the echo", async () => { + const { findings } = await checkWith(ACCELERATED, { + echo: [["error", "[motion-blur] off: thrown by the page itself"]], + }); + expect(findings.map(({ code }) => code)).toContain("console_error"); + }); + + it("says nothing moves only when the report says no mover, not when it says some", async () => { + const still = await checkWith({ state: "on", reason: null, reasonCode: null, movers: 0 }); + expect(blurCodes(still.findings)).toEqual(["motion_blur_no_movers"]); + const moving = await checkWith({ state: "on", reason: null, reasonCode: null, movers: 3 }); + expect(blurCodes(moving.findings)).toEqual([]); + const off = await checkWith(ACCELERATED); + expect(blurCodes(off.findings)).toEqual(["motion_blur_off"]); + }); + + it("treats a report without a mover count as no movers", async () => { + const { findings } = await checkWith({ state: "on", reason: null, reasonCode: null }); + expect(blurCodes(findings)).toEqual(["motion_blur_no_movers"]); + }); + + it("adds nothing for a page that never opted in", async () => { + vi.mocked(os.availableParallelism).mockReturnValue(1); + mountCanvasFixture(); + const page = fakePage(); + installSessionMock(page); + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false }, + { kind: "none" }, + runAuditGrid, + ); + expect(blurCodes(result.runtimeFindings)).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 40fb809790d..6751963ab18 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -1,5 +1,6 @@ import type { CheckDeadline } from "./checkDeadline.js"; import { BrowserCleanupError, closeManagedBrowser } from "../browser/launch.js"; +import { collectMotionBlurDrafts, dropMotionBlurConsoleEcho } from "./checkMotionBlur.js"; import { checkSnapshotDirectory } from "./checkSnapshotDirectory.js"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import os from "node:os"; @@ -81,6 +82,7 @@ interface RuntimeDraft { line?: number; count?: number; abortedImage?: boolean; + fixHint?: string; } type RuntimeStamp = Pick; @@ -297,6 +299,8 @@ export async function runBrowserCheck( const message = tweenTailMessage(tail); drafts.push({ code: "gsap_tween_tail", severity: "warning", message, time: tail.later.end }); } + const blur = await collectMotionBlurDrafts(page); + drafts.push(...blur); const sampleLog = sampleWindow(drafts); const driver = createPageDriver(page, clock, sampleLog); const result = await runGrid(driver, options, motion, helpers); @@ -308,7 +312,7 @@ export async function runBrowserCheck( ...result, timings: { ...result.timings, launchSettleMs }, runtimeFindings: dropFailedProxyEchoes( - keepBrokenImageAborts(merged, broken), + dropMotionBlurConsoleEcho(keepBrokenImageAborts(merged, broken), blur), failedPaths, ).map((draft) => runtimeFinding(draft, rootAnchor)), }; @@ -1352,20 +1356,23 @@ function anchorsInPage(values: AnchorRequest[]): CheckAnchor[] { } async function resolveRootAnchor(page: Page): Promise { + const selector = await page.evaluate( + (explicit) => (document.querySelector(explicit) ? explicit : "[data-composition-id]"), + '[data-composition-id][data-root="true"]', + ); const anchors = await resolveAnchors(page, [ - { selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) }, + { selector, time: 0, bbox: await compositionBbox(page, selector) }, ]); return anchors[0] ?? fallbackAnchor(undefined); } -async function compositionBbox(page: Page): Promise { - return page.evaluate(() => { - const element = document.querySelector("[data-composition-id]"); - const rect = element?.getBoundingClientRect(); +async function compositionBbox(page: Page, selector: string): Promise { + return page.evaluate((root) => { + const rect = document.querySelector(root)?.getBoundingClientRect(); return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : { x: 0, y: 0, width: 0, height: 0 }; - }); + }, selector); } async function collectContrast( @@ -1835,6 +1842,7 @@ function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding { ...(draft.timeRange ? { timeRange: draft.timeRange } : {}), url: draft.url, line: draft.line, + ...(draft.fixHint ? { fixHint: draft.fixHint } : {}), }; } diff --git a/packages/cli/src/utils/checkMotionBlur.page.test.ts b/packages/cli/src/utils/checkMotionBlur.page.test.ts new file mode 100644 index 00000000000..1800d4926d0 --- /dev/null +++ b/packages/cli/src/utils/checkMotionBlur.page.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readMotionBlurProbe } from "./checkMotionBlur.js"; + +const report = { state: "off", reason: "why", reasonCode: "root-transform", movers: 3 }; + +function page(html: string, withApi = true, order: string[] = []) { + document.body.innerHTML = html; + const probe = vi.fn(() => { + order.push("probe"); + return report; + }); + const suspend = vi.fn(() => order.push("suspend")); + if (withApi) Reflect.set(window, "__hfMotionBlur", { probe, suspend }); + return Object.assign(probe, { suspend }); +} + +afterEach(() => { + Reflect.deleteProperty(window, "__hfMotionBlur"); + document.body.innerHTML = ""; +}); + +const MAIN = '
'; +const TWO_COMPOSITIONS = + '
' + + '
'; + +describe("readMotionBlurProbe", () => { + it("calls the runtime probe and returns its report", () => { + const probe = page(MAIN); + expect(readMotionBlurProbe()).toEqual(report); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("asks the runtime, not the first composition's attribute, which root wants blur", () => { + const probe = page(TWO_COMPOSITIONS); + expect(readMotionBlurProbe()).toEqual(report); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("suspends blur painting right after the probe, so the audits read plain DOM", () => { + const order: string[] = []; + page(MAIN, true, order); + readMotionBlurProbe(); + expect(order).toEqual(["probe", "suspend"]); + }); + + it("still reads the probe from a runtime that has no suspend", () => { + document.body.innerHTML = MAIN; + Reflect.set(window, "__hfMotionBlur", { probe: () => report }); + expect(readMotionBlurProbe()).toEqual(report); + }); + + it("takes the mover count from the report, and reads a missing one as none", () => { + Reflect.set(window, "__hfMotionBlur", { probe: () => ({ state: "on", movers: 7 }) }); + expect(readMotionBlurProbe()).toMatchObject({ movers: 7 }); + Reflect.set(window, "__hfMotionBlur", { probe: () => ({ state: "on" }) }); + expect(readMotionBlurProbe()).toMatchObject({ state: "on", movers: 0, reason: null }); + }); + + it("reads nothing when the runtime installed no instance, as for a composition that never opted in", () => { + page('
', false); + expect(readMotionBlurProbe()).toBeNull(); + }); +}); diff --git a/packages/cli/src/utils/checkMotionBlur.test.ts b/packages/cli/src/utils/checkMotionBlur.test.ts new file mode 100644 index 00000000000..5f438f4ab4e --- /dev/null +++ b/packages/cli/src/utils/checkMotionBlur.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + type MotionBlurProbe, + dropMotionBlurConsoleEcho, + motionBlurDrafts, +} from "./checkMotionBlur.js"; + +const off = (reasonCode: string | null, reason = "because"): MotionBlurProbe => ({ + state: "off", + reason, + reasonCode, + movers: 0, +}); + +describe("motionBlurDrafts", () => { + it("says nothing without an opt-in to read, or when blur works", () => { + expect(motionBlurDrafts(null)).toEqual([]); + expect(motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 2 })).toEqual( + [], + ); + expect(motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 1 })).toEqual( + [], + ); + }); + + it.each([ + [ + "accelerated-canvas", + /in sub-compositions too.*render that effect separately.*drop data-motion-blur/, + ], + ["backface-visibility", /mirrored.*3D flip/], + ["iframe-in-root", /