Render every <wn-*> in a real browser (Playwright, --no-sandbox; reuses an external static server when present) and write the adapter-contract parity result to adapters/lit/parity/_parity.json: - Contract parity: element must upgrade + carry no attribute-mismatch vs IR (computed statically via scaffold.componentDrift, avoiding runtime type-coercion false positives). prop-missing is a coverage note, not a failure. - Visual parity: render smoke (non-empty + positive box) + per-component screenshot artifact (gitignored). Pixel-exact regression stays with the Playwright baseline suite; IR exemplars are gallery cards, not single-component baselines, so they are the human reference, not an auto pixel gate. - Result: 10 components, contractFail=0 visualFail=0, PipelineStrip skipped (wn-pipeline-strip rename drift). Exit 4 on failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
8.5 KiB
JavaScript
174 lines
8.5 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 } 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 results = [];
|
|
for (const c of contracts) {
|
|
const tag = c.tag;
|
|
const drift = componentDrift(c, byTag);
|
|
const attrMismatch = drift.issues.filter((i) => i.kind === "attribute-mismatch");
|
|
const missing = drift.issues.filter((i) => i.kind === "prop-missing");
|
|
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();
|