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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e33354cfb5
commit
15d1e7a9e3
@@ -0,0 +1,108 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { captureCanvasStillsInPage } from "../src/capture/capture.js";
|
||||
import type { IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import { propsList, resolveTag, renderChildrenJsx } from "../src/generate/app.js";
|
||||
|
||||
// tsx/esbuild wraps functions with a __name() helper for stack traces; the serialized
|
||||
// page functions carry those calls, so shim it (same as capture.ts's init script).
|
||||
const ESBUILD_SHIM =
|
||||
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
|
||||
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
|
||||
|
||||
describe("capture: canvas raster fallback (in-page)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(`
|
||||
<canvas id="big" width="200" height="100"></canvas>
|
||||
<canvas id="tiny" width="20" height="20"></canvas>
|
||||
<canvas id="ghost" width="300" height="150" style="visibility:hidden"></canvas>
|
||||
<script>
|
||||
const ctx = document.getElementById("big").getContext("2d");
|
||||
ctx.fillStyle = "#3b82f6";
|
||||
ctx.fillRect(10, 10, 180, 80);
|
||||
</script>
|
||||
`);
|
||||
// setContent replaces the document without a navigation, so the init script may
|
||||
// not have applied — evaluate the shim directly (same as capture.ts's frame path).
|
||||
await page.evaluate(ESBUILD_SHIM);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("rasterizes a meaningful 2D canvas to a PNG data URL under a synthetic clone-canvas URL", async () => {
|
||||
const plan = await page.evaluate(captureCanvasStillsInPage);
|
||||
assert.equal(plan.stills.length, 1, "exactly one canvas qualifies");
|
||||
assert.equal(plan.shots.length, 0, "readable 2D canvas needs no element-screenshot fallback");
|
||||
const still = plan.stills[0]!;
|
||||
assert.ok(still.dataUrl.startsWith("data:image/png"), `PNG data URL (got ${still.dataUrl.slice(0, 30)})`);
|
||||
assert.match(still.url, /^https:\/\/clone-canvas\.local\/0-[0-9a-z]+\.png$/);
|
||||
assert.equal(still.sel, 'canvas[data-clone-canvas="0"]');
|
||||
const bytes = Buffer.from(still.dataUrl.slice(still.dataUrl.indexOf(",") + 1), "base64");
|
||||
assert.ok(bytes.length > 0, "still decodes to non-empty bytes");
|
||||
|
||||
const stamped = await page.evaluate(() => ({
|
||||
big: document.getElementById("big")?.getAttribute("data-clone-canvas") ?? null,
|
||||
tiny: document.getElementById("tiny")?.getAttribute("data-clone-canvas") ?? null,
|
||||
ghost: document.getElementById("ghost")?.getAttribute("data-clone-canvas") ?? null,
|
||||
}));
|
||||
assert.equal(stamped.big, "0", "qualifying canvas gets the stable marker");
|
||||
assert.equal(stamped.tiny, null, "a 20x20 canvas is below the meaningful-size gate");
|
||||
assert.equal(stamped.ghost, null, "a hidden canvas is skipped");
|
||||
});
|
||||
});
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const SOURCE = "https://example.test/page";
|
||||
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
function el(id: string, tag: string, attrs: Record<string, string> = {}, children: IRChild[] = [], visible = true): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { display: "block", position: "static", visibility: "visible" };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 };
|
||||
visibleByVp[vp] = visible;
|
||||
}
|
||||
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
describe("generate: canvas-still emission", () => {
|
||||
const STILL_URL = "https://clone-canvas.local/0-abc.png";
|
||||
const LOCAL = "/assets/cloned/images/ab.png";
|
||||
const assetMap = new Map([[STILL_URL, LOCAL]]);
|
||||
const canvas = () => el("7", "canvas", { src: STILL_URL, width: "200", height: "100" });
|
||||
|
||||
it("retags a canvas carrying a captured still to <img>; a bare canvas stays canvas", () => {
|
||||
assert.equal(resolveTag(canvas(), false), "img");
|
||||
assert.equal(resolveTag(el("8", "canvas", { width: "200", height: "100" }), false), "canvas");
|
||||
});
|
||||
|
||||
it("resolves the synthetic still URL through the asset map and emits data-cid + alt", () => {
|
||||
const p = new Map(propsList(canvas(), assetMap, SOURCE));
|
||||
assert.equal(p.get("src"), JSON.stringify(LOCAL));
|
||||
assert.equal(p.get('"data-cid"'), JSON.stringify("7"));
|
||||
assert.equal(p.get("alt"), JSON.stringify(""), "decorative alt is injected for the raster still");
|
||||
assert.equal(p.get("width"), JSON.stringify("200"));
|
||||
assert.equal(p.get("height"), JSON.stringify("100"));
|
||||
});
|
||||
|
||||
it("renders a self-closing <img> in place of the canvas", () => {
|
||||
const jsx = renderChildrenJsx([canvas()], assetMap, SOURCE, 0);
|
||||
assert.match(jsx, /<img[^>]*data-cid="7"[^>]*\/>/);
|
||||
assert.match(jsx, new RegExp(`src="${LOCAL.replace(/[/]/g, "\\/")}"`));
|
||||
assert.ok(!jsx.includes("<canvas"), "no canvas element remains");
|
||||
});
|
||||
|
||||
it("falls back to the transparent GIF when the still missed the asset map (never a remote URL)", () => {
|
||||
const p = new Map(propsList(canvas(), new Map(), SOURCE));
|
||||
assert.equal(p.get("src"), JSON.stringify(GIF));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { PNG } from "pngjs";
|
||||
import pixelmatch from "pixelmatch";
|
||||
import { captureFullPageViaCDP } from "../src/capture/capture.js";
|
||||
|
||||
const ESBUILD_SHIM = "globalThis.__name = globalThis.__name || ((fn) => fn);";
|
||||
|
||||
// A STATIC page (no scroll-linked animation) several viewports tall: a few full-width
|
||||
// colored bands. Both capture paths should render the identical at-rest picture, so any
|
||||
// difference is purely a capture-mechanism artifact (scrollbar gutter, off-by-one), which
|
||||
// is exactly what this test measures.
|
||||
const BANDS_HTML =
|
||||
"<!doctype html><html><head><style>" +
|
||||
"*{margin:0;padding:0;box-sizing:border-box}" +
|
||||
"html,body{width:100%}" +
|
||||
".band{width:100%;height:600px;display:flex;align-items:center;justify-content:center;" +
|
||||
"font:bold 48px sans-serif;color:#fff}" +
|
||||
".b0{background:#c0392b}.b1{background:#27ae60}.b2{background:#2980b9}" +
|
||||
".b3{background:#8e44ad}.b4{background:#d35400}" +
|
||||
"</style></head><body>" +
|
||||
"<div class='band b0'>1</div><div class='band b1'>2</div>" +
|
||||
"<div class='band b2'>3</div><div class='band b3'>4</div>" +
|
||||
"<div class='band b4'>5</div>" +
|
||||
"</body></html>";
|
||||
|
||||
describe("CDP full-page capture vs Playwright fullPage stitch (static fixture)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let tmp: string;
|
||||
|
||||
before(async () => {
|
||||
browser = await chromium.launch({ args: ["--disable-dev-shm-usage"] });
|
||||
// A viewport SHORTER than the content so fullPage must span multiple bands: this is
|
||||
// where Playwright would scroll-stitch and CDP renders in one shot.
|
||||
const context = await browser.newContext({ viewport: { width: 800, height: 700 }, deviceScaleFactor: 1 });
|
||||
page = await context.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(BANDS_HTML, { waitUntil: "load" });
|
||||
tmp = mkdtempSync(join(tmpdir(), "cdp-shot-"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("produces a valid PNG of the full content height (not just the viewport)", async () => {
|
||||
const cdpPath = join(tmp, "cdp.png");
|
||||
await captureFullPageViaCDP(page, cdpPath);
|
||||
const cdp = PNG.sync.read(readFileSync(cdpPath));
|
||||
// 5 bands * 600px = 3000px tall, well beyond the 700px viewport.
|
||||
assert.ok(cdp.height >= 2900, `CDP shot spans full content (got ${cdp.height})`);
|
||||
assert.ok(cdp.width >= 780 && cdp.width <= 800, `CDP width ~= content width (got ${cdp.width})`);
|
||||
assert.ok(readFileSync(cdpPath).length > 1000, "CDP PNG is non-trivial in size");
|
||||
});
|
||||
|
||||
it("is pixel-equivalent to Playwright's fullPage screenshot on a static page", async () => {
|
||||
const cdpPath = join(tmp, "cdp2.png");
|
||||
const pwPath = join(tmp, "pw.png");
|
||||
await captureFullPageViaCDP(page, cdpPath);
|
||||
await page.screenshot({ path: pwPath, fullPage: true, animations: "disabled" });
|
||||
|
||||
const cdp = PNG.sync.read(readFileSync(cdpPath));
|
||||
const pw = PNG.sync.read(readFileSync(pwPath));
|
||||
|
||||
// Dimensions must match (a scrollbar-gutter difference would show up here first).
|
||||
assert.equal(cdp.width, pw.width, `width match (cdp ${cdp.width} vs pw ${pw.width})`);
|
||||
assert.equal(cdp.height, pw.height, `height match (cdp ${cdp.height} vs pw ${pw.height})`);
|
||||
|
||||
const { width, height } = cdp;
|
||||
const diff = new PNG({ width, height });
|
||||
const mismatched = pixelmatch(cdp.data, pw.data, diff.data, width, height, { threshold: 0.1 });
|
||||
const pct = (mismatched / (width * height)) * 100;
|
||||
// On a static page the two mechanisms should be near-identical. Allow a tiny tolerance
|
||||
// for antialiasing at band seams; assert well under 0.5% differing pixels.
|
||||
assert.ok(pct < 0.5, `pixel delta ${pct.toFixed(4)}% (${mismatched} px) must be < 0.5%`);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { RawSizing } from "../src/capture/walker.js";
|
||||
import { generateCss, collectNodeRules } from "../src/generate/css.js";
|
||||
import { generateCss, collectNodeRules, RESET_CSS } from "../src/generate/css.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const CANONICAL = 1280;
|
||||
@@ -618,6 +618,32 @@ describe("collectNodeRules lottie mount height pin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The reset ships a rule that forces the lottie runtime svg/canvas to fit its overlay box. The
|
||||
// player re-mounts its media into an absolute overlay that fills the host's pinned (per-viewport)
|
||||
// height; without this rule an aspect-mismatched viewBox (a portrait animation in a shorter,
|
||||
// letterboxed source box) inflates past that height. Scoped to the runtime-marked host only, so it
|
||||
// is inert when a page has no lottie.
|
||||
describe("RESET_CSS lottie runtime-fit rule", () => {
|
||||
it("constrains the runtime svg/canvas to the overlay box, scoped to the marked host", () => {
|
||||
assert.match(RESET_CSS, /\[data-ditto-lottie\]\s*>\s*div\s*>\s*svg/);
|
||||
assert.match(RESET_CSS, /\[data-ditto-lottie\]\s*>\s*div\s*>\s*canvas/);
|
||||
// the rule must pin both dimensions so height:100% resolves against the definite overlay
|
||||
const m = RESET_CSS.match(/\[data-ditto-lottie\][^{]*\{([^}]*)\}/);
|
||||
assert.ok(m, "the data-ditto-lottie rule must be present");
|
||||
assert.match(m![1], /width:\s*100%/);
|
||||
assert.match(m![1], /height:\s*100%/);
|
||||
});
|
||||
|
||||
it("does not affect the pre-swap placeholder (a direct-child svg, not nested under a div)", () => {
|
||||
// The selector targets `> div > svg` (the runtime overlay), never a `> svg` placeholder, so the
|
||||
// captured placeholder keeps its own pinned classes and pre/post-swap geometry stays identical.
|
||||
assert.ok(
|
||||
!/\[data-ditto-lottie\]\s*>\s*svg\b/.test(RESET_CSS),
|
||||
"the rule must not target a direct-child placeholder svg",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Defect E — a content-sized flex ROW's items are dropped to `width:auto` only when NO shrink fired
|
||||
// (positive slack ⇒ items at content size). But a WRAPPING text leaf paints at its balanced-wrap
|
||||
// width, which is BELOW its max-content: `width:auto` on the block child fills the line to
|
||||
|
||||
@@ -37,6 +37,33 @@ describe("DittoLottie placeholder retention", () => {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user