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
15 changes: 9 additions & 6 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -712,11 +712,14 @@ background colours, the measured ratio against the required one, and a
compliant colour you could use instead.

Severity is persistence-aware: a finding at a single sample demotes to info, a
finding that persists gates the exit code, and a timeline that never moves on a
composition of 3s or more fails with `sweep_static`. When only audio advanced
and nothing on screen moved, `sweep_static` is a warning instead (`--strict`
fails the run on it); add `data-no-timeline` to a composition that is meant to
be still.
finding that persists gates the exit code, and a composition of 3s or more
fails with `sweep_static` when its frame never changes and a GSAP timeline or
tween, or a CSS or Web animation, on the page held the same time without ever
finishing, so the seek never drove it.
Any other frame that never changes, such as a still title card, is a
`sweep_static` warning ("Nothing on screen moved under seek"), and so is an
audio-only advance; `--strict` fails the run on either. A composition that is
meant to be still can declare `data-no-timeline` to skip the guard.

{/* VISUAL: a `check --snapshots` overview frame beside one finding crop, with the labelled finding box visible on both */}

Expand Down Expand Up @@ -770,7 +773,7 @@ borders and shadows, the pixels of visible canvas/video/img elements, and video
playback time all count as motion — so a playing background video keeps a scope
live on its own, even a color-graded one whose picture is drawn into a canvas.
Audio playback time never counts here: it shows the timeline ran, but it is not
a moving picture. The frozen-sweep guard turns an audio-only advance into a
a moving picture. The frozen-sweep guard reports an audio-only advance as a
warning rather than a failure. Elements under
`data-layout-ignore` inside the scope never count; the opt-out does not apply to
the scope element itself, since naming it in an assertion outranks it. Content
Expand Down
65 changes: 64 additions & 1 deletion packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
LayoutOverflow,
LayoutRect,
} from "../utils/layoutAudit.js";
import type { SeekClock } from "../utils/checkTypes.js";
import type { ProjectDir } from "../utils/project.js";

const PROJECT: ProjectDir = {
Expand Down Expand Up @@ -155,6 +156,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
collectOverlap: vi.fn(async (_time: number) => []),
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
collectSeekClock: vi.fn(async () => [{ id: 1, time: 0, done: false }]),
collectRotationSample: vi.fn(async (_time: number) => []),
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
collectGeometryCandidates: vi.fn(async () => []),
Expand Down Expand Up @@ -1528,11 +1530,72 @@ describe("check pipeline", () => {
finding.code === "sweep_static" &&
finding.severity === "error" &&
finding.message.includes("did not advance") &&
finding.fixHint?.includes("data-no-timeline"),
finding.fixHint?.includes("window.__timelines"),
),
).toBe(true);
});

function stillCard(clocks: (sample: number) => SeekClock[]) {
let sample = 0;
return fakeDriver({
getDuration: vi.fn(async () => 4),
collectLayoutGeometry: vi.fn(async () => "frozen"),
collectSeekClock: vi.fn(async () => clocks(sample++)),
});
}

function sweepOf(report: CheckReport): [string, string][] {
return report.layout.findings
.filter((finding) => finding.code === "sweep_static")
.map((finding) => [finding.severity, finding.message]);
}

it.each([
[
"whose timeline follows the seek",
(sample: number) => [{ id: 1, time: sample, done: false }],
],
["with nothing that could move", () => []],
["whose only animation already holds at its end", () => [{ id: 1, time: 4, done: true }]],
[
"whose timeline is seen, listed twice, at only one sample",
(sample: number) =>
sample === 2
? [
{ id: 1, time: 0, done: false },
{ id: 1, time: 0, done: false },
]
: [],
],
])("warns, without failing, on a still card %s", async (_case, clocks) => {
const { report } = await runScenario(stillCard(clocks));

expect(sweepOf(report)).toEqual([["warning", "Nothing on screen moved under seek."]]);
expect(report.ok).toBe(true);
});

