Skip to content

Commit b09ab96

Browse files
test: Add browser test adapter
1 parent 802ca6b commit b09ab96

File tree

10 files changed

+307
-51
lines changed

10 files changed

+307
-51
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"scripts": {
88
"build": "swc src -d dist && tsc --emitDeclarationOnly",
99
"prepare": "swc src -d dist && tsc --emitDeclarationOnly",
10-
"test": "./test/run-testsuite.sh",
10+
"test:node": "./test/run-testsuite.sh node",
11+
"test:browser": "./test/run-testsuite.sh browser",
12+
"test": "npm run test:node && npm run test:browser",
1113
"check": "tsc --noEmit && prettier src -c && eslint src/"
1214
},
1315
"repository": {

test/adapters/browser/adapter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../shared/adapter.py

test/adapters/browser/run-test.html

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<html>
2+
<script type="module">
3+
import { WASI, OpenFile, File, Fd, Directory, PreopenDirectory, wasi, strace } from "/dist/index.js";
4+
class ConsoleStdout extends Fd {
5+
constructor(write) {
6+
super();
7+
this.write = write;
8+
}
9+
10+
fd_filestat_get() {
11+
const filestat = new wasi.Filestat(
12+
wasi.FILETYPE_CHARACTER_DEVICE,
13+
BigInt(0),
14+
);
15+
return { ret: 0, filestat };
16+
}
17+
18+
fd_fdstat_get() {
19+
const fdstat = new wasi.Fdstat(wasi.FILETYPE_CHARACTER_DEVICE, 0);
20+
fdstat.fs_rights_base = BigInt(wasi.RIGHTS_FD_WRITE);
21+
return { ret: 0, fdstat };
22+
}
23+
24+
fd_write(view8, iovs) {
25+
let nwritten = 0;
26+
for (let iovec of iovs) {
27+
let buffer = view8.slice(iovec.buf, iovec.buf + iovec.buf_len);
28+
this.write(buffer);
29+
nwritten += iovec.buf_len;
30+
}
31+
return { ret: 0, nwritten };
32+
}
33+
}
34+
35+
async function derivePreopens(dirs) {
36+
const rawPreopens = await window.bindingDerivePreopens(dirs)
37+
function transform(entry) {
38+
if (entry.kind === "dir") {
39+
const contents = {};
40+
for (const [name, child] of Object.entries(entry.contents)) {
41+
contents[name] = transform(child);
42+
}
43+
return new Directory(contents);
44+
} else if (entry.kind === "file") {
45+
return new File(Uint8Array.from(entry.buffer))
46+
} else {
47+
throw new Error("Unknown kind: ", entry.kind, entry);
48+
}
49+
}
50+
const preopens = []
51+
for (const preopen of rawPreopens) {
52+
const { dir, contents } = preopen;
53+
const newContents = {};
54+
for (const [name, child] of Object.entries(contents)) {
55+
newContents[name] = transform(child);
56+
}
57+
preopens.push(new PreopenDirectory(dir, newContents));
58+
}
59+
return preopens;
60+
}
61+
62+
window.runWASI = async (options) => {
63+
const testFile = options["test-file"];
64+
const args = [testFile].concat(options.arg);
65+
const fds = [
66+
new OpenFile(new File([])),
67+
// Uint8Array is not [Serializable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description)
68+
// so we need to convert it to an array before passing it to Playwright.
69+
new ConsoleStdout(bytes => window.bindingWriteIO(Array.from(bytes), "stdout")),
70+
new ConsoleStdout(bytes => window.bindingWriteIO(Array.from(bytes), "stderr")),
71+
];
72+
const preopens = await derivePreopens(options.dir)
73+
fds.push(...preopens);
74+
75+
const wasi = new WASI(args, options.env, fds, { debug: false });
76+
77+
const moduleBytes = await fetch(testFile).then(r => r.arrayBuffer());
78+
const module = await WebAssembly.compile(moduleBytes);
79+
let wasiImport = wasi.wasiImport;
80+
const instance = await WebAssembly.instantiate(module, {
81+
wasi_snapshot_preview1: wasiImport
82+
});
83+
84+
const exitCode = wasi.start(instance);
85+
return exitCode;
86+
}
87+
</script>
88+
89+
</html>

test/adapters/browser/run-wasi.mjs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'fs/promises';
4+
import path from 'path';
5+
import { chromium } from "playwright"
6+
import { parseArgs } from "../shared/parseArgs.mjs"
7+
import { walkFs } from "../shared/walkFs.mjs"
8+
9+
async function derivePreopens(dirs) {
10+
const preopens = [];
11+
for (let dir of dirs) {
12+
const contents = await walkFs(dir, (name, entry, out) => {
13+
if (entry.kind === "file") {
14+
// Convert buffer to array to make it serializable.
15+
entry.buffer = Array.from(entry.buffer);
16+
}
17+
return { ...out, [name]: entry };
18+
}, {});
19+
preopens.push({ dir, contents });
20+
}
21+
return preopens;
22+
}
23+
24+
/**
25+
* Configure routes for the browser harness.
26+
*
27+
* @param {import('playwright').BrowserContext} context
28+
* @param {string} harnessURL
29+
*/
30+
async function configureRoutes(context, harnessURL) {
31+
32+
// Serve the main test page.
33+
context.route(`${harnessURL}/run-test.html`, async route => {
34+
const dirname = new URL(".", import.meta.url).pathname;
35+
const body = await fs.readFile(path.join(dirname, "run-test.html"), "utf8");
36+
route.fulfill({
37+
status: 200,
38+
contentType: 'text/html',
39+
// Browsers reduce the precision of performance.now() if the page is not
40+
// isolated. To keep the precision for `clock_get_time` we need to set the
41+
// following headers.
42+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Performance/now#security_requirements
43+
headers: {
44+
"Cross-Origin-Opener-Policy": "same-origin",
45+
"Cross-Origin-Embedder-Policy": "require-corp",
46+
},
47+
body,
48+
});
49+
})
50+
51+
// Serve wasi-testsuite files.
52+
// e.g. http://browser-wasi-shim.localhost/home/me/browser_wasi_shim/test/wasi-testsuite/xxx
53+
let projectDir = path.join(new URL("../../wasi-testsuite", import.meta.url).pathname);
54+
projectDir = path.resolve(projectDir);
55+
context.route(`${harnessURL}${projectDir}/**/*`, async route => {
56+
const pathname = new URL(route.request().url()).pathname;
57+
const relativePath = pathname.slice(pathname.indexOf(projectDir) + projectDir.length);
58+
const content = await fs.readFile(path.join(projectDir, relativePath));
59+
route.fulfill({
60+
status: 200,
61+
contentType: 'application/javascript',
62+
body: content,
63+
});
64+
});
65+
66+
// Serve transpiled browser_wasi_shim files under ./dist.
67+
context.route(`${harnessURL}/dist/*.js`, async route => {
68+
const pathname = new URL(route.request().url()).pathname;
69+
const distRelativePath = pathname.slice(pathname.indexOf("/dist/"));
70+
const distDir = new URL("../../..", import.meta.url);
71+
const distPath = path.join(distDir.pathname, distRelativePath);
72+
const content = await fs.readFile(distPath);
73+
route.fulfill({
74+
status: 200,
75+
contentType: 'application/javascript',
76+
body: content,
77+
});
78+
});
79+
}
80+
81+
async function runWASIOnBrowser(options) {
82+
const browser = await chromium.launch();
83+
const context = await browser.newContext();
84+
const harnessURL = 'http://browser-wasi-shim.localhost'
85+
86+
await configureRoutes(context, harnessURL);
87+
88+
const page = await context.newPage();
89+
// Expose stdout/stderr bindings to allow test driver to capture output.
90+
page.exposeBinding("bindingWriteIO", (_, buffer, destination) => {
91+
buffer = Buffer.from(buffer);
92+
switch (destination) {
93+
case "stdout":
94+
process.stdout.write(buffer);
95+
break;
96+
case "stderr":
97+
process.stderr.write(buffer);
98+
break;
99+
default:
100+
throw new Error(`Unknown destination ${destination}`);
101+
}
102+
});
103+
// Expose a way to serialize preopened directories to the browser.
104+
page.exposeBinding("bindingDerivePreopens", async (_, dirs) => {
105+
return await derivePreopens(dirs);
106+
});
107+
108+
page.on('console', msg => console.log(msg.text()));
109+
page.on('pageerror', ({ message }) => {
110+
console.log('PAGE ERROR:', message)
111+
process.exit(1); // Unexpected error.
112+
});
113+
114+
await page.goto(`${harnessURL}/run-test.html`, { waitUntil: "load" })
115+
const status = await page.evaluate(async (o) => await window.runWASI(o), options)
116+
await page.close();
117+
process.exit(status);
118+
}
119+
120+
async function main() {
121+
const options = parseArgs();
122+
if (options.version) {
123+
const pkg = JSON.parse(await fs.readFile(new URL("../../../package.json", import.meta.url)));
124+
console.log(`${pkg.name} v${pkg.version}`);
125+
return;
126+
}
127+
128+
await runWASIOnBrowser(options);
129+
}
130+
131+
await main();

test/adapters/node/adapter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../shared/adapter.py

test/run-wasi.mjs renamed to test/adapters/node/run-wasi.mjs

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,9 @@
22

33
import fs from 'fs/promises';
44
import path from 'path';
5-
import { WASI, wasi, strace, OpenFile, File, Directory, PreopenDirectory, Fd } from "../dist/index.js"
6-
7-
function parseArgs() {
8-
const args = process.argv.slice(2);
9-
const options = {
10-
"version": false,
11-
"test-file": null,
12-
"arg": [],
13-
"env": [],
14-
"dir": [],
15-
};
16-
while (args.length > 0) {
17-
const arg = args.shift();
18-
if (arg.startsWith("--")) {
19-
let [name, value] = arg.split("=");
20-
name = name.slice(2);
21-
if (Object.prototype.hasOwnProperty.call(options, name)) {
22-
if (value === undefined) {
23-
value = args.shift() || true;
24-
}
25-
if (Array.isArray(options[name])) {
26-
options[name].push(value);
27-
} else {
28-
options[name] = value;
29-
}
30-
}
31-
}
32-
}
33-
34-
return options;
35-
}
5+
import { WASI, wasi, strace, OpenFile, File, Directory, PreopenDirectory, Fd } from "../../../dist/index.js"
6+
import { parseArgs } from "../shared/parseArgs.mjs"
7+
import { walkFs } from "../shared/walkFs.mjs"
368

379
class NodeStdout extends Fd {
3810
constructor(out) {
@@ -65,26 +37,22 @@ class NodeStdout extends Fd {
6537
}
6638
}
6739

68-
async function cloneToMemfs(dir) {
69-
const destContents = {};
70-
const srcContents = await fs.readdir(dir, { withFileTypes: true });
71-
for (let entry of srcContents) {
72-
const entryPath = path.join(dir, entry.name);
73-
if (entry.isDirectory()) {
74-
destContents[entry.name] = new Directory(await cloneToMemfs(entryPath));
75-
} else {
76-
const buffer = await fs.readFile(entryPath);
77-
const file = new File(buffer);
78-
destContents[entry.name] = file;
79-
}
80-
}
81-
return destContents;
82-
}
83-
8440
async function derivePreopens(dirs) {
8541
const preopens = [];
8642
for (let dir of dirs) {
87-
const contents = await cloneToMemfs(dir);
43+
const contents = await walkFs(dir, (name, entry, out) => {
44+
switch (entry.kind) {
45+
case "dir":
46+
entry = new Directory(entry.contents);
47+
break;
48+
case "file":
49+
entry = new File(entry.buffer);
50+
break;
51+
default:
52+
throw new Error(`Unexpected entry kind: ${entry.kind}`);
53+
}
54+
return { ...out, [name]: entry}
55+
}, {})
8856
const preopen = new PreopenDirectory(dir, contents);
8957
preopens.push(preopen);
9058
}
@@ -123,7 +91,7 @@ async function runWASI(options) {
12391
async function main() {
12492
const options = parseArgs();
12593
if (options.version) {
126-
const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url)));
94+
const pkg = JSON.parse(await fs.readFile(new URL("../../../package.json", import.meta.url)));
12795
console.log(`${pkg.name} v${pkg.version}`);
12896
return;
12997
}
File renamed without changes.

test/adapters/shared/parseArgs.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/// Parse command line arguments given by `adapter.py` through
2+
/// `wasi-testsuite`'s test runner.
3+
export function parseArgs() {
4+
const args = process.argv.slice(2);
5+
const options = {
6+
"version": false,
7+
"test-file": null,
8+
"arg": [],
9+
"env": [],
10+
"dir": [],
11+
};
12+
while (args.length > 0) {
13+
const arg = args.shift();
14+
if (arg.startsWith("--")) {
15+
let [name, value] = arg.split("=");
16+
name = name.slice(2);
17+
if (Object.prototype.hasOwnProperty.call(options, name)) {
18+
if (value === undefined) {
19+
value = args.shift() || true;
20+
}
21+
if (Array.isArray(options[name])) {
22+
options[name].push(value);
23+
} else {
24+
options[name] = value;
25+
}
26+
}
27+
}
28+
}
29+
30+
return options;
31+
}

test/adapters/shared/walkFs.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
4+
/**
5+
* Walks a directory recursively and returns the result of combining the found entries
6+
* using the given reducer function.
7+
*
8+
* @typedef {{ kind: "dir", contents: any } | { kind: "file", buffer: Buffer }} Entry
9+
* @param {string} dir
10+
* @param {(name: string, entry: Entry, out: any) => any} nextPartialResult
11+
* @param {any} initial
12+
*/
13+
export async function walkFs(dir, nextPartialResult, initial) {
14+
let result = { ...initial }
15+
const srcContents = await fs.readdir(dir, { withFileTypes: true });
16+
for (let entry of srcContents) {
17+
const entryPath = path.join(dir, entry.name);
18+
if (entry.isDirectory()) {
19+
const contents = await walkFs(entryPath, nextPartialResult, initial);
20+
result = nextPartialResult(entry.name, { kind: "dir", contents }, result);
21+
} else {
22+
const buffer = await fs.readFile(entryPath);
23+
result = nextPartialResult(entry.name, { kind: "file", buffer }, result);
24+
}
25+
}
26+
return result;
27+
}

0 commit comments

Comments
 (0)