Files
ditto.site/compiler/test/placeholder.test.ts
Samraaj BathandClaude Fable 5 9aed2540aa 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>
2026-07-03 17:45:42 -07:00

165 lines
6.9 KiB
TypeScript

import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium, type Browser, type Page } from "playwright";
import { collectPage, type RawNode, type RawChild } from "../src/capture/walker.js";
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
import { generateCss } from "../src/generate/css.js";
import { buildTailwind } from "../src/generate/tailwind.js";
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
function isText(c: RawChild): c is { text: string } {
return (c as { text?: string }).text !== undefined;
}
function findBy(root: RawNode, pred: (n: RawNode) => boolean): RawNode | null {
if (pred(root)) return root;
for (const c of root.children) {
if (isText(c)) continue;
const hit = findBy(c, pred);
if (hit) return hit;
}
return null;
}
describe("walker ::placeholder capture", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage();
});
after(async () => {
await browser.close();
});
it("captures the styled ::placeholder color/font of input and textarea", async () => {
await page.setContent(readFileSync(join(FIXTURES, "placeholder.html"), "utf8"));
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
const snap = await page.evaluate(collectPage);
const input = findBy(snap.root, (n) => n.tag === "input" && n.attrs.class === "styled")!;
assert.ok(input.placeholder, "styled input carries a placeholder style");
assert.equal(input.placeholder!.color, "rgb(120, 30, 200)");
assert.equal(input.placeholder!.fontSize, "14px");
const textarea = findBy(snap.root, (n) => n.tag === "textarea")!;
assert.ok(textarea.placeholder, "styled textarea carries a placeholder style");
assert.equal(textarea.placeholder!.color, "rgb(5, 100, 5)");
});
it("does not attach placeholder style to a control without placeholder text", async () => {
await page.setContent(readFileSync(join(FIXTURES, "placeholder.html"), "utf8"));
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
const snap = await page.evaluate(collectPage);
const plain = findBy(snap.root, (n) => n.tag === "input" && n.attrs.class === "plain")!;
assert.equal(plain.placeholder, undefined);
});
});
// ---- Generated CSS ----
const VPS = [375, 1280];
const CANONICAL = 1280;
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", ...over };
}
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = []): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of VPS) {
computedByVp[vp] = { ...cs };
bboxByVp[vp] = { x: 0, y: 0, width: 200, height: 40 };
visibleByVp[vp] = true;
}
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
}
function irWith(root: IRNode): IR {
return {
doc: {
sourceUrl: "https://example.test/placeholder",
title: "Placeholder Fixture",
lang: "en",
charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: VPS,
sampleViewports: VPS,
canonicalViewport: CANONICAL,
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
nodeCount: 2,
keyframes: [],
},
root,
};
}
describe("generateCss ::placeholder emission", () => {
it("emits a ::placeholder rule with the captured color", () => {
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)", fontSize: "16px" }));
input.placeholderByVp = {
375: { color: "rgb(120, 30, 200)", fontSize: "14px" },
1280: { color: "rgb(120, 30, 200)", fontSize: "14px" },
};
const root = node("n0", "body", computed(), [input]);
const css = generateCss(irWith(root), new Map());
const m = css.match(/\.cn1::placeholder\{([^}]*)\}/);
assert.ok(m, "a .cn1::placeholder rule is emitted");
assert.ok(m![1]!.includes("color:rgb(120, 30, 200)"));
// fontSize differs from the host's own 16px, so it must be emitted too.
assert.ok(m![1]!.includes("font-size:14px"));
});
it("omits font props the placeholder inherits from the input itself", () => {
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)", fontSize: "16px", fontFamily: "Arial" }));
input.placeholderByVp = {
375: { color: "rgb(200, 150, 100)", fontSize: "16px", fontFamily: "Arial" },
1280: { color: "rgb(200, 150, 100)", fontSize: "16px", fontFamily: "Arial" },
};
const root = node("n0", "body", computed(), [input]);
const css = generateCss(irWith(root), new Map());
const m = css.match(/\.cn1::placeholder\{([^}]*)\}/);
assert.ok(m);
assert.ok(m![1]!.includes("color:rgb(200, 150, 100)"));
assert.ok(!m![1]!.includes("font-size"), "inherited font-size is not re-declared");
assert.ok(!m![1]!.includes("font-family"), "inherited font-family is not re-declared");
});
it("bands a placeholder color that changes across viewports", () => {
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
input.placeholderByVp = {
375: { color: "rgb(1, 2, 3)" },
1280: { color: "rgb(120, 30, 200)" },
};
const root = node("n0", "body", computed(), [input]);
const css = generateCss(irWith(root), new Map());
assert.ok(/\.cn1::placeholder\{[^}]*rgb\(120, 30, 200\)/.test(css), "base carries the canonical color");
assert.ok(/@media \(max-width: \d+px\)[\s\S]*\.cn1::placeholder\{[^}]*rgb\(1, 2, 3\)/.test(css), "mobile band overrides the color");
});
it("emits no ::placeholder rule for nodes without placeholder styles", () => {
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
const root = node("n0", "body", computed(), [input]);
const css = generateCss(irWith(root), new Map());
assert.ok(!css.includes("::placeholder"));
});
it("tailwind mode (the default pipeline) carries the rule in ditto.css keyed by data-cid", () => {
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
input.placeholderByVp = {
375: { color: "rgb(120, 30, 200)" },
1280: { color: "rgb(120, 30, 200)" },
};
const root = node("n0", "body", computed(), [input]);
const tw = buildTailwind(irWith(root), new Map());
assert.ok(/\[data-cid="n1"\]::placeholder\s*\{[^}]*color:\s*rgb\(120, 30, 200\)/.test(tw.pseudoCss),
`pseudoCss carries the placeholder rule:\n${tw.pseudoCss}`);
});
});