Files
hyperframes/packages/cli/src/utils/motionSpec.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

169 lines
6.1 KiB
TypeScript

import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findMotionSpec, parseMotionSpec, readMotionSpec, type MotionSpec } from "./motionSpec.js";
const RFC_SPEC = {
duration: 6,
assertions: [
{ kind: "appearsBy", selector: "#headline", bySec: 0.5 },
{ kind: "before", a: "#headline", b: "#cta" },
{ kind: "staysInFrame", selector: ".card" },
{ kind: "keepsMoving", withinSelector: ".scene" },
],
};
function expectOk(result: ReturnType<typeof parseMotionSpec>): MotionSpec {
if (!result.ok) throw new Error(`expected ok, got errors: ${result.errors.join(", ")}`);
return result.spec;
}
describe("parseMotionSpec", () => {
it("parses the RFC four-assertion spec", () => {
const spec = expectOk(parseMotionSpec(RFC_SPEC));
expect(spec.duration).toBe(6);
expect(spec.assertions).toHaveLength(4);
expect(spec.assertions[0]).toEqual({ kind: "appearsBy", selector: "#headline", bySec: 0.5 });
expect(spec.assertions[3]).toEqual({ kind: "keepsMoving", withinSelector: ".scene" });
});
it("allows a missing duration", () => {
const spec = expectOk(
parseMotionSpec({ assertions: [{ kind: "staysInFrame", selector: ".card" }] }),
);
expect(spec.duration).toBeUndefined();
});
it("rejects an unknown assertion kind", () => {
const result = parseMotionSpec({ assertions: [{ kind: "onBeat", selector: "#x" }] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("unknown assertion kind");
});
it("reports per-field errors for missing required fields", () => {
const result = parseMotionSpec({
assertions: [
{ kind: "appearsBy", selector: "#h" },
{ kind: "before", a: "#a" },
{ kind: "staysInFrame" },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toHaveLength(3);
expect(result.errors[0]).toContain("bySec");
expect(result.errors[1]).toContain('"b"');
expect(result.errors[2]).toContain("selector");
}
});
it("rejects a non-object spec and an empty assertion list", () => {
expect(parseMotionSpec(42).ok).toBe(false);
expect(parseMotionSpec({ assertions: [] }).ok).toBe(false);
expect(parseMotionSpec({}).ok).toBe(false);
});
it("rejects a non-positive maxStaticSec", () => {
const result = parseMotionSpec({
assertions: [{ kind: "keepsMoving", maxStaticSec: 0 }],
});
expect(result.ok).toBe(false);
});
it("rejects an unsupported spec version", () => {
const result = parseMotionSpec({
version: 2,
assertions: [{ kind: "staysInFrame", selector: ".card" }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("version");
});
it("rejects NaN as duration", () => {
const result = parseMotionSpec({
duration: NaN,
assertions: [{ kind: "staysInFrame", selector: ".card" }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("duration");
});
it('rejects "*" as withinSelector', () => {
const result = parseMotionSpec({
assertions: [{ kind: "keepsMoving", withinSelector: "*" }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain('"*"');
});
});
describe("findMotionSpec", () => {
it("returns null when no sidecar is present", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-none-"));
writeFileSync(join(dir, "main.html"), "<div></div>");
expect(findMotionSpec(dir)).toBeNull();
});
it("finds the single sidecar", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-one-"));
writeFileSync(join(dir, "anything.motion.json"), "{}");
expect(findMotionSpec(dir)).toBe(join(dir, "anything.motion.json"));
});
it("prefers the sidecar matching a composition html basename", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-many-"));
writeFileSync(join(dir, "aaa.motion.json"), "{}");
writeFileSync(join(dir, "main.motion.json"), "{}");
writeFileSync(join(dir, "main.html"), "<div></div>");
expect(findMotionSpec(dir)).toBe(join(dir, "main.motion.json"));
});
it("throws when multiple sidecars each match a different composition", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-ambig-"));
writeFileSync(join(dir, "hero.motion.json"), "{}");
writeFileSync(join(dir, "landing.motion.json"), "{}");
writeFileSync(join(dir, "hero.html"), "<div></div>");
writeFileSync(join(dir, "landing.html"), "<div></div>");
expect(() => findMotionSpec(dir)).toThrow("ambiguous motion sidecars");
});
});
describe("readMotionSpec", () => {
it("returns error for a nonexistent file", () => {
const result = readMotionSpec("/tmp/__nonexistent_motion_spec__.json");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("could not read");
});
it("returns error for a file with invalid JSON", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-bad-"));
const path = join(dir, "bad.motion.json");
writeFileSync(path, "not json {{");
const result = readMotionSpec(path);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("could not read");
});
it("returns error for a file with a valid JSON but invalid spec", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-invalid-"));
const path = join(dir, "invalid.motion.json");
writeFileSync(path, JSON.stringify({ assertions: [] }));
const result = readMotionSpec(path);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("no assertions");
});
it("parses a valid sidecar file", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-valid-"));
const path = join(dir, "main.motion.json");
writeFileSync(
path,
JSON.stringify({ assertions: [{ kind: "staysInFrame", selector: ".card" }] }),
);
const result = readMotionSpec(path);
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.assertions).toHaveLength(1);
});
});