Generated-code hygiene: listener cleanup, shared cn(), whitespace collapse, probe stripping, sub-pixel snapping, clone-origin metadata, dependency pinning
- DittoWire/Accordion/DropdownMenu runtime templates: AbortController-based listener cleanup, idempotent effects, orphan panel removal on unmount - Emit a single src/lib/utils.ts cn() instead of a copy per component file - Collapse captured whitespace runs under white-space:normal (pre* preserved) - Tag source-injected font-metric probe nodes at capture; drop them from IR - Snap sub-pixel arbitrary lengths (integer within 0.1px, else 1 decimal) - Emit SITE_ORIGIN (src/lib/site.ts, env-overridable): sitemap/robots/ canonical/og:url/JSON-LD resolve against the clone's own origin instead of the source domain - Pin lottie-web in generated package.json whenever DittoLottie is emitted 144 tests pass (21 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
dfcd4a6563
commit
77be868ee3
@@ -0,0 +1,167 @@
|
||||
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 {
|
||||
CN_UTILS_MODULE,
|
||||
cnImportLine,
|
||||
componentFiles,
|
||||
generatePageTsx,
|
||||
injectLottieDep,
|
||||
PACKAGE_JSON,
|
||||
PACKAGE_JSON_TW,
|
||||
PACKAGE_JSON_VITE,
|
||||
PACKAGE_JSON_VITE_TW,
|
||||
type ComponentRegistry,
|
||||
} from "../src/generate/app.js";
|
||||
import { DITTO_WIRE_TSX, ACCORDION_TSX } from "../src/generate/interactive.js";
|
||||
import { DROPDOWN_MENU_TSX } from "../src/generate/menu.js";
|
||||
import { declToUtil, snapLen } from "../src/generate/tailwind.js";
|
||||
|
||||
const VPS = [1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...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: vp, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/",
|
||||
title: "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: 4,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Fix 1: emitted runtime components clean up their listeners ----
|
||||
describe("runtime components abort their listeners on unmount (fix 1)", () => {
|
||||
const templates: Array<[string, string]> = [
|
||||
["DittoWire", DITTO_WIRE_TSX],
|
||||
["Accordion", ACCORDION_TSX],
|
||||
["DropdownMenu", DROPDOWN_MENU_TSX],
|
||||
];
|
||||
for (const [name, tsx] of templates) {
|
||||
it(`${name}: one AbortController, every addEventListener signalled, cleanup returns abort`, () => {
|
||||
assert.ok(tsx.includes("new AbortController()"), "creates an AbortController");
|
||||
assert.ok(tsx.includes("ac.abort()"), "effect cleanup aborts");
|
||||
// Every addEventListener must carry a listener-options object with the signal (either
|
||||
// `, { signal })` or `, { passive: true, signal })`). Count-match guarantees none is missed.
|
||||
const adds = (tsx.match(/addEventListener\(/g) ?? []).length;
|
||||
const signalled = (tsx.match(/, \{ signal \}\)/g) ?? []).length
|
||||
+ (tsx.match(/, \{ passive: true, signal \}\)/g) ?? []).length;
|
||||
assert.ok(adds > 0, "has listeners");
|
||||
assert.equal(signalled, adds, "every addEventListener passes the abort signal");
|
||||
// The stale re-wire guard is gone; effects are idempotent + cleaned up instead.
|
||||
assert.ok(!tsx.includes("wired.current"), "no wired.current guard");
|
||||
});
|
||||
}
|
||||
|
||||
it("DropdownMenu removes any still-open panels on unmount", () => {
|
||||
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels"), "tracks open panels");
|
||||
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels.splice(0)) p.remove()"), "removes panels on cleanup");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 2: cn() is a single shared module, imported (not copied per file) ----
|
||||
describe("cn() is deduplicated into src/lib/utils (fix 2)", () => {
|
||||
it("exports a single cn from the shared utils module", () => {
|
||||
assert.ok(CN_UTILS_MODULE.includes("export function cn("), "utils module exports cn");
|
||||
});
|
||||
|
||||
it("cnImportLine resolves to the shared module at the right depth", () => {
|
||||
assert.equal(cnImportLine(1), 'import { cn } from "../lib/utils";');
|
||||
assert.equal(cnImportLine(2), 'import { cn } from "../../lib/utils";');
|
||||
});
|
||||
|
||||
it("a component module that uses cn() imports it rather than redefining it", () => {
|
||||
// componentFiles reads only funcDefs / byName / fieldTypes / dataDecls / styleDecls.
|
||||
const reg = {
|
||||
byName: new Map([["Card", { runs: 1, instances: 2, cids: ["n1"] }]]),
|
||||
funcDefs: new Map([["Card", 'function Card({ styles }: { styles: string }) {\n return <div className={cn("p-4", styles)} />;\n}']]),
|
||||
fieldTypes: new Map(),
|
||||
dataDecls: [],
|
||||
cidDecls: [],
|
||||
styleDecls: [],
|
||||
} as unknown as ComponentRegistry;
|
||||
const files = componentFiles(reg);
|
||||
const card = files.find((f) => f.name === "Card")!.module;
|
||||
assert.ok(card.includes('import { cn } from "../../lib/utils";'), "imports shared cn");
|
||||
assert.ok(!card.includes("function cn("), "no inline cn definition");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 3: JSX whitespace is collapsed under white-space: normal ----
|
||||
describe("JSX whitespace collapses under white-space:normal (fix 3)", () => {
|
||||
it("collapses captured \\n\\t indentation and multi-space runs to single spaces", () => {
|
||||
const p = node("n1", "p", computed(), [{ text: "\n\t\tSkip to content\n\t\t" }]);
|
||||
const root = node("n0", "body", computed(), [p]);
|
||||
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
|
||||
assert.ok(!/\{"[^"]*\\n[^"]*"\}/.test(tsx), "no literal \\n frozen in text");
|
||||
assert.ok(!/\{"\s{2,}"\}/.test(tsx), "no multi-space whitespace literal");
|
||||
assert.ok(tsx.includes("Skip to content"), "content preserved");
|
||||
});
|
||||
|
||||
it("preserves whitespace verbatim inside a <pre> (white-space: pre)", () => {
|
||||
const pre = node("n1", "pre", computed({ whiteSpace: "pre" }), [{ text: "line1\n\tline2" }]);
|
||||
const root = node("n0", "body", computed(), [pre]);
|
||||
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
|
||||
assert.ok(tsx.includes("line1\\n\\tline2"), "pre keeps raw newlines/tabs");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 5: sub-pixel arbitrary values snap ----
|
||||
describe("sub-pixel lengths snap (fix 5)", () => {
|
||||
it("snapLen keeps genuine fractions at 1 decimal, snaps near-integer jitter to an integer", () => {
|
||||
assert.equal(snapLen("204.797px"), "204.8px");
|
||||
assert.equal(snapLen("627.188px"), "627.2px");
|
||||
assert.equal(snapLen("100.3px"), "100.3px");
|
||||
assert.equal(snapLen("204.98px"), "205px"); // within 0.1px of an integer → snap
|
||||
assert.equal(snapLen("2.996px"), "3px"); // rem/px jitter snaps
|
||||
assert.equal(snapLen("627px"), "627px"); // already clean
|
||||
});
|
||||
|
||||
it("snapLen leaves non-simple values (calc/percent/multi-token) untouched", () => {
|
||||
assert.equal(snapLen("calc(100% - 3.333px)"), "calc(100% - 3.333px)");
|
||||
assert.equal(snapLen("50.5%"), "50.5%");
|
||||
assert.equal(snapLen("0px 2.5px"), "0px 2.5px");
|
||||
});
|
||||
|
||||
it("declToUtil snaps a width arbitrary value but keeps border-width exact", () => {
|
||||
assert.equal(declToUtil("width", "204.797px"), "w-[204.8px]");
|
||||
// Border width sub-pixel precision is load-bearing — left untouched.
|
||||
assert.equal(declToUtil("border-width", "0.667px"), "border-[0.667px]");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 7: every emitted runtime import is a declared dependency ----
|
||||
describe("lottie-web is declared when its import is emitted (fix 7)", () => {
|
||||
it("injectLottieDep pins lottie-web into every package.json template", () => {
|
||||
for (const pkg of [PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW]) {
|
||||
const injected = injectLottieDep(pkg);
|
||||
const deps = JSON.parse(injected).dependencies as Record<string, string>;
|
||||
assert.equal(deps["lottie-web"], "5.12.2", "lottie-web pinned to the harness version");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -98,3 +98,21 @@ describe("IR prune keeps <source> media candidates", () => {
|
||||
assert.equal(findByTag(root, "picture"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IR drops font-metric probe nodes (fix 4)", () => {
|
||||
it("excludes a walker-tagged probe node from the IR, keeping its siblings", () => {
|
||||
const probe: RawNode = {
|
||||
...raw("div", { class: "font-probe" }, [{ text: "Mgy" }]),
|
||||
probe: true,
|
||||
};
|
||||
const real = raw("h1", { class: "title" }, [{ text: "Heading" }]);
|
||||
const body = raw("body", {}, [real, probe]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
|
||||
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||
assert.deepEqual(kept, ["h1"], "probe div is dropped, the real heading survives");
|
||||
// Its text must not leak into the tree either.
|
||||
assert.equal(findByTag(root, "div"), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,3 +195,54 @@ describe("generated Next config", () => {
|
||||
assert.ok(NEXT_CONFIG.includes("devIndicators: false"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEO origin references the clone, not the source domain (fix 6)", () => {
|
||||
it("sitemap.ts / robots.ts resolve against SITE_ORIGIN, not the source origin", () => {
|
||||
const ir = fixtureIr();
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const files = seoRouteFiles(report, [routeSummaryFromIr(ir, "/", "/", ir.doc.sourceUrl)]);
|
||||
const robots = files.find(([p]) => p === "robots.ts")![1];
|
||||
const sitemap = files.find(([p]) => p === "sitemap.ts")![1];
|
||||
for (const body of [robots, sitemap]) {
|
||||
assert.ok(body.includes('import { SITE_ORIGIN } from "../lib/site";'), "imports SITE_ORIGIN");
|
||||
assert.ok(body.includes("SITE_ORIGIN +"), "builds URLs from SITE_ORIGIN");
|
||||
assert.ok(!body.includes("example.test"), "never bakes the source domain");
|
||||
}
|
||||
// sitemap path is relativized off the source origin.
|
||||
assert.ok(sitemap.includes('SITE_ORIGIN + "/seo"') || sitemap.includes('SITE_ORIGIN + "/"'));
|
||||
});
|
||||
|
||||
it("metadata sets metadataBase from SITE_ORIGIN and relativizes the canonical", () => {
|
||||
const ir = fixtureIr();
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const metadata = metadataExport(report);
|
||||
assert.ok(metadata.includes('new URL(SITE_ORIGIN || "http://localhost:3000")'), "metadataBase from SITE_ORIGIN");
|
||||
assert.ok(metadata.includes('"canonical": "/seo"'), "canonical relativized to a path");
|
||||
// The source domain must not survive in canonical (og:image assets are localized elsewhere).
|
||||
assert.ok(!metadata.includes('"canonical": "https://example.test'), "canonical is not absolute to source");
|
||||
});
|
||||
|
||||
it("relativizes og:url off the source origin", () => {
|
||||
const ir = fixtureIr();
|
||||
ir.doc.head!.meta!.push({ property: "og:url", content: "https://example.test/seo" });
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const metadata = metadataExport(report);
|
||||
assert.ok(metadata.includes('"url": "/seo"'), "og:url relativized to a path");
|
||||
assert.ok(!metadata.includes('"url": "https://example.test/seo"'), "og:url not absolute to source");
|
||||
});
|
||||
|
||||
it("rewrites on-origin JSON-LD @id/url off the source domain via SITE_ORIGIN", () => {
|
||||
const ir = fixtureIr();
|
||||
// JSON-LD carrying the source origin in @id/url (escaped-slash form, like WordPress emits).
|
||||
ir.doc.head!.jsonLd = [{
|
||||
id: "graph",
|
||||
text: '{"@context":"https:\\/\\/schema.org","@id":"https:\\/\\/example.test\\/#website","url":"https:\\/\\/example.test\\/"}',
|
||||
}];
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const markup = jsonLdHeadMarkup(report);
|
||||
assert.ok(markup.includes(".join(SITE_ORIGIN)"), "rejoins segments with SITE_ORIGIN at runtime");
|
||||
// The source origin must not survive as a literal (schema.org context is off-origin, kept).
|
||||
assert.ok(!markup.includes("example.test"), "source origin removed from JSON-LD");
|
||||
assert.ok(markup.includes("schema.org"), "off-origin @context left untouched");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,3 +158,56 @@ describe("walker off-screen visibility", () => {
|
||||
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(`
|
||||
<p class="real">Real content</p>
|
||||
<div class="probe" style="position:absolute;top:-99999px;left:-99999px;
|
||||
visibility:hidden;white-space:nowrap;">Mgy</div>`);
|
||||
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(`
|
||||
<div class="drawer" style="position:fixed;left:0;top:0;width:375px;height:768px;
|
||||
transform:translateX(-100%);visibility:hidden;">
|
||||
<h2 class="dtitle">Menu</h2>
|
||||
</div>`);
|
||||
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(`
|
||||
<a href="/"><span class="sr" style="position:absolute;left:-9999px;">Skip to content</span>Home</a>`);
|
||||
const sr = findByClass(snap.root, "sr")!;
|
||||
assert.ok(!sr.probe, "sr-only accessible text is not a probe");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user