Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes
Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
(collapsed sections, viewport-inconsistent captures); settle autoplay
carousels to home slide before every snapshot; force-reveal hidden
videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
picture>source through IR prune with srcset rewrite (mobile-crop-on-
desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
forms) with element-screenshot fallback; asset download retry +
visual-assets-missing reporting
Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
da537e68b8
commit
9aed2540aa
@@ -0,0 +1,166 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import {
|
||||
promoteLazyMediaInPage,
|
||||
settleCarouselsInPage,
|
||||
forceRevealForShot,
|
||||
restoreRevealForShot,
|
||||
} from "../src/capture/stabilize.js";
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||
// 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);";
|
||||
|
||||
describe("stabilize: lazy-media promotion", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.goto(pathToFileURL(join(FIXTURES, "lazy.html")).href);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("promotes data-lazy-src/data-src/data-srcset/data-bg and measures the loaded size", async () => {
|
||||
const count = await page.evaluate(promoteLazyMediaInPage);
|
||||
// lazy1 (src), lazy2 (src), lazysource (srcset), lazy3 (src), lazybg (background)
|
||||
assert.equal(count, 5);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const attr = (id: string, name: string) => document.getElementById(id)?.getAttribute(name) ?? null;
|
||||
const img = (id: string) => document.getElementById(id) as HTMLImageElement;
|
||||
return {
|
||||
lazy1Src: attr("lazy1", "src"),
|
||||
lazy1Loading: attr("lazy1", "loading"),
|
||||
lazy1Width: img("lazy1").naturalWidth,
|
||||
lazy2Src: attr("lazy2", "src"),
|
||||
lazy2Sizes: attr("lazy2", "sizes"), // data-sizes="auto" is a flag, not a value
|
||||
sourceSrcset: attr("lazysource", "srcset"),
|
||||
lazy3Src: attr("lazy3", "src"),
|
||||
bg: (document.getElementById("lazybg") as HTMLElement).style.backgroundImage,
|
||||
notaurlSrc: attr("notaurl", "src"),
|
||||
};
|
||||
});
|
||||
assert.equal(state.lazy1Src, "og-image.png");
|
||||
assert.equal(state.lazy1Loading, "eager");
|
||||
assert.ok(state.lazy1Width > 1, "promoted image decoded to its real size");
|
||||
assert.equal(state.lazy2Src, "seo-icon.png");
|
||||
assert.equal(state.lazy2Sizes, null);
|
||||
assert.equal(state.sourceSrcset, "og-image.png 1x");
|
||||
assert.equal(state.lazy3Src, "og-image.png");
|
||||
assert.ok(state.bg.includes("brand.svg"), `background promoted (got ${state.bg})`);
|
||||
assert.equal(state.notaurlSrc, null, "non-URL data attr must not be promoted");
|
||||
});
|
||||
|
||||
it("is idempotent and never clobbers an src that already equals the target", async () => {
|
||||
const again = await page.evaluate(promoteLazyMediaInPage);
|
||||
assert.equal(again, 0);
|
||||
const alreadySrc = await page.evaluate(() => document.getElementById("already")?.getAttribute("src"));
|
||||
assert.equal(alreadySrc, "seo-icon.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stabilize: carousel settling", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.goto(pathToFileURL(join(FIXTURES, "carousel-autoplay.html")).href);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const txOf = (sel: string) =>
|
||||
page.evaluate((s) => {
|
||||
const el = document.querySelector(s)!;
|
||||
return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41;
|
||||
}, sel);
|
||||
|
||||
it("returns an autoplaying carousel to its home slide and pauses autoplay", async () => {
|
||||
// let autoplay advance at least one slide so there is something to settle
|
||||
await page.waitForFunction("window.__autoplayTicks >= 2", null, { timeout: 5000 });
|
||||
assert.notEqual(await txOf(".splide__list"), 0);
|
||||
|
||||
const res = await page.evaluate(settleCarouselsInPage);
|
||||
assert.equal(res.roots, 2);
|
||||
assert.equal(res.normalized, 2);
|
||||
|
||||
assert.equal(await txOf(".splide__list"), 0, "track back at the home slide");
|
||||
const bulletActive = await page.evaluate(() =>
|
||||
document.querySelector(".splide__pagination__bullet")!.classList.contains("is-active"));
|
||||
assert.ok(bulletActive, "first bullet re-activated");
|
||||
|
||||
// paused: no further autoplay ticks, and the track holds its home transform
|
||||
const ticks = await page.evaluate("window.__autoplayTicks");
|
||||
await page.waitForTimeout(700); // > 2 autoplay intervals
|
||||
assert.equal(await page.evaluate("window.__autoplayTicks"), ticks, "autoplay latched paused");
|
||||
assert.equal(await txOf(".splide__list"), 0, "track still at home after the autoplay window");
|
||||
});
|
||||
|
||||
it("pins a control-less non-loop track left mid-offset back to translateX(0)", async () => {
|
||||
assert.equal(await txOf("#stuck .swiper-wrapper"), 0);
|
||||
});
|
||||
|
||||
it("cancels a mid-flight track transition instead of freezing it", async () => {
|
||||
// Start a slide transition and settle while it is still running: pausing the
|
||||
// CSSTransition would disassociate it from style and HOLD the frozen mid-flight
|
||||
// transform over the home navigation — the settled track must still land at 0.
|
||||
await page.evaluate(`(async () => {
|
||||
const bullets = document.querySelectorAll(".splide__pagination__bullet");
|
||||
bullets[2].click(); // transition toward slide 3 begins (0.2s)
|
||||
return await (${settleCarouselsInPage.toString()})();
|
||||
})()`);
|
||||
assert.equal(await txOf(".splide__list"), 0, "track settled at home, not a frozen mid-flight offset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stabilize: force-reveal for element screenshots", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(
|
||||
'<div id="wrap" style="visibility:hidden">' +
|
||||
'<video id="v" style="width:320px;height:180px;background:#000"></video>' +
|
||||
"</div>",
|
||||
);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("reveals the hidden ancestor chain so the screenshot succeeds, then restores exactly", async () => {
|
||||
const vis = () => page.evaluate(() => getComputedStyle(document.getElementById("v")!).visibility);
|
||||
assert.equal(await vis(), "hidden");
|
||||
|
||||
const forced = await page.evaluate(forceRevealForShot, "#v");
|
||||
assert.equal(forced, 2); // the video (inherited hidden) + the wrapping div
|
||||
assert.equal(await vis(), "visible");
|
||||
|
||||
const buf = await page.locator("#v").screenshot({ type: "jpeg", quality: 82, timeout: 2000, animations: "disabled" });
|
||||
assert.ok(buf.length > 0, "screenshot captured while revealed");
|
||||
|
||||
await page.evaluate(restoreRevealForShot);
|
||||
assert.equal(await vis(), "hidden");
|
||||
const after = await page.evaluate(() => ({
|
||||
wrapInline: (document.getElementById("wrap") as HTMLElement).style.visibility,
|
||||
videoInline: (document.getElementById("v") as HTMLElement).style.visibility,
|
||||
markers: document.querySelectorAll("[data-clone-vis-restore]").length,
|
||||
}));
|
||||
assert.equal(after.wrapInline, "hidden", "original inline value restored");
|
||||
assert.equal(after.videoInline, "", "no inline visibility left on the video");
|
||||
assert.equal(after.markers, 0, "restore markers removed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user