import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { chromium, type Browser, type Page } from "playwright"; import { collectPage, type RawNode, type RawChild } from "../src/capture/walker.js"; function isText(c: RawChild): c is { text: string } { return (c as { text?: string }).text !== undefined; } function findByTag(root: RawNode, tag: string): RawNode | null { if (root.tag === tag) return root; for (const c of root.children) { if (isText(c)) continue; const hit = findByTag(c, tag); if (hit) return hit; } return null; } function textRun(node: RawNode): string { let out = ""; for (const c of node.children) out += isText(c) ? c.text : textRun(c); return out; } describe("walker whitespace-only text nodes", () => { let browser: Browser; let page: Page; before(async () => { browser = await chromium.launch(); page = await browser.newPage(); }); after(async () => { await browser.close(); }); const capture = async (html: string) => { await page.setContent(html); // tsx/esbuild wraps functions with a __name() helper for stack traces; the // serialized collectPage carries those calls, so shim it (same as capture.ts). await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); return page.evaluate(collectPage); }; it("keeps the lone space that is the only child of an inline element", async () => { // Real case (ooni.com): the space between "of" and "the" lives alone inside // ; dropping it fuses the adjacent text runs ("ofthe"). const snap = await capture("

Creator of the world's

"); const p = findByTag(snap.root, "p")!; const strong = p.children.find((c) => !isText(c) && c.tag === "strong") as RawNode; assert.deepEqual(strong.children, [{ text: " " }]); assert.equal(textRun(p), "Creator of the world's"); }); it("still keeps the single space between inline elements", async () => { const snap = await capture("

a b

"); const p = findByTag(snap.root, "p")!; assert.equal(textRun(p), "a b"); }); it("does not emit a space inside an empty block container", async () => { const snap = await capture("
\n \n
"); const section = findByTag(snap.root, "section")!; assert.deepEqual(section.children, []); }); }); function findByClass(root: RawNode, cls: string): RawNode | null { if ((root.attrs?.class ?? "").split(/\s+/).includes(cls)) return root; for (const c of root.children) { if (isText(c)) continue; const hit = findByClass(c, cls); if (hit) return hit; } return null; } describe("walker off-screen visibility", () => { let browser: Browser; let page: Page; before(async () => { browser = await chromium.launch(); page = await browser.newPage(); await page.setViewportSize({ width: 375, height: 768 }); }); after(async () => { await browser.close(); }); const capture = async (html: string) => { await page.setContent(html); await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); return page.evaluate(collectPage); }; it("marks a fixed off-screen-left drawer's un-hidden inner content invisible", async () => { // Real case (ooni.com): a slide-in search drawer is a position:fixed box parked at // x:-375 (right edge at 0) with visibility:hidden; its inner content sets // visibility:visible (so getComputedStyle reports it visible) but the whole box is // still off the left edge. The drawer must not leak into the clone's DOM text. const snap = await capture(`

Hello

`); const title = findByClass(snap.root, "drawer-title")!; assert.equal(title.visible, false, "off-screen-left drawer title should be invisible"); // The genuinely on-screen paragraph is still visible (sanity that the gate isn't over-broad). const onscreen = findByClass(snap.root, "onscreen")!; assert.equal(onscreen.visible, true, "on-screen content stays visible"); }); it("keeps a partially-overlapping negative-margin decoration visible", async () => { // A decoration pulled left by a negative margin so it straddles the left edge // (x:-40, width:200 -> right edge +160) still paints and must stay visible. const snap = await capture(`
peek
`); const deco = findByClass(snap.root, "deco")!; assert.ok(deco.bbox.x < 0, "decoration starts off-screen left"); assert.ok(deco.bbox.x + deco.bbox.width > 0, "but its right edge is on-screen"); assert.equal(deco.visible, true, "partially-overlapping decoration stays visible"); }); it("keeps normal in-flow content below the fold visible", async () => { // The page scrolls vertically, so content below the viewport is reachable and must // NOT be marked invisible (only position:fixed boxes are pinned). const snap = await capture(`

Below the fold

`); const below = findByClass(snap.root, "belowfold")!; assert.ok(below.bbox.y > 768, "content is below the 768px viewport"); assert.equal(below.visible, true, "below-fold in-flow content stays visible"); }); it("marks a computed-visibility:hidden subtree invisible", async () => { // A subtree whose computed visibility is actually hidden (and does not set // visibility:visible on any descendant) is not painted. const snap = await capture(`
secret
`); const child = findByClass(snap.root, "hidden-child")!; assert.equal(child.visible, false, "computed-hidden child is invisible"); }); it("rejects a fixed box parked entirely below the viewport", async () => { // A position:fixed banner pinned below the viewport bottom never scrolls into view. const snap = await capture(`
hidden banner
`); const fixed = findByClass(snap.root, "fixedlow")!; assert.equal(fixed.visible, false, "fixed box below the viewport is invisible"); }); }); describe("walker font-metric probe tagging (fix 4)", () => { let browser: Browser; let page: Page; before(async () => { browser = await chromium.launch(); page = await browser.newPage(); await page.setViewportSize({ width: 375, height: 768 }); }); after(async () => { await browser.close(); }); const capture = async (html: string) => { await page.setContent(html); await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); return page.evaluate(collectPage); }; it("tags a far-off-screen, non-painting measurement scratch node as a probe", async () => { // The classic font-metric probe pattern (WordPress/typography libs): absolutely positioned, // parked ~100000px off-screen, visibility:hidden, holding measurement text. const snap = await capture(`

Real content

`); const probe = findByClass(snap.root, "probe")!; assert.equal(probe.probe, true, "far-off-screen hidden scratch node is a probe"); const real = findByClass(snap.root, "real")!; assert.ok(!real.probe, "real content is not a probe"); }); it("does NOT tag a near-off-screen hidden drawer (real content) as a probe", async () => { // A slide-in drawer parked just off the left edge (x:-375, visibility:hidden) is real // content that a controller can reveal — it must NOT be mistaken for a measurement probe. const snap = await capture(` `); const drawer = findByClass(snap.root, "drawer")!; assert.ok(!drawer.probe, "a near-off-screen drawer is not a probe"); }); it("does NOT tag an sr-only (visible, on-screen-adjacent) accessibility label as a probe", async () => { // Screen-reader-only text stays visibility:visible so AT can read it; even parked far off // via left:-9999px it must survive (and 9999px is under the 10000px probe threshold anyway). const snap = await capture(` Skip to contentHome`); const sr = findByClass(snap.root, "sr")!; assert.ok(!sr.probe, "sr-only accessible text is not a probe"); }); }); describe("walker sizing probe: circular authored-height guard", () => { let browser: Browser; let page: Page; before(async () => { browser = await chromium.launch(); page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); }); after(async () => { await browser.close(); }); const capture = async (html: string) => { await page.setContent(html); await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); return page.evaluate(collectPage); }; it("keeps an explicit 100vh height (circular hero/fill-child pair)", async () => { // A hero authored `height:100vh` with a `height:100%` fill child: setting the hero to // `height:auto` still reproduces its box because the child pins it back, so the raw probe // would read hAuto:true and the authored 100vh would be dropped — hero collapses to 0. const snap = await capture(`

content

`); const hero = findByClass(snap.root, "hero")!; assert.ok(hero.sizing, "hero was probed"); assert.equal(hero.sizing!.hAuto, false, "explicit 100vh is not content-sized"); assert.equal(hero.sizing!.hFill, false, "explicit 100vh is authored, not a parent fill"); // Box actually equals the viewport height (720), proving the circular reproduction. assert.ok(Math.abs(hero.bbox.height - 720) <= 1, "hero rendered at 100vh"); }); it("keeps an explicit px height whose fill child reproduces it", async () => { const snap = await capture(`

content

`); const section = findByClass(snap.root, "section")!; assert.ok(section.sizing, "section was probed"); assert.equal(section.sizing!.hAuto, false, "explicit 400px is not content-sized"); assert.equal(section.sizing!.hFill, false, "explicit 400px is authored, not a fill"); assert.ok(Math.abs(section.bbox.height - 400) <= 1, "section rendered at 400px"); }); it("keeps an explicit height authored via inline style", async () => { const snap = await capture(`

content

`); const box = findByClass(snap.root, "box")!; assert.ok(box.sizing, "box was probed"); assert.equal(box.sizing!.hAuto, false, "inline explicit height is kept"); assert.equal(box.sizing!.hFill, false, "inline explicit height is not a fill"); }); it("resolves the mutual parent/child pair without disturbing the fill child", async () => { // The child is a GENUINE fill (height:100%) and must keep hFill:true / hAuto:false so the // generator emits h-full for it; only the parent's circular verdict is corrected. const snap = await capture(`

content

`); const outer = findByClass(snap.root, "outer")!; const inner = findByClass(snap.root, "inner")!; assert.equal(outer.sizing!.hAuto, false, "parent explicit height kept"); assert.equal(outer.sizing!.hFill, false, "parent is authored, not a fill"); // The child authors only `height:100%` (a fill), so the explicit-height guard must NOT fire on // it — hFill stays true so the generator can still emit h-full for the genuine fill child. assert.equal(inner.sizing!.hFill, true, "child still fills the definite parent"); }); it("still detects a genuinely content-sized (auto) height as hAuto", async () => { // No authored height anywhere: the box is content-sized and must stay droppable. const snap = await capture(`

just some flowing text content

`); const wrap = findByClass(snap.root, "wrap")!; assert.ok(wrap.sizing, "wrap was probed"); assert.equal(wrap.sizing!.hAuto, true, "content-sized height is still auto"); }); it("does not treat a percentage or zero authored height as explicit", async () => { // height:100% is the FILL case (handled by hFill), and height:0 is not definite; neither // should trip the explicit-height override. const snap = await capture(`

x

`); const pct = findByClass(snap.root, "pct")!; assert.ok(pct.sizing, "pct was probed"); // A true fill child: hFill true, hAuto false — untouched by the explicit-height guard. assert.equal(pct.sizing!.hFill, true, "percentage height stays a fill"); assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized"); }); }); describe("walker text-wrap capture", () => { let browser: Browser; let page: Page; before(async () => { browser = await chromium.launch(); page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); }); after(async () => { await browser.close(); }); const capture = async (html: string) => { await page.setContent(html); await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); return page.evaluate(collectPage); }; it("captures text-wrap:balance on a heading (modern line-balancing)", async () => { // Real case: a hero heading authored `text-wrap:balance` wraps its two lines evenly; without // capturing the prop the clone wraps it lopsidedly. const snap = await capture(`

BUILT RUGGED. WORN DAILY.

`); const h1 = findByTag(snap.root, "h1")!; assert.equal(h1.computed.textWrap, "balance", "text-wrap:balance is captured"); }); it("captures text-wrap:pretty", async () => { const snap = await capture(`

Some flowing paragraph text here.

`); const p = findByTag(snap.root, "p")!; assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured"); }); }); describe("walker shadow-DOM composed-tree serialization (FIX 1)", () => { let browser: Browser; let page: Page; before(async () => { browser = await chromium.launch(); page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); }); after(async () => { await browser.close(); }); // Attach an OPEN shadow root to any host carrying data-shadow, injecting its data-shadow value as // the shadow tree HTML. Runs in-page before collectPage so getComputedStyle/bbox see the composed tree. const capture = async (html: string) => { await page.setContent(html); await page.evaluate(() => { for (const host of Array.from(document.querySelectorAll("[data-shadow]"))) { const markup = host.getAttribute("data-shadow") || ""; const sr = (host as HTMLElement).attachShadow({ mode: "open" }); sr.innerHTML = markup; } }); await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); return page.evaluate(collectPage); }; it("serializes an open custom element's shadow tree as the host's children", async () => { // A `` web component renders its swatches/title/price INSIDE its shadow root; a // childNodes-only walk captured it empty. The composed walk must surface the shadow content. const snap = await capture(` `); const host = findByTag(snap.root, "product-info")!; assert.ok(host, "the custom element host is present"); assert.equal(host.shadowHost, true, "the host is tagged as a shadow host"); assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Magnolia Shirt $78.00", "the shadow tree text is captured"); const title = findByClass(host, "pi-title")!; assert.ok(title, "a shadow descendant node is serialized"); assert.equal(title.inShadow, true, "shadow descendants are tagged inShadow"); assert.ok(title.bbox.width > 0, "shadow nodes get real bboxes via getBoundingClientRect"); }); it("renders the FLATTENED tree: a is replaced by its assigned light-DOM nodes", async () => { // The host's light child (the assigned node) renders at the slot position — once, and NOT tagged // inShadow (it is the author's real content). Shadow chrome around the slot still appears. const snap = await capture(`

Vintage Sunset T-Shirt

`); const host = findByTag(snap.root, "my-card")!; const frame = findByClass(host, "card-frame")!; assert.ok(frame, "the shadow frame around the slot is serialized"); assert.equal(frame.inShadow, true, "the shadow frame is tagged inShadow"); const slotted = findByClass(host, "slotted")!; assert.ok(slotted, "the slotted light child renders at the slot position"); assert.equal(textRun(slotted).trim(), "Vintage Sunset T-Shirt"); assert.ok(!slotted.inShadow, "the slotted light child is NOT tagged inShadow (author content)"); // No double-serialization: exactly one node carries the slotted text. let count = 0; const countText = (n: RawNode): void => { if (n.children.some((c) => isText(c) && c.text.includes("Vintage Sunset T-Shirt"))) count++; for (const c of n.children) if (!isText(c)) countText(c as RawNode); }; countText(host); assert.equal(count, 1, "the slotted child is serialized exactly once"); }); it("uses fallback content when nothing is assigned", async () => { const snap = await capture(` `); const host = findByTag(snap.root, "my-badge")!; assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Default Label", "unfilled slot falls back to its own content"); }); it("does not tag ordinary light-DOM nodes as shadow", async () => { const snap = await capture(`

light

`); const plain = findByClass(snap.root, "plain")!; assert.ok(!plain.shadowHost, "a plain div is not a shadow host"); assert.ok(!plain.inShadow, "plain light DOM is not tagged inShadow"); }); });