From f3d433c024b5f8c31d5bf9d66fc6b579ef9368f1 Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Fri, 3 Jul 2026 20:22:42 -0700 Subject: [PATCH] Teach motion gate about reveal-replay; auto-provision missing harness deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - motionGate: a captured CSS entrance animation the clone replays through a DittoMotion reveal (re-hide on mount, restart @keyframes on scroll) is graded by the scroll-reveal check, not the static-CSS expectation — previously every such cid failed emitted=false. Also drive every captured reveal instead of the first 16 (pages with more reveals could never pass; this was the spurious "reveals 16/24"). - render/harness: the validator copies app sources over the preinstalled harness without installing deps, so a Lottie clone's injected lottie-web crashed webpack ("build failed" with no real signal). Missing app deps are now installed into the harness pinned to the app's exact version (--no-save, idempotent), with a clear gate issue on install failure. Cotton motion gate: 33 static + 10 replayed + 24/24 reveals -> pass. Cropin home build gate: pass (was webpack Module not found). 116/116 compiler tests. Co-Authored-By: Claude Fable 5 --- compiler/src/validate/motionGate.ts | 50 +++++++++-- compiler/src/validate/render.ts | 45 ++++++++++ compiler/test/motionGate.test.ts | 131 ++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 compiler/test/motionGate.test.ts diff --git a/compiler/src/validate/motionGate.ts b/compiler/src/validate/motionGate.ts index c3ac04a..0796dec 100644 --- a/compiler/src/validate/motionGate.ts +++ b/compiler/src/validate/motionGate.ts @@ -12,6 +12,11 @@ import type { GateResult } from "./gates.js"; * on the node, register the referenced @keyframes, and (decisively for infinite * loops) actually be running it (`document.getAnimations()`); a keyframes set we * could not capture (cross-origin sheet) is counted "frozen" — honestly left static. + * A captured CSS entrance animation that the clone instead REPLAYS through a + * DittoMotion reveal (visibility+entrance-class: start hidden, restart the entrance + * @keyframes on scroll-into-view) is NOT emitted as a static decl — it is deliberately + * driven by the reveal replay and graded by the scroll-reveal check below, so it is + * excluded from the static-CSS expectation instead of failing `emitted=false`. * - WAAPI animations + rotating text (from motion.json): the clone's DittoMotion * controller must instantiate the same running animation / cycle the same texts. * No wall-clock: the check reads the declared spec (names/timing/keyframe registration) @@ -46,8 +51,21 @@ function capToCid(ir: IR): Map { return m; } +/** A captured CSS entrance is reveal-REPLAYED (not statically emitted) when the node has a + * DittoMotion reveal spec that replays a matching entrance animationName. Such cids are graded + * by the scroll-reveal check, so they must be excluded from the static-CSS expectation rather + * than failing `emitted=false`. `revealAnimByCid` maps cid → the animationName its reveal replays. */ +export function cssReplayedByReveal( + cid: string, + names: string[], + revealAnimByCid: Map, +): boolean { + const revealAnim = revealAnimByCid.get(cid); + return !!revealAnim && names.includes(revealAnim); +} + /** CSS-animated nodes expected from the IR (computed animation-name ≠ none). */ -function collectExpectedCss(ir: IR): ExpectedCss[] { +export function collectExpectedCss(ir: IR): ExpectedCss[] { const vp = ir.doc.canonicalViewport; const out: ExpectedCss[] = []; const walk = (n: IRNode): void => { @@ -100,7 +118,7 @@ export async function driveMotionGate(opts: { const vh = VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66); const issues: string[] = []; - let cssReproduced = 0, cssFrozen = 0, cssRunning = 0; + let cssReproduced = 0, cssFrozen = 0, cssReplayed = 0, cssRunning = 0; let waapiReproduced = 0, rotatorsReproduced = 0, revealsReproduced = 0, marqueesReproduced = 0; const browser = await chromium.launch({ headless: true, args: ["--disable-dev-shm-usage"] }); try { @@ -146,8 +164,21 @@ export async function driveMotionGate(opts: { const presentKf = new Set(probe.kf); const runningSet = new Set(probe.runningCids); + // Entrance animations the clone drives through a DittoMotion reveal (visibility+entrance-class): + // it re-hides the node on mount and restarts the entrance @keyframes on scroll-into-view, so the + // static `animation-name` is deliberately NOT emitted. Such cids are graded by the scroll-reveal + // check (opacity → visible), not the static-CSS decl check — record their names so a matching + // captured CSS animation is excluded from the static expectation rather than failing emitted=false. + const revealAnimByCid = new Map(); + for (const rv of reveals) { + if (rv.animationName) revealAnimByCid.set(rv.cid, rv.animationName); + } + // ---- declarative CSS @keyframes ---- for (const e of expectedCss) { + // Reveal-replayed entrance: covered by the scroll-reveal check, not static CSS. Excluded + // when the captured animation name matches the name the reveal replays for this cid. + if (cssReplayedByReveal(e.cid, e.names, revealAnimByCid)) { cssReplayed++; continue; } const reproducible = e.names.filter((n) => capturedKf.has(n)); if (reproducible.length === 0) { cssFrozen++; continue; } // keyframes not captured → frozen (honest) const cloneAn = probe.animName[e.cid] ?? ""; @@ -191,9 +222,13 @@ export async function driveMotionGate(opts: { } // ---- scroll reveals — scrolling each into view must reveal it (opacity → visible) ---- + // Grade EVERY captured reveal: the pass check compares revealsReproduced against + // reveals.length, so capping the drive at a subset would make any page with more reveals + // than the cap unable to pass (its later reveals silently ungraded). (This was the cause + // of the spurious "reveals 16/24" — 24 reveals, only 16 driven.) const opacityOf = (cid: string): Promise => page.evaluate((c) => { const el = document.querySelector(`[data-cid="${c}"]`); return el ? parseFloat(getComputedStyle(el).opacity || "1") : -1; }, cid); - for (const rv of reveals.slice(0, 16)) { + for (const rv of reveals) { try { await page.evaluate((c) => document.querySelector(`[data-cid="${c}"]`)?.scrollIntoView({ block: "center" }), rv.cid); await page.waitForTimeout(650); // allow the reveal transition to complete @@ -212,10 +247,11 @@ export async function driveMotionGate(opts: { const cssExpected = expectedCss.length; const droveOk = !issues.some((i) => i.startsWith("motion drive error")); - // Pass: every captured animation is either faithfully reproduced or honestly frozen - // (keyframes uncapturable), every WAAPI/rotator reproduced, and the driver didn't crash. + // Pass: every captured animation is either faithfully reproduced as static CSS, honestly + // frozen (keyframes uncapturable), or replayed through a DittoMotion reveal (graded by the + // scroll-reveal check); every WAAPI/rotator/reveal reproduced; and the driver didn't crash. const pass = droveOk - && cssReproduced + cssFrozen === cssExpected + && cssReproduced + cssFrozen + cssReplayed === cssExpected && waapiReproduced === waapi.length && rotatorsReproduced === rotators.length && revealsReproduced === reveals.length @@ -225,7 +261,7 @@ export async function driveMotionGate(opts: { pass, metrics: { animations: cssExpected + waapi.length + rotators.length + reveals.length + marquees.length, - css: `${cssReproduced}/${cssExpected}`, cssReproduced, cssFrozen, cssRunning, + css: `${cssReproduced}/${cssExpected}`, cssReproduced, cssFrozen, cssReplayed, cssRunning, waapi: `${waapiReproduced}/${waapi.length}`, rotators: `${rotatorsReproduced}/${rotators.length}`, reveals: `${revealsReproduced}/${reveals.length}`, diff --git a/compiler/src/validate/render.ts b/compiler/src/validate/render.ts index 20e8cff..8acbb73 100644 --- a/compiler/src/validate/render.ts +++ b/compiler/src/validate/render.ts @@ -21,9 +21,54 @@ export type BuildResult = { durationMs: number; }; +/** Dependencies the app's package.json declares that are absent from the harness's + * preinstalled node_modules. The harness copies app SOURCE over its own tree but keeps its + * preinstalled node_modules/package.json (for speed + determinism), so a dep the generator + * injects for this clone but that the harness was never provisioned with (e.g. `lottie-web`, + * added by injectLottieDep only for clones with Lottie content) would be missing at build time + * → "Module not found" webpack error. Returns each such dep pinned to the app's exact version. */ +export function missingHarnessDeps(appDir: string, harnessDir: string): Array<{ name: string; version: string }> { + const pkgPath = join(appDir, "package.json"); + if (!existsSync(pkgPath)) return []; + let deps: Record = {}; + try { deps = (JSON.parse(readFileSync(pkgPath, "utf8")).dependencies ?? {}) as Record; } + catch { return []; } + const out: Array<{ name: string; version: string }> = []; + for (const [name, version] of Object.entries(deps)) { + // A dep is present iff its package.json resolves under the harness node_modules. + if (!existsSync(join(harnessDir, "node_modules", name, "package.json"))) { + out.push({ name, version: String(version).replace(/^[\^~]/, "") }); + } + } + return out; +} + +/** Install any app-declared dependency missing from the harness node_modules, pinned to the + * app's exact version. --no-save keeps the harness package.json/lockfile untouched (the install + * is per-clone and idempotent); returns an error string if the install failed (surfaced as a + * build-gate issue) or null on success/no-op. */ +function ensureHarnessDeps(appDir: string, harnessDir: string): string | null { + const missing = missingHarnessDeps(appDir, harnessDir); + if (!missing.length) return null; + const specs = missing.map((d) => `${d.name}@${d.version}`); + const res = spawnSync("npm", ["install", "--no-save", "--no-audit", "--no-fund", ...specs], { + cwd: harnessDir, + encoding: "utf8", + env: { ...process.env }, + maxBuffer: 64 * 1024 * 1024, + timeout: 180000, + }); + if (res.status !== 0) { + return `harness dep install failed for ${specs.join(", ")}: ${(res.stderr || res.stdout || "").split("\n").filter(Boolean).slice(-3).join(" | ")}`; + } + return null; +} + /** Build the generated app inside the shared harness (deps preinstalled). */ export function buildApp(appDir: string, harnessDir: string): BuildResult { const t0 = Date.now(); + const depErr = ensureHarnessDeps(appDir, harnessDir); + if (depErr) return { ok: false, outDir: null, stderr: depErr, durationMs: Date.now() - t0 }; const isVite = existsSync(join(appDir, "vite.config.ts")) || existsSync(join(appDir, "index.html")); if (isVite) { for (const entry of readdirSync(harnessDir, { withFileTypes: true })) { diff --git a/compiler/test/motionGate.test.ts b/compiler/test/motionGate.test.ts new file mode 100644 index 0000000..5761ff5 --- /dev/null +++ b/compiler/test/motionGate.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { IR, IRNode } from "../src/normalize/ir.js"; +import { collectExpectedCss, cssReplayedByReveal } from "../src/validate/motionGate.js"; +import { missingHarnessDeps } from "../src/validate/render.js"; + +// Minimal IRNode factory — collectExpectedCss only reads id, computedByVp[vp].animation*, +// and children, so we build just those fields and cast. +function node(id: string, anim: { animationName?: string; animationIterationCount?: string } | null, children: IRNode[] = []): IRNode { + return { + id, + tag: "div", + attrs: {}, + visibleByVp: {}, + bboxByVp: {}, + computedByVp: anim ? { 1280: anim as unknown as IRNode["computedByVp"][number] } : {}, + children, + } as unknown as IRNode; +} + +function ir(root: IRNode): IR { + return { doc: { canonicalViewport: 1280 } as unknown as IR["doc"], root }; +} + +describe("motion gate — CSS entrance / reveal-replay accounting", () => { + it("collectExpectedCss reports every node with a computed animation-name != none", () => { + const tree = ir( + node("n0", null, [ + node("n464", { animationName: "fadeInUp", animationIterationCount: "1" }), + node("n2", { animationName: "none" }), // excluded + node("n3", null), // no animation + node("n4", { animationName: "spin", animationIterationCount: "infinite" }), + ]), + ); + const got = collectExpectedCss(tree); + assert.deepEqual(got.map((e) => e.cid).sort(), ["n4", "n464"]); + const spin = got.find((e) => e.cid === "n4")!; + assert.equal(spin.infinite, true); + const fade = got.find((e) => e.cid === "n464")!; + assert.equal(fade.infinite, false); + assert.deepEqual(fade.names, ["fadeInUp"]); + }); + + it("cssReplayedByReveal excludes a captured entrance the clone replays via a reveal", () => { + // The cropin regression: n464's fadeInUp is driven by a DittoMotion reveal, so the static + // animation-name is deliberately NOT emitted — it must NOT fail emitted=false. + const revealAnim = new Map([["n464", "fadeInUp"]]); + assert.equal(cssReplayedByReveal("n464", ["fadeInUp"], revealAnim), true); + }); + + it("cssReplayedByReveal does NOT exclude a CSS anim whose node has no reveal (still static)", () => { + const revealAnim = new Map([["n464", "fadeInUp"]]); + // A different, non-reveal node keeps the static-CSS expectation. + assert.equal(cssReplayedByReveal("n9", ["spin"], revealAnim), false); + }); + + it("cssReplayedByReveal does NOT exclude when the reveal replays a DIFFERENT animation name", () => { + // Reveal covers the cid but with a different entrance — the captured CSS anim is unrelated + // and still owed as static CSS, so it must not be silently excused. + const revealAnim = new Map([["n464", "fadeInUp"]]); + assert.equal(cssReplayedByReveal("n464", ["pulse"], revealAnim), false); + }); + + it("cssReplayedByReveal ignores reveals with no entrance animationName (transition family)", () => { + // Transition-family reveals (opacity/transform only) carry no animationName; a node with a + // real CSS keyframe anim is NOT excused by such a reveal. + const revealAnim = new Map(); // no entries for transition-family reveals + assert.equal(cssReplayedByReveal("n464", ["fadeInUp"], revealAnim), false); + }); +}); + +describe("harness build — missing dependency detection (Lottie regression)", () => { + function tmp(): { app: string; harness: string; cleanup: () => void } { + const root = mkdtempSync(join(tmpdir(), "harness-dep-")); + const app = join(root, "app"); + const harness = join(root, "harness"); + mkdirSync(app, { recursive: true }); + mkdirSync(join(harness, "node_modules"), { recursive: true }); + return { app, harness, cleanup: () => rmSync(root, { recursive: true, force: true }) }; + } + function writePkg(dir: string, deps: Record): void { + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "x", dependencies: deps })); + } + function provision(harness: string, name: string, version: string): void { + const p = join(harness, "node_modules", name); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, "package.json"), JSON.stringify({ name, version })); + } + + it("flags a dep the app declares but the harness lacks, pinned to the app's exact version", () => { + const { app, harness, cleanup } = tmp(); + try { + writePkg(app, { next: "15.5.19", react: "19.2.7", "lottie-web": "5.12.2" }); + provision(harness, "next", "15.5.19"); + provision(harness, "react", "19.2.7"); + // lottie-web NOT provisioned in the harness — mirrors the real regression. + const missing = missingHarnessDeps(app, harness); + assert.deepEqual(missing, [{ name: "lottie-web", version: "5.12.2" }]); + } finally { cleanup(); } + }); + + it("returns nothing when every app dep is already in the harness node_modules", () => { + const { app, harness, cleanup } = tmp(); + try { + writePkg(app, { next: "15.5.19", react: "19.2.7" }); + provision(harness, "next", "15.5.19"); + provision(harness, "react", "19.2.7"); + assert.deepEqual(missingHarnessDeps(app, harness), []); + } finally { cleanup(); } + }); + + it("strips a caret/tilde range to a bare version for a deterministic pinned install", () => { + const { app, harness, cleanup } = tmp(); + try { + writePkg(app, { "lottie-web": "^5.12.2" }); + const missing = missingHarnessDeps(app, harness); + assert.deepEqual(missing, [{ name: "lottie-web", version: "5.12.2" }]); + } finally { cleanup(); } + }); + + it("returns nothing when the app has no package.json", () => { + const { app, harness, cleanup } = tmp(); + try { + rmSync(join(app, "package.json"), { force: true }); + assert.deepEqual(missingHarnessDeps(app, harness), []); + } finally { cleanup(); } + }); +});