Teach motion gate about reveal-replay; auto-provision missing harness deps

- 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 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 20:22:42 -07:00
co-authored by Claude Fable 5
parent 7f99b76f66
commit f3d433c024
3 changed files with 219 additions and 7 deletions
+43 -7
View File
@@ -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<string, string> {
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<string, string>,
): 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<string, string>();
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<number> =>
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}`,
+45
View File
@@ -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<string, string> = {};
try { deps = (JSON.parse(readFileSync(pkgPath, "utf8")).dependencies ?? {}) as Record<string, string>; }
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 })) {
+131
View File
@@ -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<string, string>(); // 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<string, string>): 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(); }
});
});