diff --git a/js/browser-driver.mjs b/js/browser-driver.mjs new file mode 100644 index 0000000..4027833 --- /dev/null +++ b/js/browser-driver.mjs @@ -0,0 +1,244 @@ +// The node-only browser page driver (#59's browser half): a static +// server over the consumer's repository root with this package +// self-mounted, a headless Playwright engine running a generated +// harness page, heartbeat-based stall detection, and the +// Chrome-binary ladder. The in-page halves are ./viewer/page-runner.mjs +// and ./viewer/browser-worker.mjs, reached through the self-mount. +// +// This module imports only Node builtins; the caller passes in its own +// playwright-core module, since each npm tree pins its own version. +// +// The page contract: the harness calls `window.__progress(note)` as +// work streams (the heartbeat the stall watchdog observes) and +// `window.__report(outcome)` exactly once at the end, with `{ error }` +// carrying an in-page failure. + +import { access, readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Where this package self-mounts on the harness server. */ +export const MOUNT = "/__component-test"; + +const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +const MIME = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".wasm": "application/wasm", + ".map": "application/json", + ".json": "application/json", +}; + +/** The import-map entries resolving this package's bare specifiers to + * the self-mount (module workers cannot see the map; they receive + * URLs instead). */ +export function componentTestImportMap() { + return { + "@polymorph/component-test-js/harness": `${MOUNT}/js/viewer/harness.mjs`, + "@polymorph/component-test-js/context": `${MOUNT}/js/viewer/context.js`, + "@polymorph/component-test-js/imports": `${MOUNT}/js/viewer/imports.mjs`, + }; +} + +/** + * A minimal harness document: the import map (this package's entries + * plus the caller's), then a module script handing `config` to + * [`runSuitesInPage`]. `config.suites[*].{moduleUrl,coreUrls,importsUrl,contextUrl}` + * must be server-absolute paths. + */ +export function buildHarnessPage({ title = "component-test conformance", importMap = {}, config }) { + const map = JSON.stringify({ imports: { ...componentTestImportMap(), ...importMap } }); + return ` + +${title} + +`; +} + +/** Serve `repoRoot` statically plus the harness page at "/" and this + * package under [`MOUNT`]; `routes(req, res) => boolean` (optional) + * claims a request before the static paths. */ +function serve({ repoRoot, html, routes }) { + const server = createServer(async (req, res) => { + if (routes && (await routes(req, res))) return; + const path = new URL(req.url, "http://localhost").pathname; + if (path === "/") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(html); + return; + } + const file = path.startsWith(`${MOUNT}/`) + ? join(PACKAGE_ROOT, path.slice(MOUNT.length + 1)) + : join(repoRoot, path); + try { + const body = await readFile(file); + res.writeHead(200, { + "content-type": MIME[extname(file)] ?? "application/octet-stream", + }); + res.end(body); + } catch { + res.writeHead(404); + res.end("not found"); + } + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +function launchBrowser(playwright, engine, executablePath, timeout) { + if (engine === "firefox") { + // Gecko's JSPI pref: the transpiled guests suspend on JSPI, which + // Firefox has not yet shipped by default. + return playwright.firefox.launch({ + headless: true, + timeout, + firefoxUserPrefs: { + "javascript.options.wasm_js_promise_integration": true, + }, + }); + } + const options = { headless: true, timeout }; + if (executablePath !== undefined) options.executablePath = executablePath; + return playwright[engine].launch(options); +} + +/** + * Locate a Chromium/Chrome binary: CHROME_PATH, common system names, + * then the Playwright browser cache. Throws when nothing is found. + */ +export async function findChrome(env = process.env) { + const candidates = []; + if (env.CHROME_PATH) candidates.push(env.CHROME_PATH); + for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) { + for (const dir of ["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"]) { + candidates.push(join(dir, name)); + } + } + const cache = join(env.HOME ?? "", ".cache", "ms-playwright"); + try { + const { readdir } = await import("node:fs/promises"); + for (const entry of (await readdir(cache)).sort().reverse()) { + if (entry.startsWith("chromium_headless_shell-")) { + candidates.push(join(cache, entry, "chrome-linux", "headless_shell")); + } else if (entry.startsWith("chromium-")) { + candidates.push(join(cache, entry, "chrome-linux", "chrome")); + } + } + } catch { + // No playwright cache. + } + for (const candidate of candidates) { + try { + await access(candidate); + return candidate; + } catch { + // Try the next candidate. + } + } + throw new Error( + "no Chromium/Chrome binary found: set CHROME_PATH or install one " + + "(e.g. `npx playwright-core install chromium`)", + ); +} + +/** + * Run a harness page to completion and return what it reported. + * + * Watchdog bounds: browser launch and page load get hard timeouts; the + * run itself is bounded by *inactivity* — the harness heartbeats as + * results stream in, so a stall means the page hung (a wedged worker, + * a deadlocked JSPI suspension, an uncaught error nothing was + * listening for), and the watchdog fails fast with the last heartbeat + * naming where. `stallTimeoutMs` is per-caller: the tolerable quiet + * time depends on the harness's heartbeat cadence. + * + * @param {object} options + * @param {object} options.playwright The caller's playwright-core module. + * @param {string} options.engine "chromium" | "firefox" | "webkit". + * @param {string} [options.executablePath] A specific browser binary, + * instead of Playwright's own build of the engine. + * @param {string} options.repoRoot Directory the static server serves. + * @param {string} options.html The harness document served at "/". + * @param {function} [options.routes] `(req, res) => boolean` claiming a + * request before the static paths (proxies, health checks). + * @param {number} options.stallTimeoutMs Max quiet time between heartbeats. + * @param {number} [options.launchTimeoutMs] + * @param {number} [options.loadTimeoutMs] + * @returns {Promise} The page's `__report` payload; throws if it + * carries `error`, if the page crashes or throws, or on a stall. + */ +export async function runPageHarness({ + playwright, + engine, + executablePath, + repoRoot, + html, + routes, + stallTimeoutMs, + launchTimeoutMs = 120_000, + loadTimeoutMs = 60_000, +}) { + const [browser, server] = await Promise.all([ + launchBrowser(playwright, engine, executablePath, launchTimeoutMs), + serve({ repoRoot, html, routes }), + ]); + try { + const { port } = server.address(); + const page = await browser.newPage(); + page.on("console", (msg) => { + if (msg.type() === "error") console.error("[page]", msg.text()); + }); + + let lastBeat = { at: Date.now(), note: "page created" }; + await page.exposeFunction("__progress", (note) => { + lastBeat = { at: Date.now(), note: String(note) }; + }); + let settled = false; + const report = new Promise((resolve, reject) => { + page.exposeFunction("__report", resolve); + page.on("crash", () => + reject(new Error(`page crashed (last heartbeat: ${lastBeat.note})`)), + ); + page.on("pageerror", (err) => + reject(new Error(`uncaught page error: ${err} (last heartbeat: ${lastBeat.note})`)), + ); + const watchdog = setInterval(() => { + if (settled) { + clearInterval(watchdog); + return; + } + const stalled = Date.now() - lastBeat.at; + if (stalled > stallTimeoutMs) { + clearInterval(watchdog); + reject( + new Error( + `harness stalled: no heartbeat for ${Math.round(stalled / 1000)}s ` + + `(last: ${lastBeat.note})`, + ), + ); + } + }, 5_000); + watchdog.unref?.(); + }); + + await page.goto(`http://127.0.0.1:${port}/`, { timeout: loadTimeoutMs }); + const outcome = await report.finally(() => { + settled = true; + }); + if (outcome.error) throw new Error(`in-page harness failed: ${outcome.error}`); + return outcome; + } finally { + await browser.close(); + server.close(); + } +} diff --git a/js/browser-driver.test.mjs b/js/browser-driver.test.mjs new file mode 100644 index 0000000..f22ea28 --- /dev/null +++ b/js/browser-driver.test.mjs @@ -0,0 +1,68 @@ +// Unit checks for the browser page driver's node-side pieces +// (js/browser-driver.mjs): the harness-page builder, the self-mount +// import map, and the Chrome ladder's env override. The in-page halves +// (page-runner, browser-worker) are syntax-checked here and +// integration-gated by the consumers' browser legs. Plain node, no +// browser. `just verify-imports`. + +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + MOUNT, + buildHarnessPage, + componentTestImportMap, + findChrome, +} from "./browser-driver.mjs"; + +// The import map points every bare specifier at the self-mount. +const map = componentTestImportMap(); +for (const [specifier, path] of Object.entries(map)) { + assert.ok(specifier.startsWith("@polymorph/component-test-js/")); + assert.ok(path.startsWith(`${MOUNT}/js/viewer/`)); +} + +// The page: import map merged with the caller's, config JSON embedded, +// page-runner and worker reached through the mount. +const html = buildHarnessPage({ + importMap: { "my:sut": "/js/sut.js" }, + config: { + jobs: 1, + suites: [ + { + suite: "conformance-guest-ct", + target: "jco-browser", + moduleUrl: "/gen/suite.js", + coreUrls: ["/gen/suite.core.wasm"], + importsUrl: "/jco/imports.mjs", + }, + ], + }, +}); +assert.ok(html.includes(`"my:sut":"/js/sut.js"`), "caller import-map entry"); +assert.ok(html.includes(map["@polymorph/component-test-js/harness"]), "self-mount entry"); +assert.ok(html.includes(`${MOUNT}/js/viewer/page-runner.mjs`)); +assert.ok(html.includes(`${MOUNT}/js/viewer/browser-worker.mjs`)); +assert.ok(html.includes(`"jco-browser"`), "config embedded"); + +// findChrome: an executable CHROME_PATH wins outright; a missing one +// falls through (to an error here, on a machine-independent HOME). +const dir = await mkdtemp(join(tmpdir(), "browser-driver-test-")); +try { + const fake = join(dir, "fake-chrome"); + await writeFile(fake, "#!/bin/sh\n"); + await chmod(fake, 0o755); + assert.equal(await findChrome({ CHROME_PATH: fake, HOME: dir }), fake); + const sys = await findChrome({ HOME: dir }).catch((e) => e); + if (sys instanceof Error) { + assert.match(sys.message, /no Chromium\/Chrome binary found/); + } else { + assert.ok(sys.startsWith("/"), "fell through to a system binary"); + } +} finally { + await rm(dir, { recursive: true, force: true }); +} + +console.log("browser-driver selftest OK"); diff --git a/js/node-runner.mjs b/js/node-runner.mjs index 950db88..a7af98a 100644 --- a/js/node-runner.mjs +++ b/js/node-runner.mjs @@ -7,6 +7,10 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +// Browser-safe logic lives in the harness; re-exported here so node +// drivers keep one import site. +export { resolveTestsExport } from "./viewer/harness.mjs"; + /** * Compile a transpiled suite's core modules from `dir`, in name order: * `.core*.wasm` when `prefix` is given (several suites may @@ -34,20 +38,6 @@ export async function loadCoreModules(dir, prefix) { return { modules, coreBytes }; } -/** - * The suite's `tests` interface from an instantiated component, - * whichever spelling the transpile used. Throws with the instance's - * export names when none matches. - */ -export function resolveTestsExport(instance) { - const tests = - instance.tests ?? instance["polymorph:test/tests@0.1.0"] ?? instance["polymorph:test/tests"]; - if (!tests) { - throw new Error(`suite instance exports no tests interface: ${Object.keys(instance)}`); - } - return tests; -} - /** * Write one target's results stream to `/.jsonl` * (trailing newline included), creating `dir` as needed. Returns the diff --git a/js/viewer/browser-worker.mjs b/js/viewer/browser-worker.mjs new file mode 100644 index 0000000..d2e8ef2 --- /dev/null +++ b/js/viewer/browser-worker.mjs @@ -0,0 +1,79 @@ +// The generic browser-side shard worker for jco-transpiled suites +// (#59's browser half): compiles the cores it is handed, builds the +// import object through the consumer's imports module, and runs one +// shard of the case loop, streaming indexed events back to the page. +// +// Browser-safe module Worker; workers cannot see the page's import +// map, so every module reference arrives as a URL in the run message: +// +// { +// moduleUrl, // the transpiled suite's .js (instantiation: async) +// coreUrls, // its core wasm files, fetch order = name order +// importsUrl, // module exporting suiteImports(env) -> imports object +// contextUrl?, // module exporting Context; upstream default otherwise +// env?, // [name, value] pairs handed to suiteImports +// missing?, shard?, caseTimeoutMs?, +// } +// +// Replies: { kind: "event", index, event } per case, +// { kind: "counts", counts } on completion, { kind: "error", error } +// on harness breakage. + +import { inventoryLookup, resolveTestsExport, runCases } from "./harness.mjs"; +import { Context as DefaultContext } from "./context.js"; + +// A rejection escaping the awaited chain (e.g. a platform quirk +// surfacing through the transpiled guest's async plumbing) would +// otherwise leave the worker silently wedged: unhandled rejections +// fire neither the catch below nor the page's worker.onerror. +self.onunhandledrejection = (event) => { + event.preventDefault?.(); + self.postMessage({ kind: "error", error: String(event.reason?.stack ?? event.reason) }); +}; + +self.onmessage = async ({ data }) => { + const { + moduleUrl, + coreUrls, + importsUrl, + contextUrl, + env = [], + missing = [], + shard, + caseTimeoutMs, + } = data; + try { + const coreBytes = []; + const modules = new Map(); + for (const url of coreUrls) { + const res = await fetch(url); + if (!res.ok) throw new Error(`fetching ${url}: ${res.status}`); + const bytes = new Uint8Array(await res.arrayBuffer()); + coreBytes.push(bytes); + // instantiate() asks for cores by file name. + modules.set(new URL(url, self.location.href).pathname.split("/").pop(), await WebAssembly.compile(bytes)); + } + const tagsOf = inventoryLookup(coreBytes); + + const { instantiate } = await import(moduleUrl); + const { suiteImports } = await import(importsUrl); + const Context = contextUrl ? (await import(contextUrl)).Context : DefaultContext; + const imports = await suiteImports(env); + const newTests = async () => + resolveTestsExport(await instantiate((name) => modules.get(name), imports)); + + const counts = await runCases({ + cases: await (await newTests()).all(), + Context, + tagsOf, + missing, + shard, + caseTimeoutMs, + emit: (event, index) => self.postMessage({ kind: "event", index, event }), + freshCases: async () => (await newTests()).all(), + }); + self.postMessage({ kind: "counts", counts }); + } catch (err) { + self.postMessage({ kind: "error", error: String(err?.stack ?? err) }); + } +}; diff --git a/js/viewer/harness.mjs b/js/viewer/harness.mjs index 9558537..9c064de 100644 --- a/js/viewer/harness.mjs +++ b/js/viewer/harness.mjs @@ -253,6 +253,20 @@ export function workerCount(available) { return Math.max(1, Math.min(available ?? 1, 8)); } +/** + * The suite's `tests` interface from an instantiated component, + * whichever spelling the transpile used. Throws with the instance's + * export names when none matches. + */ +export function resolveTestsExport(instance) { + const tests = + instance.tests ?? instance["polymorph:test/tests@0.1.0"] ?? instance["polymorph:test/tests"]; + if (!tests) { + throw new Error(`suite instance exports no tests interface: ${Object.keys(instance)}`); + } + return tests; +} + /** * Run one suite's whole case loop and emit a complete results-JSONL * stream: envelope, one serialized event per case, terminator. The diff --git a/js/viewer/page-runner.mjs b/js/viewer/page-runner.mjs new file mode 100644 index 0000000..fd870c6 --- /dev/null +++ b/js/viewer/page-runner.mjs @@ -0,0 +1,84 @@ +// The in-page pool runner for jco-transpiled suites (#59's browser +// half): for each configured suite, stripes the case loop over a pool +// of module Web Workers (browser-worker.mjs), restores suite order, +// and reports one results payload per suite through the page driver's +// `__report`, heartbeating `__progress` as rows stream in. +// +// Loaded by the driver-built harness page (see ./browser-driver's +// buildHarnessPage); browser-safe. + +import { envelope, mergeCounts, workerCount } from "./harness.mjs"; + +const beat = (note) => { + try { + window.__progress(note)?.catch?.(() => {}); + } catch { + // A closing page must not turn a heartbeat into an unhandled rejection. + } +}; + +/** One shard of one suite: a fresh worker running its stripe to + * completion. Workers are per-shard (not reused across suites) so + * each suite gets fresh instances. */ +function runShard(workerUrl, config, shard, onRow) { + return new Promise((resolve, reject) => { + const worker = new Worker(workerUrl, { type: "module" }); + const events = []; + worker.onmessage = ({ data }) => { + if (data.kind === "event") { + events.push(data); + onRow(data); + } else if (data.kind === "counts") { + worker.terminate(); + resolve({ events, counts: data.counts }); + } else { + worker.terminate(); + reject(new Error(`worker (shard ${shard.index}): ${data.error}`)); + } + }; + worker.onerror = (e) => { + worker.terminate(); + reject(new Error(`worker (shard ${shard.index}): ${e.message ?? e}`)); + }; + worker.postMessage({ ...config, shard }); + }); +} + +/** + * Run every configured suite and report. `suites` entries carry the + * browser-worker run message minus `shard` (moduleUrl, coreUrls, + * importsUrl, contextUrl?, env?, missing?, caseTimeoutMs?) plus + * `suite` (the results identity) and `target`. `jobs` defaults to the + * capped hardware parallelism; pass 1 for sequential corpora. + */ +export async function runSuitesInPage({ workerUrl, suites, jobs }) { + const pool = jobs ?? workerCount(navigator.hardwareConcurrency ?? 4); + let rows = 0; + try { + const out = {}; + for (const { suite, target, ...config } of suites) { + beat(`suite ${suite}: ${pool} workers`); + const shards = await Promise.all( + Array.from({ length: pool }, (_, index) => + runShard(workerUrl, config, { index, count: pool }, (data) => { + rows += 1; + if (rows % 25 === 0) beat(`row ${rows}: ${data.event.case}`); + }), + ), + ); + const events = shards.flatMap((s) => s.events); + events.sort((a, b) => a.index - b.index); + out[suite] = { + lines: [ + JSON.stringify(envelope(target, suite)), + ...events.map((e) => JSON.stringify(e.event)), + '{"segment-end":true}', + ], + counts: mergeCounts(shards.map((s) => s.counts)), + }; + } + window.__report(out); + } catch (err) { + window.__report({ error: String(err?.stack ?? err) }); + } +} diff --git a/justfile b/justfile index c0c26af..c1fb471 100644 --- a/justfile +++ b/justfile @@ -231,6 +231,9 @@ verify-viewer: viewer-build verify-imports: node js/viewer/imports.test.mjs node js/node-runner.test.mjs + node js/browser-driver.test.mjs + node --check js/viewer/browser-worker.mjs + node --check js/viewer/page-runner.mjs # Serve the viewer over the repository root (demo fixtures + transpiled # suites resolve by relative path): http://127.0.0.1:8123/ diff --git a/package.json b/package.json index 3b9a8c0..1f6416a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "js/viewer/context.js", "js/viewer/imports.mjs", "js/viewer/worker.mjs", + "js/viewer/browser-worker.mjs", + "js/viewer/page-runner.mjs", "js/node-runner.mjs", + "js/browser-driver.mjs", "js/jco-transpile.mjs" ], "exports": { @@ -19,7 +22,8 @@ "./context": "./js/viewer/context.js", "./imports": "./js/viewer/imports.mjs", "./worker": "./js/viewer/worker.mjs", - "./node-runner": "./js/node-runner.mjs" + "./node-runner": "./js/node-runner.mjs", + "./browser-driver": "./js/browser-driver.mjs" }, "bin": { "component-test-jco-transpile": "./js/jco-transpile.mjs"