User-visible changes
- Cancel button — terminates the running worker. Pyodide's slow mandelbrot
no longer freezes the UI; click cancel and the elapsed counter freezes
at "(cancelled @ NNNN ms)".
- Live ms counter ticks per animation frame while a tier is busy, so the
Pyodide tier's ~5-15 s wait is visible instead of looking hung.
- Restyled to match lumbda.com: chunkfive wordmark, --green: #227842
(light) / #5ec07a (dark), lumbda-logo-green.png, lowercase "lumbda"
everywhere. Pulls fonts/chunkfive locally so the playground stays
self-contained.
Architecture
- All tier evaluations now run inside a Web Worker (wasm/app/worker.mjs)
so the main thread stays responsive. Cancel = worker.terminate(); next
eval respawns a fresh worker.
- Loaders use new URL("./...", import.meta.url) so paths resolve against
the loader file's own location — works identically in window and
worker contexts, no baseURL argument needed.
- C tier Emscripten build flipped to EXPORT_ES6=1; loader uses dynamic
`import()` of the factory module. Integration test updated accordingly.
- Python loader uses `import("pyodide.mjs")` (ES module) instead of
document.createElement, which doesn't exist in workers.
Bug fixes
- Asm tier state leak: running the same demo twice on a cached WASM
instance produced corrupted output (every other cell on row 2+ rendered
as " " instead of the expected shade char). Root cause: top-level eval
passed `global_env` as the env, so closures captured stale globals;
fixed by passing NIL — env_lookup falls back to the CURRENT global_env
via its existing two-pass walk. Multi-run regression added to the
functional test suite.
- fib-ack demo: (ack 3 4) was too heavy for Pyodide (minutes). Cut to
(ack 3 3) + (fib 20) max so every tier finishes in seconds.
Test discipline
- Root `make test-all` now includes `wasm-test`. Adding a language
feature without exercising it on all six implementations is no longer
possible by accident.
- Functional suite: 11 assertions (was 10) — adds asm multi-run stability.
- Integration + unit: still 20 + 8.
156 lines
7.3 KiB
JavaScript
156 lines
7.3 KiB
JavaScript
// 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("4 program radios", (await page.locator('input[name="program"]').count()) === 4);
|
|
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);
|
|
}
|
|
})();
|