it.each([
[
"one animation is stuck while another follows the seek",
(sample: number) => [
{ id: 1, time: 0, done: false },
{ id: 2, time: sample, done: false },
],
],
[
"a tween created mid-run is stuck",
(sample: number) => [
{ id: 1, time: sample, done: false },
...(sample >= 2 ? [{ id: 2, time: 0, done: false }] : []),
],
],
])("fails when %s", async (_case, clocks) => {
const { report } = await runScenario(stillCard(clocks));

expect(sweepOf(report).map(([severity]) => severity)).toEqual(["error"]);
expect(report.ok).toBe(false);
});

it("warns, without failing, when only the audio advanced and nothing on screen moved", async () => {
let call = 0;
const driver = fakeDriver({
Expand Down
126 changes: 126 additions & 0 deletions packages/cli/src/utils/checkBrowser.chromium.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import { collectSeekClock } from "./checkBrowser.js";

const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;

// Objects with GSAP's total*() and reversed() shape; GSAP itself is not a repo dependency.
const PAGE = `<body>
<style>
@keyframes slide { to { transform: translateX(100px) } }
#forever { animation: slide 1s linear infinite paused }
#scrolled { animation: slide linear both; animation-timeline: scroll(root) }
#viewed { animation: slide linear both; animation-timeline: view() }
</style>
<div id="forever"></div>
<div id="scrolled"></div>
<div id="viewed"></div>
<div id="reverse"></div>
<div id="held"></div>
<div id="parked"></div>
<div id="rewound"></div>
<script>
const clock = (total, at, reversed = false) => {
let now = at;
return {
totalDuration: () => total,
totalTime: () => now,
totalProgress: () => now / total,
reversed: () => reversed,
seek: (t) => (now = t),
};
};
window.registered = clock(4, 0);
window.__timelines = { main: window.registered, empty: clock(0, 0) };
// A yoyo that finished: its one-iteration time() is back at 0, its totalTime() is at the end.
const finishedYoyo = clock(0.2, 0.2);
const finishedReversed = clock(4, 0, true);
window.gsap = { globalTimeline: { getChildren: () => [finishedYoyo, finishedReversed] } };
const reverse = document.getElementById("reverse").animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 9000, fill: "both" },
);
reverse.playbackRate = -1;
reverse.finish();
// Paused at its end, the way a seek leaves it: never "finished", still done.
const held = document.getElementById("held").animate([{ opacity: 0 }, { opacity: 1 }], { duration: 3000, fill: "forwards" });
held.pause();
held.currentTime = 3000;
// Reversed and paused: at its end it has not started, at 0 it is complete.
const backwards = (id, at) => {
const animation = document.getElementById(id).animate([{ opacity: 0 }, { opacity: 1 }], { duration: 3000, fill: "both" });
animation.playbackRate = -1;
animation.pause();
animation.currentTime = at;
};
backwards("parked", 3000);
backwards("rewound", 0);
// A registered timeline with only the one-iteration methods, which the runtime accepts.
window.__timelines.partial = { duration: () => 4, time: () => 1 };
window.__timelines.partialEnded = { duration: () => 4, time: () => 4 };
// Seekable but with no way to read its time: not evidence either way.
window.__timelines.seekOnly = { duration: () => 4, seek: () => {} };
// Plain values where GSAP has methods must not stop the read.
window.__timelines.plainDuration = { duration: 4, seek: () => {} };
window.__timelines.plainReversed = { totalDuration: () => 4, totalTime: () => 4, reversed: false };
</script>
</body>`;

describe.runIf(executablePath)("collectSeekClock in Chromium", () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await puppeteer.launch({ executablePath, args: ["--no-sandbox"] });
});
afterAll(async () => {
await browser?.close();
});

async function seekTo(time: number) {
await page.evaluate((t) => {
Reflect.get(window, "registered").seek(t);
const forever = document.getElementById("forever")?.getAnimations()[0];
if (forever) forever.currentTime = t * 1000;
}, time);
return collectSeekClock(page);
}

it("marks finished clocks done, an infinite one never, and keeps ids across samples", async () => {
page = await browser.newPage();
await page.setContent(PAGE);

const first = await seekTo(1);
await page.evaluate(() => {
const late = { totalDuration: () => 2, totalTime: () => 0, totalProgress: () => 0 };
Reflect.set(window, "__timelines", { late, ...Reflect.get(window, "__timelines") });
});
const second = await seekTo(2);

expect(first.map(({ time, done }) => [time, done])).toEqual([
[1, false],
[1, false],
[4, true],
[4, true],
[0.2, true],
[0, true],
[1000, false],
[0, true],
[3000, true],
[3000, false],
[0, true],
]);
const [late, ...rest] = second;
expect(rest.map(({ id }) => id)).toEqual(first.map(({ id }) => id));
expect(first.map(({ id }) => id)).not.toContain(late?.id);
expect(second.map(({ time }) => time)).toEqual([0, 2, 1, 4, 4, 0.2, 0, 2000, 0, 3000, 3000, 0]);
await page.close();
});

it("reports nothing on a page with no animations", async () => {
page = await browser.newPage();
await page.setContent("<body><h1>Hello</h1></body>");

expect(await collectSeekClock(page)).toEqual([]);
await page.close();
});
});
54 changes: 54 additions & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
OffPivotRotationSample,
RotationSample,
RunAuditGrid,
SeekClock,
} from "./checkTypes.js";
import type { ProjectDir } from "./project.js";

