Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit dbdbc48

Browse files
committedJan 21, 2025·
test: add browser test harness
This commit adds a browser test harness to run the tests in the browser.
1 parent f1c557c commit dbdbc48

File tree

8 files changed

+530
-0
lines changed

8 files changed

+530
-0
lines changed
 

‎.github/workflows/main.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ jobs:
133133
TARGET_TRIPLE=wasm32-wasip2 make test
134134
TARGET_TRIPLE=wasm32-wasi-threads make test
135135
TARGET_TRIPLE=wasm32-wasip1-threads make test
136+
137+
npm -C scripts/browser-test install
138+
npx -C scripts/browser-test playwright install chromium-headless-shell
139+
ENGINE="$PWD/scripts/browser-test/harness.mjs" TARGET_TRIPLE=wasm32-wasip1 make test
140+
ENGINE="$PWD/scripts/browser-test/harness.mjs" TARGET_TRIPLE=wasm32-wasip1-threads make test
136141
# The older version of Clang does not provide the expected symbol for the
137142
# test entrypoints: `undefined symbol: __main_argc_argv`.
138143
# The older (<15.0.7) version of wasm-ld does not provide `__heap_end`,

‎test/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
build
22
run
3+
node_modules

‎test/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ fs # a directory containing any test-created files
3535
output.log # the captured printed output--only for errors
3636
```
3737

38+
### Running tests in the browser
39+
40+
To run a test in the browser, use the `scripts/browser-test/harness.mjs` as `ENGINE`
41+
42+
```sh
43+
$ npm -C scripts/browser-test install
44+
$ npx -C scripts/browser-test playwright install chromium-headless-shell
45+
$ make ENGINE="$PWD/scripts/browser-test/harness.mjs" TARGET_TRIPLE=...
46+
```
47+
3848
### Adding tests
3949

4050
To add a test, create a new C file in [`src/misc`]:

‎test/scripts/browser-test/harness.mjs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env node
2+
3+
/*
4+
* Run a WASI-compatible test program in the browser.
5+
*
6+
* This script behaves like `wasmtime` but runs given WASI-compatible test
7+
* program in the browser.
8+
*
9+
* Example:
10+
* $ ./harness.mjs check.wasm
11+
*/
12+
13+
import { parseArgs } from 'node:util';
14+
import { createServer } from 'node:http';
15+
import { fileURLToPath } from 'node:url';
16+
import { dirname, join } from 'node:path';
17+
import { readFileSync } from 'node:fs';
18+
import url from "node:url";
19+
import { chromium } from 'playwright';
20+
21+
const SKIP_TESTS = [
22+
// "poll_oneoff" can't be implemented in the browser
23+
"libc-test/functional/pthread_cond",
24+
// atomic.wait32 can't be executed on the main thread
25+
"libc-test/functional/pthread_mutex",
26+
"libc-test/functional/pthread_tsd",
27+
// XFAIL: https://github.com/bjorn3/browser_wasi_shim/pull/86
28+
"misc/fts",
29+
];
30+
31+
/**
32+
* @param {{wasmPath: string, port: number}}
33+
* @returns {Promise<{server: import('node:http').Server, port: number}>}
34+
*/
35+
async function startServer({ wasmPath, port }) {
36+
const server = createServer((req, res) => {
37+
// Set required headers for SharedArrayBuffer
38+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
39+
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
40+
41+
let filePath;
42+
const parsed = url.parse(req.url, true);
43+
const pathname = parsed.pathname;
44+
if (pathname === "/target.wasm") {
45+
// Serve the test target Wasm file
46+
filePath = wasmPath;
47+
res.setHeader('Content-Type', 'application/wasm');
48+
} else {
49+
// Serve other resources
50+
const __dirname = dirname(fileURLToPath(import.meta.url));
51+
filePath = join(__dirname, pathname);
52+
const contentTypes = {
53+
"mjs": "text/javascript",
54+
"js": "text/javascript",
55+
"html": "text/html",
56+
}
57+
res.setHeader('Content-Type', contentTypes[pathname.split('.').pop()] || 'text/plain');
58+
}
59+
60+
try {
61+
const content = readFileSync(filePath);
62+
res.end(content);
63+
} catch (error) {
64+
res.statusCode = 404;
65+
res.end('Not found');
66+
}
67+
});
68+
69+
return new Promise((resolve) => {
70+
server.listen(port, () => {
71+
const port = server.address().port;
72+
resolve({ server, port });
73+
});
74+
});
75+
}
76+
77+
/** @param {number} port */
78+
function buildUrl(port) {
79+
return `http://localhost:${port}/run-test.html`;
80+
}
81+
82+
/** @param {import('playwright').Page} page */
83+
/** @param {number} port */
84+
/** @returns {Promise<{passed: boolean, error?: string}>} */
85+
async function runTest(page, port) {
86+
const url = buildUrl(port);
87+
const onExit = new Promise((resolve) => {
88+
page.exposeFunction("exitTest", resolve);
89+
});
90+
await page.goto(url);
91+
return onExit;
92+
}
93+
94+
async function main() {
95+
// Parse and interpret a subset of the wasmtime CLI options used by the tests
96+
const args = parseArgs({
97+
args: process.argv.slice(2),
98+
allowPositionals: true,
99+
options: {
100+
// MARK: wasmtime CLI options
101+
wasi: {
102+
type: "string",
103+
multiple: true,
104+
},
105+
dir: {
106+
type: "string",
107+
multiple: true,
108+
},
109+
// MARK: For debugging purposes
110+
headful: {
111+
type: "boolean",
112+
default: false,
113+
},
114+
port: {
115+
type: "string",
116+
default: "0",
117+
}
118+
}
119+
});
120+
121+
const wasmPath = args.positionals[0];
122+
if (!wasmPath) {
123+
console.error('Test path not specified');
124+
return 1;
125+
}
126+
127+
if (SKIP_TESTS.some(test => wasmPath.includes(test + "."))) {
128+
// Silently skip tests that are known to fail in the browser
129+
return 0;
130+
}
131+
132+
// Start a HTTP server to serve the test files
133+
const { server, port } = await startServer({ wasmPath, port: parseInt(args.values.port) });
134+
135+
const browser = await chromium.launch();
136+
const page = await browser.newPage();
137+
138+
try {
139+
if (args.values.headful) {
140+
// Run in headful mode to allow manual testing
141+
console.log(`Please visit ${buildUrl(port)}`);
142+
console.log('Press Ctrl+C to stop');
143+
await new Promise(resolve => process.on('SIGINT', resolve));
144+
return 0;
145+
}
146+
147+
// Run in headless mode
148+
const result = await runTest(page, port);
149+
if (!result.passed) {
150+
console.error('Test failed:', result.error);
151+
console.error(`Hint: You can debug the test by running it in headful mode by passing --headful
152+
$ ${process.argv.join(' ')} --headful`);
153+
return 1;
154+
}
155+
return 0;
156+
} catch (error) {
157+
console.error('Test failed:', error);
158+
return 1;
159+
} finally {
160+
await browser.close();
161+
server.close();
162+
}
163+
}
164+
165+
process.exit(await main());

