Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions packages/cli/src/utils/checkBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1380,3 +1380,156 @@ describe("component output membership", () => {
);
});
});

describe("motion blur findings in the check output (T6)", () => {
type Handler = (message: ReturnType<typeof fakeConsoleMessage>) => 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(`<p id="copy" data-layout-name="copy">Hi</p>`);
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 = `
<div id="aux" data-composition-id="aux" data-duration="4"></div>
<div id="real" data-composition-id="main" data-root="true" data-duration="10"></div>`;
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([]);
});
});
22 changes: 15 additions & 7 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -81,6 +82,7 @@ interface RuntimeDraft {
line?: number;
count?: number;
abortedImage?: boolean;
fixHint?: string;
}

type RuntimeStamp = Pick<RuntimeDraft, "time" | "timeRange">;
Expand Down Expand Up @@ -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);
Expand All @@ -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)),
};
Expand Down Expand Up @@ -1352,20 +1356,23 @@ function anchorsInPage(values: AnchorRequest[]): CheckAnchor[] {
}

async function resolveRootAnchor(page: Page): Promise<CheckAnchor> {
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<CheckBbox> {
return page.evaluate(() => {
const element = document.querySelector("[data-composition-id]");
const rect = element?.getBoundingClientRect();
async function compositionBbox(page: Page, selector: string): Promise<CheckBbox> {
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(
Expand Down Expand Up @@ -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 } : {}),
};
}

Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/checkMotionBlur.page.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<div data-composition-id="main" data-motion-blur="on"></div>';
const TWO_COMPOSITIONS =
'<div data-composition-id="empty"></div>' +
'<div data-composition-id="main" data-root="true" data-motion-blur="on"></div>';

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('<div data-composition-id="main"></div>', false);
expect(readMotionBlurProbe()).toBeNull();
});
});
75 changes: 75 additions & 0 deletions packages/cli/src/utils/checkMotionBlur.test.ts
Original file line number Diff line number Diff line change
@@ -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", /<iframe>, <object>, <embed> or custom element out of the composition root/],
["root-transform", /child element instead of the composition root/],
["root-opacity", /child element instead of the composition root/],
["root-blend-mode", /mix-blend-mode on a child element/],
["nested-capture-canvas", /inner <canvas layoutsubtree>/],
["something-new", /drop data-motion-blur/],
])("warns with a concrete fix for %s", (code, hint) => {
const [finding, ...rest] = motionBlurDrafts(off(code, "the reason text"));
expect(rest).toEqual([]);
expect(finding).toMatchObject({
code: "motion_blur_off",
severity: "warning",
message: 'data-motion-blur="on" is set but blur is off: the reason text',
});
expect(finding?.fixHint).toMatch(hint);
});

it("only informs, once, when the browser cannot run html-in-canvas", () => {
const drafts = motionBlurDrafts(off("unsupported-browser", "html-in-canvas is unavailable"));
expect(drafts).toHaveLength(1);
expect(drafts[0]).toMatchObject({ code: "motion_blur_unverified", severity: "info" });
expect(drafts[0]?.message).toContain("could not be evaluated in this browser");
});

it("informs when blur is on but nothing moves", () => {
expect(
motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 0 }),
).toMatchObject([{ code: "motion_blur_no_movers", severity: "info" }]);
});
});

describe("dropMotionBlurConsoleEcho", () => {
const echo = { code: "console_warning", message: "[motion-blur] off: because" };
const other = { code: "console_warning", message: "something else" };
const found = motionBlurDrafts(off("root-transform"));

it("drops the runtime's own off-line once a finding says it", () => {
expect(dropMotionBlurConsoleEcho([echo, other], found)).toEqual([other]);
});

it("leaves every console warning alone when there is no finding", () => {
expect(dropMotionBlurConsoleEcho([echo, other], [])).toEqual([echo, other]);
});
});
Loading
Loading