diff --git a/package.json b/package.json index 2438297..450ec0e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "build": "swc src -d dist && tsc --emitDeclarationOnly", "prepare": "swc src -d dist && tsc --emitDeclarationOnly", - "test": "./test/run-testsuite.sh", + "test:node": "./test/run-testsuite.sh node", + "test:browser": "playwright-core install && ./test/run-testsuite.sh browser", + "test": "npm run test:node && npm run test:browser", "check": "tsc --noEmit && prettier src -c && eslint src/" }, "repository": { diff --git a/test/adapters/browser/adapter.py b/test/adapters/browser/adapter.py new file mode 120000 index 0000000..9c39229 --- /dev/null +++ b/test/adapters/browser/adapter.py @@ -0,0 +1 @@ +../shared/adapter.py \ No newline at end of file diff --git a/test/adapters/browser/run-test.html b/test/adapters/browser/run-test.html new file mode 100644 index 0000000..7efd1ca --- /dev/null +++ b/test/adapters/browser/run-test.html @@ -0,0 +1,88 @@ + + + + diff --git a/test/adapters/browser/run-wasi.mjs b/test/adapters/browser/run-wasi.mjs new file mode 100644 index 0000000..e35845b --- /dev/null +++ b/test/adapters/browser/run-wasi.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import fs from 'fs/promises'; +import path from 'path'; +import { chromium } from "playwright" +import { parseArgs } from "../shared/parseArgs.mjs" +import { walkFs } from "../shared/walkFs.mjs" + +async function derivePreopens(dirs) { + const preopens = []; + for (let dir of dirs) { + const contents = await walkFs(dir, (name, entry, out) => { + if (entry.kind === "file") { + // Convert buffer to array to make it serializable. + entry.buffer = Array.from(entry.buffer); + } + return { ...out, [name]: entry }; + }, {}); + preopens.push({ dir, contents }); + } + return preopens; +} + +/** + * Configure routes for the browser harness. + * + * @param {import('playwright').BrowserContext} context + * @param {string} harnessURL + */ +async function configureRoutes(context, harnessURL) { + + // Serve the main test page. + context.route(`${harnessURL}/run-test.html`, async route => { + const dirname = new URL(".", import.meta.url).pathname; + const body = await fs.readFile(path.join(dirname, "run-test.html"), "utf8"); + route.fulfill({ + status: 200, + contentType: 'text/html', + // Browsers reduce the precision of performance.now() if the page is not + // isolated. To keep the precision for `clock_get_time` we need to set the + // following headers. + // See: https://developer.mozilla.org/en-US/docs/Web/API/Performance/now#security_requirements + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + body, + }); + }) + + // Serve wasi-testsuite files. + // e.g. http://browser-wasi-shim.localhost/home/me/browser_wasi_shim/test/wasi-testsuite/xxx + let projectDir = path.join(new URL("../../wasi-testsuite", import.meta.url).pathname); + projectDir = path.resolve(projectDir); + context.route(`${harnessURL}${projectDir}/**/*`, async route => { + const pathname = new URL(route.request().url()).pathname; + const relativePath = pathname.slice(pathname.indexOf(projectDir) + projectDir.length); + const content = await fs.readFile(path.join(projectDir, relativePath)); + route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: content, + }); + }); + + // Serve transpiled browser_wasi_shim files under ./dist. + context.route(`${harnessURL}/dist/*.js`, async route => { + const pathname = new URL(route.request().url()).pathname; + const distRelativePath = pathname.slice(pathname.indexOf("/dist/")); + const distDir = new URL("../../..", import.meta.url); + const distPath = path.join(distDir.pathname, distRelativePath); + const content = await fs.readFile(distPath); + route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: content, + }); + }); +} + +async function runWASIOnBrowser(options) { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const harnessURL = 'http://browser-wasi-shim.localhost' + + await configureRoutes(context, harnessURL); + + const page = await context.newPage(); + // Expose stdout/stderr bindings to allow test driver to capture output. + page.exposeBinding("bindingWriteIO", (_, buffer, destination) => { + buffer = Buffer.from(buffer); + switch (destination) { + case "stdout": + process.stdout.write(buffer); + break; + case "stderr": + process.stderr.write(buffer); + break; + default: + throw new Error(`Unknown destination ${destination}`); + } + }); + // Expose a way to serialize preopened directories to the browser. + page.exposeBinding("bindingDerivePreopens", async (_, dirs) => { + return await derivePreopens(dirs); + }); + + page.on('console', msg => console.log(msg.text())); + page.on('pageerror', ({ message }) => { + console.log('PAGE ERROR:', message) + process.exit(1); // Unexpected error. + }); + + await page.goto(`${harnessURL}/run-test.html`, { waitUntil: "load" }) + const status = await page.evaluate(async (o) => await window.runWASI(o), options) + await page.close(); + process.exit(status); +} + +async function main() { + const options = parseArgs(); + if (options.version) { + const pkg = JSON.parse(await fs.readFile(new URL("../../../package.json", import.meta.url))); + console.log(`${pkg.name} v${pkg.version}`); + return; + } + + await runWASIOnBrowser(options); +} + +await main(); diff --git a/test/adapters/node/adapter.py b/test/adapters/node/adapter.py new file mode 120000 index 0000000..9c39229 --- /dev/null +++ b/test/adapters/node/adapter.py @@ -0,0 +1 @@ +../shared/adapter.py \ No newline at end of file diff --git a/test/run-wasi.mjs b/test/adapters/node/run-wasi.mjs similarity index 61% rename from test/run-wasi.mjs rename to test/adapters/node/run-wasi.mjs index f1f7c6e..16efeff 100644 --- a/test/run-wasi.mjs +++ b/test/adapters/node/run-wasi.mjs @@ -2,37 +2,9 @@ import fs from 'fs/promises'; import path from 'path'; -import { WASI, wasi, strace, OpenFile, File, Directory, PreopenDirectory, Fd } from "../dist/index.js" - -function parseArgs() { - const args = process.argv.slice(2); - const options = { - "version": false, - "test-file": null, - "arg": [], - "env": [], - "dir": [], - }; - while (args.length > 0) { - const arg = args.shift(); - if (arg.startsWith("--")) { - let [name, value] = arg.split("="); - name = name.slice(2); - if (Object.prototype.hasOwnProperty.call(options, name)) { - if (value === undefined) { - value = args.shift() || true; - } - if (Array.isArray(options[name])) { - options[name].push(value); - } else { - options[name] = value; - } - } - } - } - - return options; -} +import { WASI, wasi, strace, OpenFile, File, Directory, PreopenDirectory, Fd } from "../../../dist/index.js" +import { parseArgs } from "../shared/parseArgs.mjs" +import { walkFs } from "../shared/walkFs.mjs" class NodeStdout extends Fd { constructor(out) { @@ -65,26 +37,22 @@ class NodeStdout extends Fd { } } -async function cloneToMemfs(dir) { - const destContents = {}; - const srcContents = await fs.readdir(dir, { withFileTypes: true }); - for (let entry of srcContents) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - destContents[entry.name] = new Directory(await cloneToMemfs(entryPath)); - } else { - const buffer = await fs.readFile(entryPath); - const file = new File(buffer); - destContents[entry.name] = file; - } - } - return destContents; -} - async function derivePreopens(dirs) { const preopens = []; for (let dir of dirs) { - const contents = await cloneToMemfs(dir); + const contents = await walkFs(dir, (name, entry, out) => { + switch (entry.kind) { + case "dir": + entry = new Directory(entry.contents); + break; + case "file": + entry = new File(entry.buffer); + break; + default: + throw new Error(`Unexpected entry kind: ${entry.kind}`); + } + return { ...out, [name]: entry} + }, {}) const preopen = new PreopenDirectory(dir, contents); preopens.push(preopen); } @@ -123,7 +91,7 @@ async function runWASI(options) { async function main() { const options = parseArgs(); if (options.version) { - const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url))); + const pkg = JSON.parse(await fs.readFile(new URL("../../../package.json", import.meta.url))); console.log(`${pkg.name} v${pkg.version}`); return; } diff --git a/test/adapter.py b/test/adapters/shared/adapter.py similarity index 100% rename from test/adapter.py rename to test/adapters/shared/adapter.py diff --git a/test/adapters/shared/parseArgs.mjs b/test/adapters/shared/parseArgs.mjs new file mode 100644 index 0000000..b59bff9 --- /dev/null +++ b/test/adapters/shared/parseArgs.mjs @@ -0,0 +1,31 @@ +/// Parse command line arguments given by `adapter.py` through +/// `wasi-testsuite`'s test runner. +export function parseArgs() { + const args = process.argv.slice(2); + const options = { + "version": false, + "test-file": null, + "arg": [], + "env": [], + "dir": [], + }; + while (args.length > 0) { + const arg = args.shift(); + if (arg.startsWith("--")) { + let [name, value] = arg.split("="); + name = name.slice(2); + if (Object.prototype.hasOwnProperty.call(options, name)) { + if (value === undefined) { + value = args.shift() || true; + } + if (Array.isArray(options[name])) { + options[name].push(value); + } else { + options[name] = value; + } + } + } + } + + return options; +} diff --git a/test/adapters/shared/walkFs.mjs b/test/adapters/shared/walkFs.mjs new file mode 100644 index 0000000..16fc548 --- /dev/null +++ b/test/adapters/shared/walkFs.mjs @@ -0,0 +1,27 @@ +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Walks a directory recursively and returns the result of combining the found entries + * using the given reducer function. + * + * @typedef {{ kind: "dir", contents: any } | { kind: "file", buffer: Buffer }} Entry + * @param {string} dir + * @param {(name: string, entry: Entry, out: any) => any} nextPartialResult + * @param {any} initial + */ +export async function walkFs(dir, nextPartialResult, initial) { + let result = { ...initial } + const srcContents = await fs.readdir(dir, { withFileTypes: true }); + for (let entry of srcContents) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const contents = await walkFs(entryPath, nextPartialResult, initial); + result = nextPartialResult(entry.name, { kind: "dir", contents }, result); + } else { + const buffer = await fs.readFile(entryPath); + result = nextPartialResult(entry.name, { kind: "file", buffer }, result); + } + } + return result; +} diff --git a/test/run-testsuite.sh b/test/run-testsuite.sh index 253ad11..28b58f5 100755 --- a/test/run-testsuite.sh +++ b/test/run-testsuite.sh @@ -4,11 +4,17 @@ set -euo pipefail TEST_DIR="$(cd "$(dirname $0)" && pwd)" TESTSUITE_ROOT="$TEST_DIR/wasi-testsuite" +ADAPTER="node" +# Take the first argument as the adapter name if given +if [ $# -gt 0 ]; then + ADAPTER="$1" + shift +fi python3 "$TESTSUITE_ROOT/test-runner/wasi_test_runner.py" \ --test-suite "$TESTSUITE_ROOT/tests/assemblyscript/testsuite/" \ "$TESTSUITE_ROOT/tests/c/testsuite/" \ "$TESTSUITE_ROOT/tests/rust/testsuite/" \ - --runtime-adapter "$TEST_DIR/adapter.py" \ + --runtime-adapter "$TEST_DIR/adapters/$ADAPTER/adapter.py" \ --exclude-filter "$TEST_DIR/skip.json" \ $@