‎test/scripts/browser-test/package-lock.json

Lines changed: 75 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dependencies": {
3+
"playwright": "^1.49.1"
4+
}
5+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<!DOCTYPE html>
2+
<html>
3+
4+
<head>
5+
<title>wasi-libc Browser Tests</title>
6+
</head>
7+
8+
<body>
9+
<h1>wasi-libc Browser Tests</h1>
10+
<div id="results"></div>
11+
<script type="module">
12+
import { runWasmTest } from "./run-test.mjs";
13+
function exitTest(result) {
14+
if (typeof window.exitTest === 'function') {
15+
window.exitTest(result);
16+
}
17+
}
18+
async function runTests() {
19+
const resultsDiv = document.getElementById('results');
20+
21+
try {
22+
const passed = await runWasmTest("target.wasm");
23+
resultsDiv.innerHTML = passed ?
24+
'<p style="color: green">Test passed</p>' :
25+
'<p style="color: red">Test failed</p>';
26+
exitTest({ passed });
27+
} catch (error) {
28+
console.error(error);
29+
resultsDiv.innerHTML = `<p style="color: red">Error: ${error.message}</p>`;
30+
exitTest({ passed: false, error: error.message });
31+
}
32+
}
33+
34+
runTests();
35+
</script>
36+
</body>
37+
38+
</html>
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* This script is served by `harness.mjs` and runs in the browser.
3+
*/
4+
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/+esm'
5+
import { polyfill } from 'https://cdn.jsdelivr.net/npm/wasm-imports-parser@1.0.4/polyfill.js/+esm';
6+
7+
/**
8+
* @param {{
9+
* module: WebAssembly.Module,
10+
* addToImports: (importObject: WebAssembly.Imports) => Promise<void>
11+
* }}
12+
*/
13+
export async function instantiate({ module, addToImports }) {
14+
const args = ["target.wasm"]
15+
const env = []
16+
const fds = [
17+
new OpenFile(new File([])), // stdin
18+
ConsoleStdout.lineBuffered((stdout) => {
19+
console.log(stdout);
20+
}),
21+
ConsoleStdout.lineBuffered((stderr) => {
22+
console.error(stderr);
23+
}),
24+
new PreopenDirectory("/", new Map()),
25+
];
26+
const wasi = new WASI(args, env, fds);
27+
28+
const importObject = {
29+
wasi_snapshot_preview1: wasi.wasiImport,
30+
};
31+
await addToImports(importObject);
32+
const instance = await WebAssembly.instantiate(module, importObject);
33+
return { wasi, instance };
34+
}
35+
36+
class WorkerInfoView {
37+
/**
38+
* The memory layout of a worker info placed in a shared array buffer.
39+
* All offsets are represented in Int32Array indices.
40+
*/
41+
static Layout = {
42+
STATE: 0,
43+
TID: 1,
44+
START_ARG: 2,
45+
SIZE: 3,
46+
};
47+
48+
/** @param {Int32Array} view - The memory view of the worker info */
49+
constructor(view) {
50+
this.view = view;
51+
}
52+
53+
get state() {
54+
return this.view[WorkerInfoView.Layout.STATE];
55+
}
56+
57+
setStateAndNotify(state) {
58+
this.view[WorkerInfoView.Layout.STATE] = state;
59+
Atomics.notify(this.view, WorkerInfoView.Layout.STATE);
60+
}
61+
62+
async waitWhile(state) {
63+
return await Atomics.waitAsync(this.view, WorkerInfoView.Layout.STATE, state);
64+
}
65+
66+
get tid() { return this.view[WorkerInfoView.Layout.TID]; }
67+
set tid(value) { this.view[WorkerInfoView.Layout.TID] = value; }
68+
69+
get startArg() { return this.view[WorkerInfoView.Layout.START_ARG]; }
70+
set startArg(value) { this.view[WorkerInfoView.Layout.START_ARG] = value; }
71+
}
72+
73+
const WorkerState = {
74+
NOT_STARTED: 0,
75+
READY: 1,
76+
STARTED: 2,
77+
FINISHED: 3,
78+
ERROR: 4,
79+
};
80+
81+
class Threads {
82+
/**
83+
* @param {number} poolSize - The number of threads to pool
84+
* @param {WebAssembly.Module} module - The WebAssembly module to use
85+
* @param {WebAssembly.Memory} memory - The memory to use
86+
*/
87+
static async create(poolSize, module, memory) {
88+
const workerScript = new Blob([`
89+
self.onmessage = async (event) => {
90+
const { selfFilePath } = event.data;
91+
const { startWorker } = await import(selfFilePath);
92+
await startWorker(event.data);
93+
}
94+
`], { type: 'text/javascript' });
95+
const workerScriptURL = URL.createObjectURL(workerScript);
96+
// Create a new SAB to communicate with the workers
97+
// Rationale: Some of the tests busy-wait on the main thread, and it
98+
// makes it impossible to use `postMessage` to communicate with workers
99+
// during the busy-wait as the event loop is blocked. Instead, we use a
100+
// shared array buffer and send notifications by `Atomics.notify`.
101+
const channel = new SharedArrayBuffer(poolSize * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT);
102+
103+
const workers = [];
104+
for (let workerIndex = 0; workerIndex < poolSize; workerIndex++) {
105+
const worker = new Worker(workerScriptURL);
106+
const selfFilePath = import.meta.url;
107+
worker.postMessage({ selfFilePath, channel, workerIndex, module, memory });
108+
workers.push(worker);
109+
}
110+
111+
// Wait until all workers are ready
112+
for (let workerIndex = 0; workerIndex < poolSize; workerIndex++) {
113+
const view = new Int32Array(channel, workerIndex * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT);
114+
const infoView = new WorkerInfoView(view);
115+
await (await infoView.waitWhile(WorkerState.NOT_STARTED)).value;
116+
const state = infoView.state;
117+
if (state !== WorkerState.READY) {
118+
throw new Error(`Worker ${workerIndex} is not ready: ${state}`);
119+
}
120+
}
121+
122+
return new Threads(poolSize, workers, channel);
123+
}
124+
125+
constructor(poolSize, workers, channel) {
126+
this.poolSize = poolSize;
127+
this.workers = workers;
128+
this.nextTid = 1;
129+
this.channel = channel;
130+
}
131+
132+
findAvailableWorker() {
133+
for (let i = 0; i < this.workers.length; i++) {
134+
const view = new Int32Array(this.channel, i * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT);
135+
const infoView = new WorkerInfoView(view);
136+
const state = infoView.state;
137+
if (state === WorkerState.READY) {
138+
return i;
139+
}
140+
}
141+
throw new Error("No available worker");
142+
}
143+
144+
spawnThread(startArg) {
145+
const tid = this.nextTid++;
146+
const index = this.findAvailableWorker();
147+
const view = new Int32Array(this.channel, index * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT);
148+
const infoView = new WorkerInfoView(view);
149+
infoView.tid = tid;
150+
infoView.startArg = startArg;
151+
infoView.setStateAndNotify(WorkerState.STARTED);
152+
return tid;
153+
}
154+
}
155+
156+
export async function runWasmTest(wasmPath) {
157+
const response = await fetch(wasmPath);
158+
const wasmBytes = await response.arrayBuffer();
159+
160+
// Polyfill WebAssembly if "Type Reflection JS API" is unavailable.
161+
// The feature is required to know the imported memory type.
162+
const WebAssembly = polyfill(globalThis.WebAssembly);
163+
164+
const module = await WebAssembly.compile(wasmBytes);
165+
const imports = WebAssembly.Module.imports(module);
166+
167+
const { wasi, instance } = await instantiate({
168+
module,
169+
addToImports: async (importObject) => {
170+
const memoryImport = imports.find(i => i.module === 'env' && i.name === 'memory');
171+
if (!memoryImport) {
172+
return;
173+
}
174+
175+
// Add wasi-threads support if memory is imported
176+
const memoryType = memoryImport.type;
177+
const memory = new WebAssembly.Memory({
178+
initial: memoryType.minimum,
179+
maximum: memoryType.maximum,
180+
shared: memoryType.shared,
181+
});
182+
const threads = await Threads.create(8, module, memory);
183+
importObject.env = { memory };
184+
importObject.wasi = {
185+
"thread-spawn": (startArg) => {
186+
return threads.spawnThread(startArg);
187+
}
188+
};
189+
},
190+
});
191+
192+
const exitCode = wasi.start(instance);
193+
return exitCode === 0;
194+
}
195+
196+
/**
197+
* @param {{
198+
* channel: SharedArrayBuffer,
199+
* workerIndex: number,
200+
* module: WebAssembly.Module,
201+
* memory: WebAssembly.Memory
202+
* }}
203+
*/
204+
export async function startWorker({ channel, workerIndex, module, memory }) {
205+
const view = new Int32Array(channel, workerIndex * WorkerInfoView.Layout.SIZE * Int32Array.BYTES_PER_ELEMENT);
206+
const infoView = new WorkerInfoView(view);
207+
// Mark the worker as ready
208+
infoView.setStateAndNotify(WorkerState.READY);
209+
// Wait until the main thread marks the worker as started
210+
await (await infoView.waitWhile(WorkerState.READY)).value;
211+
const tid = infoView.tid;
212+
const startArg = infoView.startArg;
213+
await startThread({ module, memory, tid, startArg });
214+
// Mark the worker as finished
215+
infoView.setStateAndNotify(WorkerState.FINISHED);
216+
}
217+
218+
async function startThread({ module, memory, tid, startArg }) {
219+
const { instance, wasi } = await instantiate({
220+
module,
221+
addToImports(importObject) {
222+
importObject["env"] = { memory }
223+
importObject["wasi"] = {
224+
"thread-spawn": () => { throw new Error("Cannot spawn a new thread from a worker thread"); }
225+
};
226+
},
227+
});
228+
229+
wasi.inst = instance;
230+
instance.exports.wasi_thread_start(tid, startArg);
231+
}

0 commit comments

Comments
 (0)
Please sign in to comment.