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:
co-authored by
Claude Fable 5
parent
7f99b76f66
commit
f3d433c024
@@ -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}`,
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user