Expand Down Expand Up @@ -461,6 +462,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
collectLayout: (time, tolerance, layout) => collectLayout(page, time, tolerance, layout),
collectOverlap: (time) => collectOverlap(page, time),
collectLayoutGeometry: () => collectLayoutGeometry(page),
collectSeekClock: () => collectSeekClock(page),
collectRotationSample: (time) => collectRotationSample(page, time),
collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time),
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
Expand Down Expand Up @@ -612,6 +614,58 @@ async function collectLayoutGeometry(page: Page): Promise<string> {
});
}

export async function collectSeekClock(page: Page): Promise<SeekClock[]> {
// Serialized into the page; each optional GSAP read is one branch of one function.
// fallow-ignore-next-line complexity
return page.evaluate(() => {
const callOrUndefined = (target: unknown, key: string, args: unknown[] = []): unknown => {
try {
return Reflect.apply(Reflect.get(Object(target), key), target, args);
} catch {
return undefined;
}
};
const known: { ids: WeakMap<object, number>; next: number } = Reflect.get(
window,
"__hfSeekClocks",
) ?? {
ids: new WeakMap(),
next: 0,
};
Reflect.set(window, "__hfSeekClocks", known);
const idOf = (target: object): number => {
if (!known.ids.has(target)) known.ids.set(target, ++known.next);
return known.ids.get(target) ?? 0;
};
const clocks: SeekClock[] = [];
const timelines: unknown = Reflect.get(window, "__timelines");
const registered = typeof timelines === "object" && timelines ? Object.values(timelines) : [];
const gsapRoot = Reflect.get(Reflect.get(window, "gsap") ?? {}, "globalTimeline");
const children = callOrUndefined(gsapRoot, "getChildren", [false, true, true]);
for (const timeline of [...registered, ...(Array.isArray(children) ? children : [])]) {
const total = Number(
callOrUndefined(timeline, "totalDuration") ?? callOrUndefined(timeline, "duration"),
);
if (typeof timeline !== "object" || !timeline || !(total > 0)) continue;
const time = Number(
callOrUndefined(timeline, "totalTime") ?? callOrUndefined(timeline, "time"),
);
if (!Number.isFinite(time)) continue;
const progress = Number(callOrUndefined(timeline, "totalProgress") ?? time / total);
const done = callOrUndefined(timeline, "reversed") === true ? progress <= 0 : progress >= 1;
clocks.push({ id: idOf(timeline), time, done });
}
for (const animation of document.getAnimations?.() ?? []) {
if (typeof animation.currentTime !== "number") continue;
const time = animation.currentTime;
const end = Number(animation.effect?.getComputedTiming().endTime ?? Number.POSITIVE_INFINITY);
const atEnd = animation.playbackRate < 0 ? time <= 0 : time >= end;
clocks.push({ id: idOf(animation), time, done: animation.playState === "finished" || atEnd });
}
return clocks;
});
}

/** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js
* and return its array result (or [] when absent / non-array). Shared by the
* per-frame sample collectors so the page.evaluate boilerplate lives once. */
Expand Down
Loading
Loading