// ============================================================= // adapters/lit/parity.mjs — make parity-lit (WHYNOT-WP-0002 · T08) // // The gate that confirms the Lit elements actually honour the IR contract. // Renders each in a real browser (Playwright) and checks: // // (a) CONTRACT parity — for every IR-declared portable prop the element HAS, // setting the IR-declared attribute must drive the property (no attribute // contradiction). A prop the element lacks is a *coverage* note, not a // contradiction — it is already surfaced by `make adapt-lit` drift (T07). // (b) VISUAL parity — the element renders non-empty with a positive box; a // screenshot is saved to adapters/lit/parity/.png as the artifact. // // On pixel-exact appearance: `ir/exemplars/.html` are designbook *gallery // cards* (a grid of all variants), not single-component baselines, so a direct // pixel diff against them is not meaningful. Per-component Lit appearance // regression is owned by the Playwright baseline suite (tests/visual/). Visual // parity here is a render smoke + artifact; the exemplar is the human reference. // // Result: adapters/lit/parity/_parity.json (adapters/ADAPTER_CONTRACT.md shape). // Exit: 0 pass · 2 usage · 4 parity failure · 5 internal. // ============================================================= import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { spawn } from "node:child_process"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parseLitElements, componentDrift, loadAccepted } from "./scaffold.mjs"; const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const IR_COMPONENTS = join(REPO, "ir", "components"); const OUT = join(REPO, "adapters", "lit", "parity"); const PORT = 4399; async function loadChromium() { for (const id of ["@playwright/test", "playwright", "playwright-core"]) { try { return (await import(id)).chromium; } catch { /* next */ } } console.error("parity: Playwright not installed (need @playwright/test)."); process.exit(2); } function waitForServer(url, tries = 30) { return new Promise((resolve, reject) => { const tick = (n) => fetch(url).then(() => resolve()).catch(() => { if (n <= 0) return reject(new Error("server did not start")); setTimeout(() => tick(n - 1), 200); }); tick(tries); }); } // Representative attribute values from the contract (to exercise rendering). function fixtureAttrs(contract) { const attrs = {}; for (const p of contract.props || []) { if (p.portable === false || p.attribute === false) continue; if (p.type === "enum") attrs[p.attribute] = p.default ?? (p.enum && p.enum[0]) ?? ""; else if (p.type === "number") attrs[p.attribute] = "1"; else if (p.type === "boolean") attrs[p.attribute] = ""; else attrs[p.attribute] = "Sample"; } return attrs; } async function main() { if (!existsSync(IR_COMPONENTS)) { console.error("No ir/components/ — run `make ir`."); process.exit(2); } const contracts = readdirSync(IR_COMPONENTS).filter((f) => f.endsWith(".json")) .map((f) => JSON.parse(readFileSync(join(IR_COMPONENTS, f), "utf8"))) .sort((a, b) => a.name.localeCompare(b.name)); mkdirSync(OUT, { recursive: true }); for (const f of readdirSync(OUT)) if (f.endsWith(".png")) rmSync(join(OUT, f)); const url = `http://127.0.0.1:${PORT}/examples/showcase/index.html`; // Reuse an already-running static server (set by the caller / CI); only spawn // and manage our own if none is up. Spawning is what some sandboxes dislike. let server = null; const alreadyUp = await fetch(url).then(() => true).catch(() => false); if (!alreadyUp) { server = spawn("python3", ["-m", "http.server", String(PORT), "--bind", "127.0.0.1"], { cwd: REPO, stdio: "ignore" }); } const stopServer = () => { if (server) server.kill(); }; const chromium = await loadChromium(); let browser; try { await waitForServer(url); browser = await chromium.launch({ args: ["--no-sandbox"] }); const page = await browser.newPage({ viewport: { width: 800, height: 600 } }); await page.route(/fonts\.(googleapis|gstatic)\.com/, (r) => r.abort()); // The showcase page registers every wn-* element and loads components.css. await page.goto(`http://127.0.0.1:${PORT}/examples/showcase/index.html`, { waitUntil: "commit" }); await page.waitForFunction(() => !!customElements.get("wn-button"), null, { timeout: 8000 }); await page.addStyleTag({ content: "#parity-stage{position:fixed;left:0;top:0;background:var(--paper);padding:16px;z-index:99999}" }); // Static contract analysis (precise attribute-name correctness, no runtime // type-coercion false positives). The browser then confirms the element // actually upgrades + renders — the thing static analysis cannot prove. const byTag = parseLitElements(REPO); const accepted = loadAccepted(REPO); const results = []; for (const c of contracts) { const tag = c.tag; const drift = componentDrift(c, byTag, accepted); const attrMismatch = drift.issues.filter((i) => i.kind === "attribute-mismatch" && i.severity === "drift"); const missing = drift.issues.filter((i) => i.kind === "prop-missing" && i.severity === "drift"); const hasDefaultSlot = (c.slots || []).some((s) => s.name === "default"); const observed = await page.evaluate(async ({ tag, attrs, hasDefaultSlot }) => { if (!customElements.get(tag)) return { exists: false }; let stage = document.getElementById("parity-stage"); if (!stage) { stage = document.createElement("div"); stage.id = "parity-stage"; document.body.appendChild(stage); } stage.innerHTML = ""; const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); if (hasDefaultSlot) el.textContent = "Sample"; stage.appendChild(el); await el.updateComplete?.catch(() => {}); const r = el.getBoundingClientRect(); const rendered = el.children.length > 0 || (el.textContent || "").trim().length > 0 || !!el.shadowRoot; return { exists: true, rendered, rect: { w: Math.round(r.width), h: Math.round(r.height) } }; }, { tag, attrs: fixtureAttrs(c), hasDefaultSlot }); if (!observed.exists) { results.push({ name: c.name, contract: "skip", visual: "skip", diffRatio: null, notes: `no <${tag}> element (see adapters/lit/drift/${c.name}.md)` }); continue; } // Screenshot the element box as the visual artifact. try { const h = await page.$("#parity-stage > *"); if (h) await h.screenshot({ path: join(OUT, `${c.name}.png`) }); } catch { /* non-fatal */ } const contract = attrMismatch.length ? "fail" : "pass"; const visual = observed.rendered && (observed.rect.w > 0 || observed.rect.h > 0) ? "pass" : "fail"; const notes = []; if (attrMismatch.length) notes.push(`attribute-mismatch: ${attrMismatch.map((i) => `${i.prop} expected '${i.expected}' got '${i.actual}'`).join(", ")}`); if (missing.length) notes.push(`coverage (drift, not gated): missing ${missing.map((i) => i.prop).join(", ")}`); results.push({ name: c.name, contract, visual, diffRatio: null, box: observed.rect, notes: notes.join("; ") || "ok" }); } const summary = { total: results.length, contractFail: results.filter((r) => r.contract === "fail").length, visualFail: results.filter((r) => r.visual === "fail").length, skipped: results.filter((r) => r.contract === "skip").length, }; const out = { stack: "lit", generatedAt: new Date().toISOString(), components: results, summary }; writeFileSync(join(OUT, "_parity.json"), JSON.stringify(out, null, 2) + "\n"); console.log(`parity-lit: ${summary.total} components · contractFail=${summary.contractFail} · visualFail=${summary.visualFail} · skip=${summary.skipped}`); for (const r of results) { const flag = r.contract === "fail" || r.visual === "fail" ? "✗" : r.contract === "skip" ? "·" : "✓"; console.log(` ${flag} ${r.name}: contract=${r.contract} visual=${r.visual}${r.notes && r.notes !== "ok" ? ` (${r.notes})` : ""}`); } await browser.close(); stopServer(); if (summary.contractFail || summary.visualFail) { console.log("parity-lit: FAILURE — see adapters/lit/parity/_parity.json. Exit 4."); process.exit(4); } console.log("parity-lit: pass."); } catch (e) { if (browser) await browser.close().catch(() => {}); stopServer(); console.error("parity-lit: internal error —", e.message); process.exit(5); } } main();