Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,23 @@ jobs:
- run: pnpm i --frozen-lockfile
- run: node --run test:workerd

quickjs-wasi:
runs-on: ubuntu-22.04
name: 'Test on quickjs-wasi'
timeout-minutes: 10
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
with:
run_install: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: '.nvmrc'
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- run: pnpm i --frozen-lockfile
- run: EXODUS_TEST_DROP_NETWORK=1 node --run test:quickjs-wasi

bun:
runs-on: ubuntu-22.04
name: 'Test on Bun'
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Use `--engine` (or `EXODUS_TEST_ENGINE=`) to specify one of:
- `hermes:bundle` — [Hermes](https://hermesengine.dev) (React Native JavaScript engine)
- `spidermonkey:bundle` — [SpiderMonkey](https://spidermonkey.dev/) (Firefox/Gecko JavaScript engine)
- `quickjs:bundle` — [QuickJS](https://github.com/quickjs-ng/quickjs)
- `quickjs-wasi:bundle` — [QuickJS](https://github.com/quickjs-ng/quickjs) compiled to WebAssembly, runs on Node.js
(via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi), installed as an optional dependency)
- `xs:bundle` — [Moddable XS](https://github.com/Moddable-OpenSource/moddable)
- `graaljs:bundle` — [GraalJS](https://github.com/oracle/graaljs)
- `escargot:bundle` — [Escargot](https://github.com/Samsung/escargot)
Expand Down
2 changes: 2 additions & 0 deletions bin/find-binary.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ function findBinaryOnce(name) {
return require('electron')
case 'workerd':
return require.resolve('workerd/bin/workerd')
case 'quickjs-wasi':
return require.resolve('quickjs-wasi/quickjs.wasm')
case 'porffor':
return require.resolve('porffor/porf')
case 'jerryscript':
Expand Down
11 changes: 9 additions & 2 deletions bin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const ENGINES = new Map(
'duktape:bundle': { binary: 'duk', ...bareboneOpts },
'engine262:bundle': { binary: 'engine262', ...bareboneOpts },
'quickjs:bundle': { binary: 'quickjs', binaryArgs: ['--std'], ...bareboneOpts },
'quickjs-wasi:bundle': { binary: 'quickjs-wasi', ...bareboneOpts },
'xs:bundle': { binary: 'xs', ...bareboneOpts },
'graaljs:bundle': { binary: 'graaljs', ...bareboneOpts },
'escargot:bundle': { binary: 'escargot', ...bareboneOpts },
Expand All @@ -71,7 +72,7 @@ const ENGINES = new Map(
'msedge:playwright': { binary: 'msedge', browsers: 'playwright', ...bundleOpts },
})
)
const bareOk = ['v8', 'd8', 'spidermonkey', 'quickjs', 'xs', 'hermes', 'shermes']
const bareOk = ['v8', 'd8', 'spidermonkey', 'quickjs', 'quickjs-wasi', 'xs', 'hermes', 'shermes']
const bareNotrack = ['jsc', 'escargot', 'boa', 'graaljs', 'jerryscript', 'engine262']
const bareIncomplete = ['ladybird-js', 'nova', 'duktape']

Expand Down Expand Up @@ -292,7 +293,8 @@ Object.assign(options, engineOptions)
options.platform = options.binary // binary can be overriden by c8 or electron
const isBrowserLike = options.browsers || options.electron || options.html
setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
setEnv('EXODUS_TEST_PLATFORM', options.binary === 'shermes' ? 'hermes' : options.binary) // e.g. 'hermes', 'node'
const platformAliases = { __proto__: null, shermes: 'hermes', 'quickjs-wasi': 'quickjs' } // same engine
setEnv('EXODUS_TEST_PLATFORM', platformAliases[options.binary] ?? options.binary) // e.g. 'hermes', 'node'
setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout)
setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '')
setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '')
Expand Down Expand Up @@ -662,6 +664,11 @@ async function launch(binary, args, opts = {}, buffering = false) {
return browsers.run(runner, args, { binary, devtools, dropNetwork, timeout, throttle })
}

if (options.platform === 'quickjs-wasi') {
args = [fileURLToPath(import.meta.resolve('./quickjs-wasi.js')), ...args]
binary = process.execPath
}

const barebones = [...bareOk, ...bareNotrack, ...bareIncomplete]
const bins = ['node', 'bun', 'deno', 'electron', 'workerd', 'servo', 'servoshell', 'jerry', 'duk']
bins.push(...barebones)
Expand Down
134 changes: 134 additions & 0 deletions bin/quickjs-wasi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Host for the quickjs-wasi:bundle engine: QuickJS (quickjs-ng) compiled to WebAssembly, run on Node.js
// Provides what barebone engines are expected to have: print(), timers and gc(), mimicking `qjs --std`

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { QuickJS, JSException, MAX_STACK_SIZE } from 'quickjs-wasi'

const [, script, file, ...extra] = process.argv
assert(script === import.meta.filename && file && extra.length === 0, 'Unexpected launcher script')

// Reported after the microtask queue is drained, like in qjs
const rejections = new Map() // promise identity => [promise, reason], kept alive while tracked
const onUnhandledRejection = (promise, reason, isHandled) => {
const key = promise.identity
if (isHandled) {
for (const handle of rejections.get(key) ?? []) handle.dispose()
rejections.delete(key)
} else {
rejections.set(key, [promise.dup(), reason.dup()])
}
}

// Compiled synchronously, async compilation was observed to delay the first timers by tens of ms
const wasm = new WebAssembly.Module(
readFileSync(new URL(import.meta.resolve('quickjs-wasi/quickjs.wasm')))
)
// maxStackSize makes deep recursion a catchable RangeError instead of exhausting the WebAssembly stack
const vm = await QuickJS.create({ wasm, maxStackSize: MAX_STACK_SIZE, onUnhandledRejection })

const hostFunction = (name, fn) =>
vm.newFunction(name, (...args) => {
fn(...args.map((arg) => vm.dump(arg)))
return vm.undefined
})

const timers = new Map() // id => Node.js timeout
const write = hostFunction('write', (text) => process.stdout.write(`${text}\n`))
const gc = hostFunction('gc', () => vm.runGC())
const stopTimer = hostFunction('stopTimer', (id) => {
clearTimeout(timers.get(id))
timers.delete(id)
})
const startTimer = hostFunction('startTimer', (id, delay) => {
const ms = delay >= 1 && delay <= 2 ** 31 - 1 ? delay : 1 // same as Node.js does, minus the warnings
const fire = () => {
timers.delete(id)
task(() => runTimer(id))
}

timers.set(id, setTimeout(fire, ms))
})

// Timers are stored in the guest, the host only tracks numeric ids
// take(id) returns the bound callback, called by the host so that stack traces have no extra frames
const prelude = `(function (write, startTimer, stopTimer, gc) {
'use strict'
const { apply } = Reflect
const { bind } = Function.prototype
const timers = new Map()
let lastId = 0
const schedule = (callback, delay, args, repeat) => {
const id = ++lastId
const ms = Number(delay)
timers.set(id, { callback: apply(bind, callback, [undefined, ...args]), ms, repeat })
startTimer(id, ms)
return id
}

globalThis.print = (...args) => write(args.map(String).join(' '))
globalThis.gc = gc
globalThis.setTimeout = (callback, delay, ...args) => schedule(callback, delay, args, false)
globalThis.setInterval = (callback, delay, ...args) => schedule(callback, delay, args, true)
globalThis.clearTimeout = globalThis.clearInterval = (id) => {
const key = Number(id)
if (timers.delete(key)) stopTimer(key)
}

return (id) => {
const timer = timers.get(id)
if (timer?.repeat) startTimer(id, timer.ms)
else timers.delete(id)
return timer?.callback
}
})`

const take = vm.evalCode(prelude, '<exodus-test>').consume((fn) => {
return vm.callFunction(fn, vm.undefined, write, startTimer, stopTimer, gc)
})

function runTimer(id) {
const callback = vm.newNumber(id).consume((arg) => vm.callFunction(take, vm.undefined, arg))
// This handle keeps the callback alive while it runs, even if it clears its own timer
callback.consume(() => {
if (!callback.isUndefined) vm.callFunction(callback, vm.undefined).dispose()
})
}

// Same output as js_std_dump_error1() in quickjs-libc
const describe = vm.evalCode(
`(value) => String(value) + (Error.isError(value) && value.stack !== undefined ? '\\n' + value.stack : '')`,
'<exodus-test>'
)
const format = (value) => {
try {
return vm.callFunction(describe, vm.undefined, value).consume((text) => text.toString())
} catch {
return '[exception]'
}
}

let crashed = false
const crash = (message) => {
crashed = true
process.exitCode = 1
for (const timeout of timers.values()) clearTimeout(timeout)
process.stderr.write(`${message}\n`)
}

// Run a macrotask, then drain microtasks and report unhandled rejections
function task(fn) {
if (crashed) return
try {
fn()
vm.executePendingJobs()
} catch (err) {
return crash(err instanceof JSException ? format(err.handle) : err?.stack ?? err)
}

if (rejections.size === 0) return
const reasons = [...rejections.values()].map(([, reason]) => format(reason))
crash(reasons.map((reason) => `Possibly unhandled promise rejection: ${reason}`).join('\n'))
}

task(() => vm.evalCode(readFileSync(file, 'utf8'), file).dispose())
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"bin/electron.preload.cjs",
"bin/find-binary.js",
"bin/inband.js",
"bin/quickjs-wasi.js",
"bin/reporter.js",
"loader/babel.cjs",
"loader/deno-import-map.json",
Expand Down Expand Up @@ -164,6 +165,7 @@
"test:servo": "EXODUS_TEST_ENGINE=servo:bundle npm run test:_bundle --",
"test:engine262": "EXODUS_TEST_ENGINE=engine262:bundle npm run test:_bundle --",
"test:quickjs": "EXODUS_TEST_ENGINE=quickjs:bundle npm run test:_bundle --",
"test:quickjs-wasi": "EXODUS_TEST_ENGINE=quickjs-wasi:bundle npm run test:_bundle --",
"test:xs": "EXODUS_TEST_ENGINE=xs:bundle npm run test:_bundle --",
"test:graaljs": "EXODUS_TEST_ENGINE=graaljs:bundle npm run test:_bundle --",
"test:escargot": "EXODUS_TEST_ENGINE=escargot:bundle npm run test:_bundle --",
Expand Down Expand Up @@ -191,6 +193,7 @@
"playwright-core": "^1.58.2",
"pretty-format": "^30.2.0",
"puppeteer-core": "^24.37.2",
"quickjs-wasi": "^3.6.2",
"tsx": "^4.21.0"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions tests/engines/quickjs-wasi.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, test } from 'node:test'
import assert from 'node:assert/strict'

describe(
'quickjs-wasi',
{
skip: process.env.EXODUS_TEST_ENGINE !== 'quickjs-wasi:bundle',
},
() => {
test('Are we QuickJS', () => {
assert.equal(process.env.EXODUS_TEST_PLATFORM, 'quickjs')
assert.equal(typeof globalThis.gc, 'function')
assert.equal(globalThis.gc(), undefined)
})

test('setTimeout passes arguments', async () => {
const args = await new Promise((resolve) => setTimeout((...a) => resolve(a), 0, 1, 'x'))
assert.deepEqual(args, [1, 'x'])
})

test('clearTimeout and clearInterval', async () => {
let count = 0
const cleared = setTimeout(() => count++, 0)
clearTimeout(cleared)
clearTimeout(cleared)
clearTimeout()
await new Promise((resolve) => {
const interval = setInterval(() => {
if (++count < 3) return
clearInterval(interval) // from inside the callback
setTimeout(resolve, 20)
}, 1)
})
assert.equal(count, 3)
})

test('Deep recursion is a catchable RangeError', () => {
const depth = (n) => (n === 0 ? 0 : 1 + depth(n - 1))
assert.throws(() => depth(1e6), RangeError)
assert.equal(depth(1000), 1000)
})
}
)
Loading