Files
hyperframes/packages/cli/src/commands/motion-sample.browser.test.ts
T
Miguel Ángel 9175eced45 feat(cli): declarative motion verification in inspect (#1437) (#1459)
Extend `inspect` to verify motion intent against the same seeked timeline
the renderer uses, catching render-≠-preview bugs that layout sampling can't:
entrance reveals the seek skips, broken stagger order, off-frame drift, and
frozen shots.

A `*.motion.json` sidecar next to the composition opts in (auto-discovered,
no flag, no authoring-framework changes); without one, inspect is unchanged.
inspect seeks a dense grid over the asserted selectors, builds an
element × time matrix of {rect, opacity, visible} plus per-scope liveness
signatures, and evaluates four assertions in Node:

  appearsBy    -> motion_appears_late
  before       -> motion_out_of_order
  staysInFrame -> motion_off_frame
  keepsMoving  -> motion_frozen

A selector matching nothing is reported as motion_selector_missing rather
than silently passing. Findings reuse the LayoutIssue shape and flow through
the existing dedupe/collapse/limit/format pipeline and JSON envelope; they
are errors by default, so a failed assertion fails the run.

The motion pass runs in the same Chrome session as the layout audit (no extra
launch) and only when a sidecar is present.
2026-06-15 16:29:11 -04:00

132 lines
4.6 KiB
TypeScript

// @vitest-environment happy-dom
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const script = readFileSync(join(__dirname, "motion-sample.browser.js"), "utf-8");
interface Geo {
rect?: { left: number; top: number; width: number; height: number };
opacity?: string;
display?: string;
visibility?: string;
}
interface SampleResult {
data: Record<
string,
{ rect: { left: number; right: number }; opacity: number; visible: boolean } | null
>;
liveness: Record<string, string>;
}
function installGeometry(byId: Record<string, Geo>): void {
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const geo = byId[(element as Element).id] ?? {};
return {
display: geo.display ?? "block",
visibility: geo.visibility ?? "visible",
opacity: geo.opacity ?? "1",
} as unknown as CSSStyleDeclaration;
});
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
const geo = byId[this.id]?.rect ?? { left: 0, top: 0, width: 0, height: 0 };
return {
left: geo.left,
top: geo.top,
right: geo.left + geo.width,
bottom: geo.top + geo.height,
width: geo.width,
height: geo.height,
} as DOMRect;
});
}
function installScript(): void {
// eslint-disable-next-line no-new-func
new Function(script)();
}
function sample(options: { selectors?: string[]; livenessScopes?: string[] }): SampleResult {
const fn = (window as unknown as { __hyperframesMotionSample: (o: unknown) => SampleResult })
.__hyperframesMotionSample;
return fn(options);
}
describe("motion-sample.browser", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (window as unknown as { __hyperframesMotionSample?: unknown }).__hyperframesMotionSample;
});
it("samples a present, visible selector and returns null for an absent one", () => {
document.body.innerHTML = `
<div data-composition-id="main"><div id="headline">Hi</div></div>
`;
installGeometry({
headline: { rect: { left: 100, top: 50, width: 300, height: 80 }, opacity: "1" },
});
installScript();
const result = sample({ selectors: ["#headline", "#missing"] });
expect(result.data["#headline"]).toMatchObject({ visible: true, opacity: 1 });
expect(result.data["#headline"]?.rect).toMatchObject({ left: 100, right: 400 });
expect(result.data["#missing"]).toBeNull();
});
it("reflects inherited ancestor opacity", () => {
document.body.innerHTML = `
<div data-composition-id="main"><div id="wrap"><div id="headline">Hi</div></div></div>
`;
installGeometry({
wrap: { rect: { left: 0, top: 0, width: 400, height: 200 }, opacity: "0.5" },
headline: { rect: { left: 100, top: 50, width: 300, height: 80 }, opacity: "0.6" },
});
installScript();
const result = sample({ selectors: ["#headline"] });
expect(result.data["#headline"]?.opacity).toBeCloseTo(0.3, 5);
});
it("produces a different liveness signature when an element moves and an identical one when static", () => {
document.body.innerHTML = `<div data-composition-id="main"><div id="box">x</div></div>`;
installGeometry({ box: { rect: { left: 100, top: 100, width: 50, height: 50 } } });
installScript();
const before = sample({ livenessScopes: ["*"] }).liveness["*"];
vi.restoreAllMocks();
installGeometry({ box: { rect: { left: 100, top: 100, width: 50, height: 50 } } });
installScript();
const stillStatic = sample({ livenessScopes: ["*"] }).liveness["*"];
vi.restoreAllMocks();
installGeometry({ box: { rect: { left: 300, top: 100, width: 50, height: 50 } } });
installScript();
const moved = sample({ livenessScopes: ["*"] }).liveness["*"];
expect(stillStatic).toBe(before);
expect(moved).not.toBe(before);
});
it("scopes liveness to a withinSelector and returns empty for a missing scope", () => {
document.body.innerHTML = `
<div data-composition-id="main"><div id="scene"><div id="box">x</div></div></div>
`;
installGeometry({
scene: { rect: { left: 0, top: 0, width: 500, height: 500 } },
box: { rect: { left: 10, top: 10, width: 50, height: 50 } },
});
installScript();
const result = sample({ livenessScopes: ["#scene", "#nope"] });
expect((result.liveness["#scene"] ?? "").length).toBeGreaterThan(0);
expect(result.liveness["#nope"]).toBe("");
});
});