diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 67ffb70b..bb96c7f3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -133,6 +133,11 @@ jobs: TARGET_TRIPLE=wasm32-wasip2 make test TARGET_TRIPLE=wasm32-wasi-threads make test TARGET_TRIPLE=wasm32-wasip1-threads make test + + npm -C scripts/browser-test install + npx -C scripts/browser-test playwright install chromium-headless-shell + ENGINE="$PWD/scripts/browser-test/harness.mjs" TARGET_TRIPLE=wasm32-wasip1 make test + ENGINE="$PWD/scripts/browser-test/harness.mjs" TARGET_TRIPLE=wasm32-wasip1-threads make test # The older version of Clang does not provide the expected symbol for the # test entrypoints: `undefined symbol: __main_argc_argv`. # The older (<15.0.7) version of wasm-ld does not provide `__heap_end`, diff --git a/test/.gitignore b/test/.gitignore index 266ca8d5..e4a41d36 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1,2 +1,3 @@ build run +node_modules diff --git a/test/README.md b/test/README.md index 2ba40e19..0fcd476c 100644 --- a/test/README.md +++ b/test/README.md @@ -35,6 +35,16 @@ fs # a directory containing any test-created files output.log # the captured printed output--only for errors ``` +### Running tests in the browser + +To run a test in the browser, use the `scripts/browser-test/harness.mjs` as `ENGINE` + +```sh +$ npm -C scripts/browser-test install +$ npx -C scripts/browser-test playwright install chromium-headless-shell +$ make ENGINE="$PWD/scripts/browser-test/harness.mjs" TARGET_TRIPLE=... +``` + ### Adding tests To add a test, create a new C file in [`src/misc`]: diff --git a/test/scripts/browser-test/harness.mjs b/test/scripts/browser-test/harness.mjs new file mode 100755 index 00000000..a3ca3c26 --- /dev/null +++ b/test/scripts/browser-test/harness.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +/* + * Run a WASI-compatible test program in the browser. + * + * This script behaves like `wasmtime` but runs given WASI-compatible test + * program in the browser. + * + * Example: + * $ ./harness.mjs check.wasm + */ + +import { parseArgs } from 'node:util'; +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import url from "node:url"; +import { chromium } from 'playwright'; + +const SKIP_TESTS = [ + // "poll_oneoff" can't be implemented in the browser + "libc-test/functional/pthread_cond", + // atomic.wait32 can't be executed on the main thread + "libc-test/functional/pthread_mutex", + "libc-test/functional/pthread_tsd", + // XFAIL: @bjorn3/browser_wasi_shim doesn't support symlinks for now + "misc/fts", +]; + +/** + * @param {{wasmPath: string, port: number}} + * @returns {Promise<{server: import('node:http').Server, port: number}>} + */ +async function startServer({ wasmPath, port }) { + const server = createServer((req, res) => { + // Set required headers for SharedArrayBuffer + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + + let filePath; + const parsed = url.parse(req.url, true); + const pathname = parsed.pathname; + if (pathname === "/target.wasm") { + // Serve the test target Wasm file + filePath = wasmPath; + res.setHeader('Content-Type', 'application/wasm'); + } else { + // Serve other resources + const __dirname = dirname(fileURLToPath(import.meta.url)); + filePath = join(__dirname, pathname); + const contentTypes = { + "mjs": "text/javascript", + "js": "text/javascript", + "html": "text/html", + } + res.setHeader('Content-Type', contentTypes[pathname.split('.').pop()] || 'text/plain'); + } + + try { + const content = readFileSync(filePath); + res.end(content); + } catch (error) { + res.statusCode = 404; + res.end('Not found'); + } + }); + + return new Promise((resolve) => { + server.listen(port, () => { + const port = server.address().port; + resolve({ server, port }); + }); + }); +} + +/** @param {number} port */ +function buildUrl(port) { + return `http://localhost:${port}/run-test.html`; +} + +/** @param {import('playwright').Page} page */ +/** @param {number} port */ +/** @returns {Promise<{passed: boolean, error?: string}>} */ +async function runTest(page, port) { + const url = buildUrl(port); + const onExit = new Promise((resolve) => { + page.exposeFunction("exitTest", resolve); + }); + await page.goto(url); + return onExit; +} + +async function main() { + // Parse and interpret a subset of the wasmtime CLI options used by the tests + const args = parseArgs({ + args: process.argv.slice(2), + allowPositionals: true, + options: { + // MARK: wasmtime CLI options + wasi: { + type: "string", + multiple: true, + }, + dir: { + type: "string", + multiple: true, + }, + // MARK: For debugging purposes + headful: { + type: "boolean", + default: false, + }, + port: { + type: "string", + default: "0", + } + } + }); + + const wasmPath = args.positionals[0]; + if (!wasmPath) { + console.error('Test path not specified'); + return 1; + } + + if (SKIP_TESTS.some(test => wasmPath.includes(test + "."))) { + // Silently skip tests that are known to fail in the browser + return 0; + } + + if (args.values.dir && args.values.dir.length > 0) { + // Silently skip tests that require preopened directories for now + // as it adds more complexity to the harness and file system emulation + // is not our primary testing target. + return 0; + } + + // Start a HTTP server to serve the test files + const { server, port } = await startServer({ wasmPath, port: parseInt(args.values.port) }); + + const browser = await chromium.launch(); + const page = await browser.newPage(); + + try { + if (args.values.headful) { + // Run in headful mode to allow manual testing + console.log(`Please visit ${buildUrl(port)}`); + console.log('Press Ctrl+C to stop'); + await new Promise(resolve => process.on('SIGINT', resolve)); + return 0; + } + + // Run in headless mode + const result = await runTest(page, port); + if (!result.passed) { + console.error('Test failed:', result.error); + console.error(`Hint: You can debug the test by running it in headful mode by passing --headful +$ ${process.argv.join(' ')} --headful`); + return 1; + } + return 0; + } catch (error) { + console.error('Test failed:', error); + return 1; + } finally { + await browser.close(); + server.close(); + } +} + +process.exit(await main()); diff --git a/test/scripts/browser-test/package-lock.json b/test/scripts/browser-test/package-lock.json new file mode 100644 index 00000000..21ff7825 --- /dev/null +++ b/test/scripts/browser-test/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "browser-test", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "^1.49.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + }, + "playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.49.1" + } + }, + "playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==" + } + } +} diff --git a/test/scripts/browser-test/package.json b/test/scripts/browser-test/package.json new file mode 100644 index 00000000..350d4864 --- /dev/null +++ b/test/scripts/browser-test/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "playwright": "^1.49.1" + } +} diff --git a/test/scripts/browser-test/run-test.html b/test/scripts/browser-test/run-test.html new file mode 100644 index 00000000..7518f01b --- /dev/null +++ b/test/scripts/browser-test/run-test.html @@ -0,0 +1,38 @@ + + + + + wasi-libc Browser Tests + + + +

