From 70929dd134c3f81dd3b1a70f0575859b8f99d090 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Tue, 28 Jan 2025 06:57:05 +0900 Subject: [PATCH] feat(tests): add tests for browser JSPI & asyncify Signed-off-by: Victor Adossi --- test/async.browser.js | 227 ++++++++++++++++++ test/async.js | 68 ++---- .../test-pages/something__test.async.html | 100 ++++++++ test/helpers.js | 103 +++++++- test/test.js | 2 + 5 files changed, 436 insertions(+), 64 deletions(-) create mode 100644 test/async.browser.js create mode 100644 test/fixtures/browser/test-pages/something__test.async.html diff --git a/test/async.browser.js b/test/async.browser.js new file mode 100644 index 00000000..bc141754 --- /dev/null +++ b/test/async.browser.js @@ -0,0 +1,227 @@ +import { dirname, join, resolve } from "node:path"; +import { execArgv } from "node:process"; +import { deepStrictEqual, ok, strictEqual, fail } from "node:assert"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; + +import { fileURLToPath, pathToFileURL } from "url"; +import puppeteer from "puppeteer"; + +import { + exec, + jcoPath, + getTmpDir, + setupAsyncTest, + startTestWebServer, + loadTestPage, +} from "./helpers.js"; + +const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") + ? ["--multi-memory"] + : []; + +const AsyncFunction = (async () => {}).constructor; + +export async function asyncBrowserTest(_fixtures) { + suite("Async", () => { + var tmpDir; + var outDir; + var outFile; + + suiteSetup(async function () { + tmpDir = await getTmpDir(); + outDir = resolve(tmpDir, "out-component-dir"); + outFile = resolve(tmpDir, "out-component-file"); + + const modulesDir = resolve(tmpDir, "node_modules", "@bytecodealliance"); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), + resolve(modulesDir, "preview2-shim"), + "dir", + ); + }); + + suiteTeardown(async function () { + try { + await rm(tmpDir, { recursive: true }); + } catch {} + }); + + teardown(async function () { + try { + await rm(outDir, { recursive: true }); + await rm(outFile); + } catch {} + }); + + test("Transpile async (browser, asyncify)", async () => { + const componentName = "async-call"; + const { + instance, + cleanup: componentCleanup, + outputDir, + } = await setupAsyncTest({ + asyncMode: "asyncify", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + "something:test/test-interface": { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: ["something:test/test-interface#call-async"], + asyncExports: ["run-async"], + }, + }, + }, + }); + const moduleName = componentName.toLowerCase().replaceAll("-", "_"); + const moduleRelPath = `${moduleName}/${moduleName}.js`; + + // Start a test web server + const { + server, + serverPort, + cleanup: webServerCleanup, + } = await startTestWebServer({ + routes: [ + // NOTE: the goal here is to serve relative paths via the browser hash + // + // (1) browser visits test page (served by test web server) + // (2) browser requests component itself by looking at URL hash fragment + // (i.e. "#transpiled:async_call/async_call.js" -> , "/transpiled/async_call/async_call.js") + // (i.e. "/transpiled/async_call/async_call.js" -> file read of /tmp/xxxxxx/async_call/async_call.js) + { + urlPrefix: "/transpiled/", + basePathURL: pathToFileURL(`${outputDir}/`), + }, + // Serve all other files (ex. the initial HTML for the page) + { basePathURL: import.meta.url }, + ], + }); + + // Start a browser to visit the test server + const browser = await puppeteer.launch(); + + // Load the test page in the browser, which will trigger tests against + // the component and/or related browser polyfills + const { + page, + output: { json }, + } = await loadTestPage({ + browser, + serverPort, + path: "fixtures/browser/test-pages/something__test.async.html", + hash: `transpiled:${moduleRelPath}`, + }); + + // Check the output expected to be returned from handle of the + // guest export (this depends on the component) + deepStrictEqual(json, { responseText: "callAsync" }); + + await browser.close(); + await webServerCleanup(); + await componentCleanup(); + }); + + test("Transpile async (browser, JSPI)", async () => { + const componentName = "async-call"; + const { + instance, + cleanup: componentCleanup, + outputDir, + } = await setupAsyncTest({ + asyncMode: "jspi", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + "something:test/test-interface": { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: ["something:test/test-interface#call-async"], + asyncExports: ["run-async"], + }, + }, + }, + }); + const moduleName = componentName.toLowerCase().replaceAll("-", "_"); + const moduleRelPath = `${moduleName}/${moduleName}.js`; + + strictEqual( + instance.runSync instanceof AsyncFunction, + false, + "runSync() should be a sync function", + ); + strictEqual( + instance.runAsync instanceof AsyncFunction, + true, + "runAsync() should be an async function", + ); + + // Start a test web server + const { + server, + serverPort, + cleanup: webServerCleanup, + } = await startTestWebServer({ + routes: [ + // NOTE: the goal here is to serve relative paths via the browser hash + // + // (1) browser visits test page (served by test web server) + // (2) browser requests component itself by looking at URL hash fragment + // (i.e. "#transpiled:async_call/async_call.js" -> , "/transpiled/async_call/async_call.js") + // (i.e. "/transpiled/async_call/async_call.js" -> file read of /tmp/xxxxxx/async_call/async_call.js) + { + urlPrefix: "/transpiled/", + basePathURL: pathToFileURL(`${outputDir}/`), + }, + // Serve all other files (ex. the initial HTML for the page) + { basePathURL: import.meta.url }, + ], + }); + + // Start a browser to visit the test server + const browser = await puppeteer.launch({ + args: [ + "--enable-experimental-webassembly-jspi", + "--flag-switches-begin", + "--enable-features=WebAssemblyExperimentalJSPI", + "--flag-switches-end", + ], + }); + + // Load the test page in the browser, which will trigger tests against + // the component and/or related browser polyfills + const { + page, + output: { json }, + } = await loadTestPage({ + browser, + serverPort, + path: "fixtures/browser/test-pages/something__test.async.html", + hash: `transpiled:${moduleRelPath}`, + }); + + // Check the output expected to be returned from handle of the + // guest export (this depends on the component) + deepStrictEqual(json, { responseText: "callAsync" }); + + await browser.close(); + await webServerCleanup(); + await componentCleanup(); + }); + }); +} diff --git a/test/async.js b/test/async.js index 50b97e3b..341ccdf9 100644 --- a/test/async.js +++ b/test/async.js @@ -1,17 +1,19 @@ -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { execArgv } from "node:process"; import { deepStrictEqual, ok, strictEqual, fail } from "node:assert"; -import { - mkdir, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "url"; +import puppeteer from "puppeteer"; -import { exec, jcoPath, getTmpDir, setupAsyncTest } from "./helpers.js"; +import { + exec, + jcoPath, + getTmpDir, + setupAsyncTest, + startTestWebServer, + loadTestPage, +} from "./helpers.js"; const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") ? ["--multi-memory"] @@ -35,7 +37,7 @@ export async function asyncTest(_fixtures) { await symlink( fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), resolve(modulesDir, "preview2-shim"), - "dir" + "dir", ); }); @@ -69,7 +71,7 @@ export async function asyncTest(_fixtures) { ok(source.toString().includes("export { test")); }); - test("Transpile async (JSPI)", async () => { + test("Transpile async (NodeJS, JSPI)", async () => { const { instance, cleanup, component } = await setupAsyncTest({ asyncMode: "jspi", component: { @@ -105,7 +107,7 @@ export async function asyncTest(_fixtures) { await cleanup(); }); - test("Transpile async (asyncify)", async () => { + test("Transpile async (NodeJS, asyncify)", async () => { const { instance, cleanup } = await setupAsyncTest({ asyncMode: "asyncify", component: { @@ -140,47 +142,5 @@ export async function asyncTest(_fixtures) { await cleanup(); }); - - // TODO: fill out `RequestOption` impl (browser-async/http/types) - // TODO: allow `Pollable` to be re-used (when poll is called again?? how is this triggered?) - // TODO: fill out browser-async sockets with "not implemented" errors (we don't have much choice but to trap here) - - test("Transpile async (asyncify)", async () => { - const { instance, cleanup } = await setupAsyncTest({ - asyncMode: "asyncify", - component: { - name: "async_call", - path: resolve("test/fixtures/components/async_call.component.wasm"), - imports: { - 'something:test/test-interface': { - callAsync: async () => "called async", - callSync: () => "called sync", - }, - }, - }, - jco: { - transpile: { - extraArgs: { - asyncImports: [ - "something:test/test-interface#call-async", - ], - asyncExports: [ - "run-async", - ], - }, - } - }, - }); - - strictEqual(instance.runSync instanceof AsyncFunction, false, "runSync() should be a sync function"); - strictEqual(instance.runAsync instanceof AsyncFunction, true, "runAsync() should be an async function"); - - strictEqual(instance.runSync(), "called sync"); - strictEqual(await instance.runAsync(), "called async"); - - await cleanup(); - }); - }); } - diff --git a/test/fixtures/browser/test-pages/something__test.async.html b/test/fixtures/browser/test-pages/something__test.async.html new file mode 100644 index 00000000..e056d88b --- /dev/null +++ b/test/fixtures/browser/test-pages/something__test.async.html @@ -0,0 +1,100 @@ + + + + diff --git a/test/helpers.js b/test/helpers.js index 5ac62d33..c3e8d38c 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -1,5 +1,6 @@ -import { env, argv, execArgv } from "node:process"; -import { createServer } from "node:net"; +import { version, env, argv, execArgv } from "node:process"; +import { createServer as createNetServer } from "node:net"; +import { createServer as createHttpServer } from "node:http"; import { basename, join, @@ -9,6 +10,7 @@ import { sep, relative, dirname, + extname, } from "node:path"; import { cp, @@ -22,6 +24,7 @@ import { ok, strictEqual } from "node:assert"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; +import mime from "mime"; import { pathToFileURL } from "url"; import { transpile } from "../src/api.js"; @@ -158,6 +161,7 @@ export async function setupAsyncTest(args) { // Build out the whole-test cleanup function let cleanup = async () => { + log("[cleanup] cleaning up component..."); if (componentBuildCleanup) { try { await componentBuildCleanup(); @@ -170,6 +174,10 @@ export async function setupAsyncTest(args) { // Return early if the test was intended to run on JSPI but JSPI is not enabled if (asyncMode == "jspi" && typeof WebAssembly?.Suspending !== "function") { + let nodeMajorVersion = parseInt(version.replace("v","").split(".")[0]); + if (nodeMajorVersion < 23) { + throw new Error("NodeJS versions <23 does not support JSPI integration, please use a NodeJS version >=23"); + } await cleanup(); throw new Error( "JSPI async type skipped, but JSPI was not enabled -- please ensure test is run from an environment with JSPI integration (ex. node with the --experimental-wasm-jspi flag)", @@ -207,10 +215,6 @@ export async function setupAsyncTest(args) { transpileOpts.preoptimized = true; } - // log("EXEC ARGS?", transpileExecArgs); - // log(`EXECable\njco ${transpileExecArgs.join(" ")}`); - // await new Promise(resolve => setTimeout(resolve, 60_000)); - const componentBytes = await readFile(componentPath); // Perform transpilation, write out files @@ -234,8 +238,8 @@ export async function setupAsyncTest(args) { // Import the transpiled JS const esModuleOutputPath = join(moduleOutputDir, `${componentName}.js`); - const esModuleSourcePath = pathToFileURL(esModuleOutputPath); - const module = await import(esModuleSourcePath); + const esModuleSourcePathURL = pathToFileURL(esModuleOutputPath); + const module = await import(esModuleSourcePathURL); // TODO: DEBUG module import not working, file is missing! // log("PRE INSTANTIATION", { moduleOutputDir }); @@ -252,10 +256,12 @@ export async function setupAsyncTest(args) { return { module, - esModuleSourcePath, + esModuleOutputPath, + esModuleSourcePathURL, esModuleRelativeSourcePath: relative(outputDir, esModuleOutputPath), instance, cleanup, + outputDir, component: { name: componentName, path: componentPath, @@ -419,6 +425,7 @@ export async function loadTestPage(args) { const serverPort = args.serverPort ? args.serverPort : 8080; const hashURL = `http://localhost:${serverPort}/${path}#${hash}`; + log(`[browser] attempting to navigate to [${hashURL}]`); const hashTest = await page.goto(hashURL); ok(hashTest.ok(), `navigated to URL [${hashURL}]`); @@ -454,7 +461,7 @@ export async function loadTestPage(args) { // Utility function for getting a random port export async function getRandomPort() { return await new Promise((resolve) => { - const server = createServer(); + const server = createNetServer(); server.listen(0, function () { const port = this.address().port; server.on("close", () => resolve(port)); @@ -462,3 +469,79 @@ export async function getRandomPort() { }); }); } + +/** + * Start a web server that serves components and related files from a + * given directory. + * + * @param {{ servePaths: { basePath: string, urlPrefix: string }[] }} args + * @returns {Promise<{ serverPort: number, server: object }>} + */ +export async function startTestWebServer(args) { + if (!args.routes) { throw new Error("missing serve paths"); } + const serverPort = await getRandomPort(); + + const server = createHttpServer(async (req, res) => { + // Build a utility fucntion for returning an error + const returnError = (e) => { + log(`[webserver] failed to find file [${fileURL}]`); + res.writeHead(404); + res.end(e.message); + }; + + // Find route to serve incoming request + const route = args.routes.find(dir => { + return !dir.urlPrefix || (dir.urlPrefix && req.url.startsWith(dir.urlPrefix)); + }); + if (!route) { + log(`[webserver] failed to find route to serve [${req.url.path}]`); + returnError(new Error(`failed to resolve url [${req.url}] with any provided routes`)); + return; + } + if (!route.basePathURL) { throw new Error("invalid/missing path in specified route"); } + + const fileURL = new URL( + `./${req.url.slice(route.urlPrefix ? route.urlPrefix.length : "")}`, + route.basePathURL, + ); + + log(`[webserver] attempting to read file on disk @ [${fileURL}]`); + + // Attempt to read the file + try { + const html = await readFile(fileURL); + res.writeHead(200, { + "content-type": mime.getType(extname(req.url)), + }); + res.end(html); + log(`[webserver] served file [${fileURL}]`); + } catch (e) { + if (e.code === "ENOENT") { + returnError(e); + } else { + log(`[webserver] ERROR [${e}]`); + res.writeHead(500); + res.end(e.message); + } + } + }); + + const served = new Promise(resolve => { + server.on('listening', () => { + resolve({ + serverPort, + server, + cleanup: async () => { + log("[cleanup] cleaning up http server..."); + server.close(() => { + log("server successfully closed"); + }); + } + }); + }); + }); + + server.listen(serverPort); + + return await served; +} diff --git a/test/test.js b/test/test.js index 8780eef0..38125133 100644 --- a/test/test.js +++ b/test/test.js @@ -29,6 +29,7 @@ import { preview2Test } from './preview2.js'; import { witTest } from './wit.js'; import { tsTest } from './typescript.js'; import { asyncTest } from './async.js'; +import { asyncBrowserTest } from './async.browser.js'; await codegenTest(componentFixtures); tsTest(); @@ -39,6 +40,7 @@ await apiTest(componentFixtures); await cliTest(componentFixtures); await witTest(); await asyncTest(); +await asyncBrowserTest(); if (platform !== 'win32') { await browserTest();