Files
ditto.site/compiler/test/lottiePlaceholder.test.ts
T
Samraaj BathandClaude Fable 5 15d1e7a9e3 Canvas raster fallback, CDP rest-state screenshots, lottie overlay containment
- Visible canvases (>=48px) are captured as PNG stills at capture time
  (toDataURL, element-screenshot fallback for tainted/webgl) and emitted as
  cid-carrying <img> elements filling the captured box, so canvas-driven
  bands no longer render blank
- Source AND clone full-page screenshots now capture via CDP
  Page.captureScreenshot with captureBeyondViewport: no scroll-stitching,
  no scroll events, so scroll-linked animations can't smear mid-scrub
  states into perceptual evidence (pixel-identical to the old path on
  static pages; Playwright fallback retained)
- DittoLottie's runtime overlay stays position:absolute/inset:0 for its
  whole life and the host carries data-ditto-lottie with a scoped
  svg/canvas fit rule, so the runtime animation always fills the pinned
  per-viewport box instead of escaping to aspect height

259 tests pass (12 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:36:08 -07:00

119 lines
6.6 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { DITTO_LOTTIE_TSX } from "../src/generate/lottie.js";
import { isIdentityTransform, canonicalizeTransforms } from "../src/normalize/ir.js";
import type { StyleMap } from "../src/normalize/ir.js";
/**
* The DittoLottie runtime must NOT erase the captured placeholder frame before the animation
* has successfully loaded — a failed load would otherwise blank the container (erasing hero /
* logo / footer media). These are string assertions on the emitted 'use client' template.
*/
describe("DittoLottie placeholder retention", () => {
it("does not clear the container's innerHTML before mounting", () => {
assert.ok(
!/el\.innerHTML\s*=\s*""/.test(DITTO_LOTTIE_TSX),
"template must not eagerly clear the placeholder via el.innerHTML = \"\"",
);
});
it("mounts the live animation into a separate overlay child, not the container itself", () => {
assert.match(DITTO_LOTTIE_TSX, /createElement\(["']div["']\)/);
assert.match(DITTO_LOTTIE_TSX, /container:\s*mount/);
});
it("only removes the placeholder after a successful load event (DOMLoaded)", () => {
// The reveal/swap must be gated behind lottie's ready event, and the placeholder removal
// must live inside that gated path (removing original children once mount is ready).
assert.match(DITTO_LOTTIE_TSX, /addEventListener\(\s*["']DOMLoaded["']/);
assert.match(DITTO_LOTTIE_TSX, /removeChild/);
// The removal must reference the ready swap, not run unconditionally at mount time.
const revealIdx = DITTO_LOTTIE_TSX.indexOf("DOMLoaded");
const clearIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
assert.ok(revealIdx >= 0 && clearIdx >= 0, "both the ready gate and the placeholder removal must be present");
});
it("handles a failed load without leaving a broken mount stacked over the placeholder", () => {
assert.match(DITTO_LOTTIE_TSX, /data_failed/);
});
// Sizing: the host box carries the captured per-viewport height; only an absolutely-filled overlay
// inherits that definite box. If the reveal reverted the overlay to static flow, the player's svg
// (style height:100%) would collapse to auto and inflate to the source aspect (a portrait viewBox
// then oversizes far past the captured, letterboxed box). So the overlay must STAY absolute/inset
// for its whole life, and the reveal must NOT clear those styles.
it("mounts the overlay as an absolute, inset-0 layer", () => {
assert.match(DITTO_LOTTIE_TSX, /mount\.style\.position\s*=\s*["']absolute["']/);
assert.match(DITTO_LOTTIE_TSX, /mount\.style\.inset\s*=\s*["']0["']/);
});
it("keeps the overlay absolute after the swap (never reverts it to static flow)", () => {
// The reveal must not strip the overlay's positioning — a `mount.style.position = ""` (or inset)
// un-pins it from the host's captured height and lets the runtime svg inflate by aspect.
assert.ok(
!/mount\.style\.position\s*=\s*["']["']/.test(DITTO_LOTTIE_TSX),
"reveal must not clear mount.style.position (would un-pin the overlay → aspect inflation)",
);
assert.ok(
!/mount\.style\.inset\s*=\s*["']["']/.test(DITTO_LOTTIE_TSX),
"reveal must not clear mount.style.inset (would un-pin the overlay → aspect inflation)",
);
});
it("marks the host with data-ditto-lottie so emitted CSS can force the runtime svg to fit", () => {
assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-ditto-lottie["']/);
});
// The player creates its <svg>/<canvas> at runtime WITHOUT a data-cid, so the DOM/media gate can't
// map it back to the captured node. The reveal must forward the discarded placeholder's data-cid
// onto the runtime-rendered element (validation-agnostic; no gate special-casing).
it("forwards the placeholder's data-cid onto the runtime-rendered svg/canvas before the swap", () => {
// reads the placeholder cid, then reassigns it to the rendered svg/canvas inside the mount
assert.match(DITTO_LOTTIE_TSX, /getAttribute\(\s*["']data-cid["']\s*\)/);
assert.match(DITTO_LOTTIE_TSX, /mount\.querySelector\(\s*["']svg,\s*canvas["']\s*\)/);
assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-cid["']\s*,/);
// the cid capture must precede its removal so the placeholder is still in the DOM when read
const readIdx = DITTO_LOTTIE_TSX.indexOf('getAttribute("data-cid")');
const removeIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
assert.ok(readIdx >= 0 && removeIdx >= 0 && readIdx < removeIdx, "must read the placeholder cid before removing it");
});
});
describe("identity-transform canonicalization (isIdentityTransform)", () => {
it("treats none and identity matrices as identity", () => {
assert.equal(isIdentityTransform("none"), true);
assert.equal(isIdentityTransform(undefined), true);
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 0)"), true);
assert.equal(isIdentityTransform("matrix(1,0,0,1,0,0)"), true);
assert.equal(
isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
true,
);
});
it("does NOT treat a real (non-identity) transform as identity", () => {
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 67.75)"), false);
assert.equal(isIdentityTransform("translateY(67.75px)"), false);
assert.equal(isIdentityTransform("matrix(0.5, 0, 0, 0.5, 0, 0)"), false);
assert.equal(isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 10, 0, 1)"), false);
});
it("rewrites identity values to `none` while leaving a real transform observable at other widths", () => {
// The contamination scenario: the base viewport (1280) baked a scroll-linked translateY,
// while the other widths report identity (none / identity matrix). After canonicalization
// the identity widths read literal `none`, so the generator's per-band delta emits the
// explicit reset and the base transform can't cascade across bands.
const computedByVp: Record<number, StyleMap> = {
375: { transform: "none" } as StyleMap,
768: { transform: "matrix(1, 0, 0, 1, 0, 0)" } as StyleMap,
1280: { transform: "matrix(1, 0, 0, 1, 0, 67.75)" } as StyleMap, // contaminated base
1920: { transform: "none" } as StyleMap,
};
canonicalizeTransforms(computedByVp);
assert.equal(computedByVp[375]!.transform, "none");
assert.equal(computedByVp[768]!.transform, "none"); // identity matrix normalized to none
assert.equal(computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 67.75)"); // real transform preserved
assert.equal(computedByVp[1920]!.transform, "none");
});
});