wasi-libc Browser Tests

+
+ + + + diff --git a/test/scripts/browser-test/run-test.mjs b/test/scripts/browser-test/run-test.mjs new file mode 100644 index 00000000..e870bd42 --- /dev/null +++ b/test/scripts/browser-test/run-test.mjs @@ -0,0 +1,230 @@ +/** + * This script is served by `harness.mjs` and runs in the browser. + */ +import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/+esm' +import { polyfill } from 'https://cdn.jsdelivr.net/npm/wasm-imports-parser@1.0.4/polyfill.js/+esm'; + +/** + * @param {{ + * module: WebAssembly.Module, + * addToImports: (importObject: WebAssembly.Imports) => Promise + * }} + */ +export async function instantiate({ module, addToImports }) { + const args = ["target.wasm"] + const env = [] + const fds = [ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered((stdout) => { + console.log(stdout); + }), + ConsoleStdout.lineBuffered((stderr) => { + console.error(stderr); + }), + ]; + const wasi = new WASI(args, env, fds); + + const importObject = { + wasi_snapshot_preview1: wasi.wasiImport, + }; + await addToImports(importObject); + const instance = await WebAssembly.instantiate(module, importObject); + return { wasi, instance }; +} + +class WorkerInfoView { + /** + * The memory layout of a worker info placed in a shared array buffer. + * All offsets are represented in Int32Array indices. + */ + static Layout = { + STATE: 0, + TID: 1, + START_ARG: 2, + SIZE: 3, + }; + + /** @param {Int32Array} view - The memory view of the worker info */ + constructor(view) { + this.view = view; + } + + get state() { + return this.view[WorkerInfoView.Layout.STATE]; + } + + setStateAndNotify(state) { + this.view[WorkerInfoView.Layout.STATE] = state; + Atomics.notify(this.view, WorkerInfoView.Layout.STATE); + } + + async waitWhile(state) { + return await Atomics.waitAsync(this.view, WorkerInfoView.Layout.STATE, state); + } + + get tid() { return this.view[WorkerInfoView.Layout.TID]; } + set tid(value) { this.view[WorkerInfoView.Layout.TID] = value; } + + get startArg() { return this.view[WorkerInfoView.Layout.START_ARG]; } + set startArg(value) { this.view[WorkerInfoView.Layout.START_ARG] = value; } +} + +const WorkerState = { + NOT_STARTED: 0, + READY: 1, + STARTED: 2, + FINISHED: 3, + ERROR: 4, +}; + +class Threads { + /** + * @param {number} poolSize - The number of threads to pool + * @param {WebAssembly.Module} module - The WebAssembly module to use + * @param {WebAssembly.Memory} memory - The memory to use + */ + static async create(poolSize, module, memory) { + const workerScript = new Blob([` + self.onmessage = async (event) => { + const { selfFilePath } = event.data; + const { startWorker } = await import(selfFilePath); + await startWorker(event.data); + } + `], { type: 'text/javascript' }); + const workerScriptURL = URL.createObjectURL(workerScript); + // Create a new SAB to communicate with the workers + // Rationale: Some of the tests busy-wait on the main thread, and it + // makes it impossible to use `postMessage` to communicate with workers + // during the busy-wait as the event loop is blocked. Instead, we use a + // shared array buffer and send notifications by `Atomics.notify`. + const channel = new SharedArrayBuffer(poolSize * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT); + + const workers = []; + for (let workerIndex = 0; workerIndex < poolSize; workerIndex++) { + const worker = new Worker(workerScriptURL); + const selfFilePath = import.meta.url; + worker.postMessage({ selfFilePath, channel, workerIndex, module, memory }); + workers.push(worker); + } + + // Wait until all workers are ready + for (let workerIndex = 0; workerIndex < poolSize; workerIndex++) { + const view = new Int32Array(channel, workerIndex * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT); + const infoView = new WorkerInfoView(view); + await (await infoView.waitWhile(WorkerState.NOT_STARTED)).value; + const state = infoView.state; + if (state !== WorkerState.READY) { + throw new Error(`Worker ${workerIndex} is not ready: ${state}`); + } + } + + return new Threads(poolSize, workers, channel); + } + + constructor(poolSize, workers, channel) { + this.poolSize = poolSize; + this.workers = workers; + this.nextTid = 1; + this.channel = channel; + } + + findAvailableWorker() { + for (let i = 0; i < this.workers.length; i++) { + const view = new Int32Array(this.channel, i * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT); + const infoView = new WorkerInfoView(view); + const state = infoView.state; + if (state === WorkerState.READY) { + return i; + } + } + throw new Error("No available worker"); + } + + spawnThread(startArg) { + const tid = this.nextTid++; + const index = this.findAvailableWorker(); + const view = new Int32Array(this.channel, index * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT); + const infoView = new WorkerInfoView(view); + infoView.tid = tid; + infoView.startArg = startArg; + infoView.setStateAndNotify(WorkerState.STARTED); + return tid; + } +} + +export async function runWasmTest(wasmPath) { + const response = await fetch(wasmPath); + const wasmBytes = await response.arrayBuffer(); + + // Polyfill WebAssembly if "Type Reflection JS API" is unavailable. + // The feature is required to know the imported memory type. + const WebAssembly = polyfill(globalThis.WebAssembly); + + const module = await WebAssembly.compile(wasmBytes); + const imports = WebAssembly.Module.imports(module); + + const { wasi, instance } = await instantiate({ + module, + addToImports: async (importObject) => { + const memoryImport = imports.find(i => i.module === 'env' && i.name === 'memory'); + if (!memoryImport) { + return; + } + + // Add wasi-threads support if memory is imported + const memoryType = memoryImport.type; + const memory = new WebAssembly.Memory({ + initial: memoryType.minimum, + maximum: memoryType.maximum, + shared: memoryType.shared, + }); + const threads = await Threads.create(8, module, memory); + importObject.env = { memory }; + importObject.wasi = { + "thread-spawn": (startArg) => { + return threads.spawnThread(startArg); + } + }; + }, + }); + + const exitCode = wasi.start(instance); + return exitCode === 0; +} + +/** + * @param {{ + * channel: SharedArrayBuffer, + * workerIndex: number, + * module: WebAssembly.Module, + * memory: WebAssembly.Memory + * }} + */ +export async function startWorker({ channel, workerIndex, module, memory }) { + const view = new Int32Array(channel, workerIndex * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT); + const infoView = new WorkerInfoView(view); + // Mark the worker as ready + infoView.setStateAndNotify(WorkerState.READY); + // Wait until the main thread marks the worker as started + await (await infoView.waitWhile(WorkerState.READY)).value; + const tid = infoView.tid; + const startArg = infoView.startArg; + await startThread({ module, memory, tid, startArg }); + // Mark the worker as finished + infoView.setStateAndNotify(WorkerState.FINISHED); +} + +async function startThread({ module, memory, tid, startArg }) { + const { instance, wasi } = await instantiate({ + module, + addToImports(importObject) { + importObject["env"] = { memory } + importObject["wasi"] = { + "thread-spawn": () => { throw new Error("Cannot spawn a new thread from a worker thread"); } + }; + }, + }); + + wasi.inst = instance; + instance.exports.wasi_thread_start(tid, startArg); +}