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
@@ -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