Files
whynot-design/adapters/lit/parity.mjs
tegwick 05fa31e2b5
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
fix(adapter): resolve all WHYNOT-WP-0002 drift — designbook-refresh green
Triage the three surfaced divergences the governance-correct way (no stack->React
back-edit, no ir/ hand-edit); make adapt-lit/parity-lit/designbook-refresh now
exit 0:

- PipelineStrip: documented TAG_OVERRIDES in scripts/ir-extract.mjs maps the
  React 'PipelineStrip' to the established tag wn-pipeline (the web-component tag
  is an IR-projection detail, not React-dictated; the component name stays
  faithful). Tag now matches the element; parity tests it (no longer skipped).
- PageHeader.actions: the drift detector now collects each element's named slots
  and treats an IR prop honoured by a same-named slot (<slot name="actions">) as
  satisfied (prop-via-slot, informational) rather than prop-missing.
- Sidebar.current: recorded as an auditable accepted divergence in
  adapters/lit/drift.accepted.json (React monolithic 'current' key vs Lit per-item
  'active' on composable <wn-sidebar-item>) — listed, downgraded to info, not gated.

Rendered surfaces (src/, examples/) untouched — verified zero diff; parity renders
all 10 components green. Adapt/parity outputs idempotent (stable re-run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:53:59 +02:00

175 lines
8.6 KiB
JavaScript

// =============================================================
// 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 <wn-*> 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/<Name>.png as the artifact.
//
// On pixel-exact appearance: `ir/exemplars/<Name>.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();