// wasm/tests/functional.mjs // Functional/browser tests — boot a static HTTP server over dist/, drive // the SPA with headless Chromium via Playwright. Asserts: // * page loads, editor + tier/program radio groups present // * Each demo runs on the C and asm tiers and produces the canonical // native Python output. // * "All three" mode renders three tier blocks. // // Python (Pyodide) tier is exercised but its output is allowed to drift — // loading Pyodide from CDN is flaky in CI and adds ~10 MB; we still assert // it produces some output and matches roughly. import { chromium } from "playwright"; import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, "..", "dist"); const repoRoot = path.resolve(here, "..", ".."); const demosDir = path.join(here, "..", "app", "demos"); const MIME = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".wasm": "application/wasm", ".json": "application/json", ".lsp": "text/plain", ".py": "text/x-python", }; function startServer(root, port) { return new Promise((resolve) => { const srv = http.createServer((req, res) => { const url = new URL(req.url, "http://localhost"); let p = path.join(root, decodeURIComponent(url.pathname)); if (fs.existsSync(p) && fs.statSync(p).isDirectory()) p = path.join(p, "index.html"); if (!fs.existsSync(p)) { res.writeHead(404); res.end("not found"); return; } const ext = path.extname(p).toLowerCase(); const ct = MIME[ext] || "application/octet-stream"; res.writeHead(200, { "content-type": ct, "cross-origin-opener-policy": "same-origin", "cross-origin-embedder-policy": "require-corp", "cache-control": "no-store", }); fs.createReadStream(p).pipe(res); }); srv.listen(port, () => resolve(srv)); }); } const state = { pass: 0, fail: 0 }; function check(name, cond, detail) { if (cond) { state.pass++; console.log(` ✓ ${name}`); } else { state.fail++; console.log(` ✗ ${name}${detail ? "\n " + detail : ""}`); } } function nativePython(demoPath) { return execFileSync("python3", [path.join(repoRoot, "lumbda.py"), "--fast", demoPath], { encoding: "utf8", timeout: 120000, }); } (async () => { const port = 8091; const server = await startServer(dist, port); const baseURL = `http://localhost:${port}/`; const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext(); const page = await ctx.newPage(); page.on("pageerror", (e) => console.log(" ⟂ pageerror:", e.message)); page.on("console", (msg) => { if (msg.type() === "error") console.log(" ⟂ console.error:", msg.text()); }); page.on("requestfailed", (req) => console.log(" ⟂ request failed:", req.url(), req.failure()?.errorText)); try { await page.goto(baseURL, { waitUntil: "networkidle" }); check("page loads", await page.title() !== ""); check("editor mounted", (await page.locator(".cm-editor").count()) > 0); check("6 program radios (4 demos + bend-gpu + free-form)", (await page.locator('input[name="program"]').count()) === 6); check("4 tier radios", (await page.locator('input[name="tier"]').count()) === 4); // Test each demo on C tier (fastest, deterministic). for (const demo of ["mandelbrot", "fib-ack", "sieve", "self-interp"]) { await page.locator(`input[name="program"][value="${demo}"]`).check(); await page.locator('input[name="tier"][value="c"]').check(); // wait for editor to update await page.waitForTimeout(150); await page.locator("#run").click(); // wait for status to read ok or err await page.waitForFunction( () => /ok|err|completed/i.test(document.getElementById("status").textContent), null, { timeout: 30000 }); const output = (await page.locator("#output pre").first().textContent()) || ""; const demoPath = path.join(demosDir, demo + ".lsp"); const canonical = nativePython(demoPath); check(`${demo} on C tier matches canonical`, output === canonical, ` expected: ${JSON.stringify(canonical.slice(0, 60))}…\n got: ${JSON.stringify(output.slice(0, 60))}…`); } // Asm tier — fib-ack only (smaller program, faster). await page.locator('input[name="program"][value="fib-ack"]').check(); await page.locator('input[name="tier"][value="asm"]').check(); await page.waitForTimeout(150); await page.locator("#run").click(); await page.waitForFunction( () => /ok|err|completed/i.test(document.getElementById("status").textContent), null, { timeout: 30000 }); const asmOut = (await page.locator("#output pre").first().textContent()) || ""; const canonicalFA = nativePython(path.join(demosDir, "fib-ack.lsp")); check("fib-ack on asm tier matches canonical", asmOut === canonicalFA); // "All three" mode — 3 tier-blocks should render. await page.locator('input[name="program"][value="sieve"]').check(); await page.locator('input[name="tier"][value="all"]').check(); await page.waitForTimeout(150); await page.locator("#run").click(); await page.waitForFunction( () => /ok|err|completed/i.test(document.getElementById("status").textContent), null, { timeout: 120000 }); const blocks = await page.locator("#output .tier-block").count(); check(`"all three" mode renders 3 tier blocks (got ${blocks})`, blocks === 3); // State-leak regression: run mandelbrot twice on the cached asm // instance and assert outputs match. Prior to commit 346b873's // env-NIL fix, every-other cell of row 2+ rendered " " instead // of the expected shade char. await page.locator('input[name="program"][value="mandelbrot"]').check(); await page.locator('input[name="tier"][value="asm"]').check(); await page.waitForTimeout(150); await page.locator("#run").click(); await page.waitForFunction( () => /ok|err/i.test(document.getElementById("status").textContent), null, { timeout: 30000 }); const asmRun1 = (await page.locator("#output pre").first().textContent()) || ""; await page.locator("#run").click(); await page.waitForFunction( () => /ok|err/i.test(document.getElementById("status").textContent), null, { timeout: 30000 }); const asmRun2 = (await page.locator("#output pre").first().textContent()) || ""; check("asm mandelbrot stable across two consecutive runs", asmRun1 === asmRun2 && asmRun1.length > 100); } catch (e) { state.fail++; console.log(" ✗ exception:", e.message); } finally { await browser.close(); server.close(); console.log(`\n${state.pass} passed, ${state.fail} failed`); process.exit(state.fail ? 1 : 0); } })();