diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e8e8bc8cb..0dcac943be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -102,6 +102,12 @@ jobs: node: 20.x name: ${{ matrix.os }} - ${{ matrix.python }} - ${{ matrix.node }} runs-on: ${{ matrix.os }} + env: + WASI_VERSION: '22' + WASI_VERSION_FULL: '22.0' + WASI_SDK_PATH: 'wasi-sdk-22.0' + EM_VERSION: '3.1.52' + EM_CACHE_FOLDER: 'emsdk-cache' steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -116,6 +122,24 @@ jobs: env: PYTHON_VERSION: ${{ matrix.python }} # Why do this? - uses: seanmiddleditch/gha-setup-ninja@v4 + - uses: mymindstorm/setup-emsdk@v14 + with: + version: ${{ env.EM_VERSION }} + actions-cache-folder: ${{ env.EM_CACHE_FOLDER }} + - name: Install wasi-sdk (macOS or Linux) + shell: bash + if: ${{ !startsWith(matrix.os, 'windows') }} + run: | + wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_VERSION}/wasi-sdk-${WASI_VERSION_FULL}-${{ startsWith(matrix.os, 'macos') && 'macos' || 'linux' }}.tar.gz + mkdir -p $WASI_SDK_PATH + tar zxvf wasi-sdk-${WASI_VERSION_FULL}-${{ startsWith(matrix.os, 'macos') && 'macos' || 'linux' }}.tar.gz -C $WASI_SDK_PATH --strip 1 + - name: Install wasi-sdk (Windows) + shell: pwsh + if: ${{ startsWith(matrix.os, 'windows') }} + run: | + Start-BitsTransfer -Source https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${env:WASI_VERSION}/wasi-sdk-${env:WASI_VERSION_FULL}.m-mingw.tar.gz + New-Item -ItemType Directory -Path ${env:WASI_SDK_PATH} + tar -zxvf wasi-sdk-${env:WASI_VERSION_FULL}.m-mingw.tar.gz -C ${env:WASI_SDK_PATH} --strip 1 - name: Install Dependencies run: | npm install @@ -126,7 +150,7 @@ jobs: echo 'GYP_MSVS_VERSION=2015' >> $Env:GITHUB_ENV echo 'GYP_MSVS_OVERRIDE_PATH=C:\\Dummy' >> $Env:GITHUB_ENV - name: Run Python Tests - run: python -m pytest + run: python -m pytest --ignore=${{ env.EM_CACHE_FOLDER }} - name: Run Tests (macOS or Linux) if: "!startsWith(matrix.os, 'windows')" shell: bash @@ -135,7 +159,7 @@ jobs: FULL_TEST: ${{ (matrix.node == '20.x' && matrix.python == '3.12') && '1' || '0' }} - name: Run Tests (Windows) if: startsWith(matrix.os, 'windows') - shell: pwsh - run: npm run test --python="${env:pythonLocation}\\python.exe" + shell: bash # Building wasm on Windows requires using make generator, it only works in bash + run: npm run test --python="${pythonLocation}\\python.exe" env: FULL_TEST: ${{ (matrix.node == '20.x' && matrix.python == '3.12') && '1' || '0' }} diff --git a/package.json b/package.json index e9402d7923..a94917cf0a 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,11 @@ "node": "^16.14.0 || >=18.0.0" }, "devDependencies": { + "@emnapi/core": "^1.2.0", + "@emnapi/runtime": "^1.2.0", "bindings": "^1.5.0", "cross-env": "^7.0.3", + "emnapi": "^1.2.0", "mocha": "^10.2.0", "nan": "^2.14.2", "require-inject": "^1.4.4", diff --git a/test/node_modules/hello_wasm/binding.gyp b/test/node_modules/hello_wasm/binding.gyp new file mode 100644 index 0000000000..cc8a21694e --- /dev/null +++ b/test/node_modules/hello_wasm/binding.gyp @@ -0,0 +1,62 @@ +{ + "targets": [ + { + "target_name": "hello", + "sources": [ "hello.c" ], + "conditions": [ + [ + "OS == 'emscripten'", + { + "product_extension": "node.js", + "ldflags": [ + '-sMODULARIZE=1', + '-sEXPORT_NAME=hello', + '-sWASM_ASYNC_COMPILATION=0' + ], + 'xcode_settings': { + 'OTHER_LDFLAGS': [ + '-sMODULARIZE=1', + '-sEXPORT_NAME=hello', + '-sWASM_ASYNC_COMPILATION=0' + ] + }, + 'conditions': [ + ['emnapi_manual_linking != 0', { + 'dependencies': [ + ' + +#if !defined(__wasm__) || (defined(__EMSCRIPTEN__) || defined(__wasi__)) +#include +#else +#define assert(x) do { if (!(x)) { __builtin_trap(); } } while (0) +#endif + +static napi_value hello(napi_env env, napi_callback_info info) { + napi_value greeting; + assert(napi_ok == napi_create_string_utf8(env, "world", NAPI_AUTO_LENGTH, &greeting)); + return greeting; +} + +NAPI_MODULE_INIT() { + napi_value hello_function; + assert(napi_ok == napi_create_function(env, "hello", NAPI_AUTO_LENGTH, + hello, NULL, &hello_function)); + assert(napi_ok == napi_set_named_property(env, exports, "hello", hello_function)); + return exports; +} diff --git a/test/node_modules/hello_wasm/hello.js b/test/node_modules/hello_wasm/hello.js new file mode 100644 index 0000000000..efd5987331 --- /dev/null +++ b/test/node_modules/hello_wasm/hello.js @@ -0,0 +1,50 @@ +const path = require('path') +const fs = require('fs') + +const addon = (function () { + const entry = (() => { + try { + return require.resolve('./build/Release/hello.node') + } catch (_) { + return require.resolve('./build/Release/hello.wasm') + } + })() + + const ext = path.extname(entry) + if (ext === '.node') { + return require(entry) + } + + const emnapi = require('@emnapi/runtime') + if (ext === '.js') { + return require(entry)().emnapiInit({ + context: emnapi.getDefaultContext() + }) + } + + if (ext === '.wasm') { + const { instantiateNapiModuleSync } = require('@emnapi/core') + const { napiModule } = instantiateNapiModuleSync(fs.readFileSync(entry), { + context: emnapi.getDefaultContext(), + wasi: new (require('wasi').WASI)({ version: 'preview1' }), + asyncWorkPoolSize: process.env.UV_THREADPOOL_SIZE || 4, + overwriteImports (imports) { + imports.env.memory = new WebAssembly.Memory({ + initial: 16777216 / 65536, + maximum: 2147483648 / 65536, + shared: true + }) + }, + onCreateWorker () { + return new (require('worker_threads').Worker)(path.join(__dirname, './worker.js'), { + env: process.env, + execArgv: ['--experimental-wasi-unstable-preview1'] + }) + } + }) + return napiModule.exports + } + throw new Error('Failed to initialize Node-API wasm module') +})() + +exports.hello = function() { return addon.hello() } diff --git a/test/node_modules/hello_wasm/package.json b/test/node_modules/hello_wasm/package.json new file mode 100644 index 0000000000..c525fb67ad --- /dev/null +++ b/test/node_modules/hello_wasm/package.json @@ -0,0 +1,16 @@ +{ + "name": "hello_wasm", + "version": "0.0.0", + "description": "Node.js Addons Example #2", + "main": "hello.js", + "private": true, + "dependencies": { + "@emnapi/core": "^1.2.0", + "@emnapi/runtime": "^1.2.0", + "emnapi": "^1.2.0" + }, + "scripts": { + "test": "node hello.js" + }, + "gypfile": true +} diff --git a/test/test-wasm.js b/test/test-wasm.js new file mode 100644 index 0000000000..b08cff949f --- /dev/null +++ b/test/test-wasm.js @@ -0,0 +1,135 @@ +'use strict' + +const { describe, it } = require('mocha') +const assert = require('assert') +const path = require('path') +const cp = require('child_process') +const util = require('../lib/util') +const { platformTimeout } = require('./common') + +const wasmAddonPath = path.resolve(__dirname, 'node_modules', 'hello_wasm') +const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js') + +const execFileSync = (...args) => cp.execFileSync(...args).toString().trim() + +const execFile = async (cmd, env) => { + const [err,, stderr] = await util.execFile(process.execPath, cmd, { + env: { + ...process.env, + NODE_GYP_NULL_LOGGER: undefined, + ...env + }, + encoding: 'utf-8' + }) + return [err, stderr.toString().trim().split(/\r?\n/)] +} + +function runWasm (hostProcess = process.execPath) { + const testCode = "console.log(require('hello_wasm').hello())" + return execFileSync(hostProcess, ['--experimental-wasi-unstable-preview1', '-e', testCode], { cwd: __dirname }) +} + +function executable (name) { + return name + (process.platform === 'win32' ? '.exe' : '') +} + +function getWasmEnv (target) { + const env = { + GYP_CROSSCOMPILE: '1', + AR_host: 'ar', + CC_host: 'clang', + CXX_host: 'clang++' + } + if (target === 'emscripten') { + env.AR_target = 'emar' + env.CC_target = 'emcc' + env.CXX_target = 'em++' + } else if (target === 'wasi') { + env.AR_target = path.resolve(__dirname, '..', process.env.WASI_SDK_PATH, 'bin', executable('ar')) + env.CC_target = path.resolve(__dirname, '..', process.env.WASI_SDK_PATH, 'bin', executable('clang')) + env.CXX_target = path.resolve(__dirname, '..', process.env.WASI_SDK_PATH, 'bin', executable('clang++')) + } else if (target === 'wasm') { + env.AR_target = path.resolve(__dirname, '..', process.env.WASI_SDK_PATH, 'bin', executable('ar')) + env.CC_target = path.resolve(__dirname, '..', process.env.WASI_SDK_PATH, 'bin', executable('clang')) + env.CXX_target = path.resolve(__dirname, '..', process.env.WASI_SDK_PATH, 'bin', executable('clang++')) + env.CFLAGS = '--target=wasm32' + } + return env +} + +describe('wasm', function () { + it('build simple node-api addon to wasm (wasm32-emscripten)', async function () { + if (process.platform === 'win32') { + return this.skip('TODO: require https://github.com/nodejs/node-gyp/pull/2974') + } + if (!process.env.EMSDK) { + return this.skip('emsdk not found') + } + this.timeout(platformTimeout(1, { win32: 5 })) + + const cmd = [ + nodeGyp, + 'rebuild', + '-C', wasmAddonPath, + '--loglevel=verbose', + '--arch=wasm32', + `--nodedir=${path.dirname(require.resolve('emnapi'))}`, + '--', '-f', 'make' + ] + const [err, logLines] = await execFile(cmd, getWasmEnv('emscripten')) + const lastLine = logLines[logLines.length - 1] + assert.strictEqual(err, null) + assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok') + assert.strictEqual(runWasm(), 'world') + }) + + it('build simple node-api addon to wasm (wasm32-wasip1)', async function () { + if (process.platform === 'win32') { + return this.skip('TODO: require https://github.com/nodejs/node-gyp/pull/2974') + } + if (!process.env.WASI_SDK_PATH) { + return this.skip('wasi-sdk not found') + } + this.timeout(platformTimeout(1, { win32: 5 })) + + const cmd = [ + nodeGyp, + 'rebuild', + '-C', wasmAddonPath, + '--loglevel=verbose', + '--arch=wasm32', + `--nodedir=${path.dirname(require.resolve('emnapi'))}`, + '--', '-f', 'make' + ] + const [err, logLines] = await execFile(cmd, getWasmEnv('wasi')) + const lastLine = logLines[logLines.length - 1] + assert.strictEqual(err, null) + assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok') + assert.strictEqual(runWasm(), 'world') + }) + + it('build simple node-api addon to wasm (wasm32-unknown-unknown)', async function () { + if (process.platform === 'win32') { + return this.skip('TODO: require https://github.com/nodejs/node-gyp/pull/2974') + } + if (!process.env.WASI_SDK_PATH) { + return this.skip('wasi-sdk not found') + } + this.timeout(platformTimeout(1, { win32: 5 })) + + const cmd = [ + nodeGyp, + 'rebuild', + '-C', wasmAddonPath, + '--loglevel=verbose', + '--arch=wasm32', + `--nodedir=${path.dirname(require.resolve('emnapi'))}`, + '--', '-f', 'make' + ] + const [err, logLines] = await execFile(cmd, getWasmEnv('wasm')) + const lastLine = logLines[logLines.length - 1] + assert.strictEqual(err, null) + assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok') + assert.strictEqual(runWasm(), 'world') + }) +})