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 })) {
|
||||
|
||||
Reference in New Issue
Block a user