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 @@ + + + +
+