diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4aa8198..a9132c6 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,24 +5,105 @@ on: branches: [main] jobs: - publish: + build: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v7 with: submodules: recursive - - uses: mymindstorm/setup-emsdk@v14 - - name: Use Node.js 22.x - uses: actions/setup-node@v4 + + - name: Setup EMSDK + uses: emscripten-core/setup-emsdk@v16 + with: + version: 6.0.4 + actions-cache-folder: emsdk-cache + + - name: Use Node.js 24.x + uses: actions/setup-node@v7 + with: + node-version: 24.x + + - name: Install dependencies + run: npm ci + + - name: Run lint (no fix) + run: npm run lint:nofix + + - name: Build WASM + run: npm run build:wasm + + - name: Build project + run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run tests + run: npm test + + - name: Run Lua tests + run: npm run luatests + + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: build-artifact + path: | + package.json + package-lock.json + dist/ + bin/ + LICENSE + + publish_npm: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Download build artifact + uses: actions/download-artifact@v8 with: - node-version: 22.x - - run: npm ci - - run: npm run lint:nofix - - run: npm run build:wasm - - run: npm run build - - run: npm test - - run: npm run luatests - - uses: JS-DevTools/npm-publish@v3 + name: build-artifact + path: build-artifact + + - name: Restore build artifact + run: cp -r build-artifact/* . + + - name: Publish to npm + uses: JS-DevTools/npm-publish@v4 with: token: ${{ secrets.NPM_TOKEN }} + + # Emscripten still doesn't support deno + # publish_jsr: + # runs-on: ubuntu-latest + # needs: build + # permissions: + # contents: read + # id-token: write + # steps: + # - name: Download build artifact + # uses: actions/download-artifact@v8 + # with: + # name: build-artifact + # path: build-artifact + + # - name: Restore build artifact + # run: cp -r build-artifact/* . + + # - name: Generate jsr.json from package.json + # run: | + # node -e "const pkg = require('./package.json'); \ + # const jsr = { \ + # name: '@ceifa/' + pkg.name, \ + # version: pkg.version, \ + # exports: pkg.main, \ + # include: ['dist/**/*', 'bin/**/*'] + # }; \ + # require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" + + # - name: Publish to JSR + # run: npx jsr publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 50e5cc3..f0519f5 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,20 +10,25 @@ jobs: strategy: matrix: - node-version: [18, 20, 22] + node-version: [24] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: recursive - - uses: mymindstorm/setup-emsdk@v12 + - uses: emscripten-core/setup-emsdk@v16 + with: + version: 6.0.4 + actions-cache-folder: emsdk-cache - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} + cache: npm - run: npm ci - run: npm run lint:nofix - run: npm run build:wasm - run: npm run build + - run: npx playwright install --with-deps chromium - run: npm test - run: npm run luatests diff --git a/.prettierrc b/.oxfmtrc.json similarity index 61% rename from .prettierrc rename to .oxfmtrc.json index f2060e4..600545a 100644 --- a/.prettierrc +++ b/.oxfmtrc.json @@ -5,5 +5,7 @@ "quoteProps": "consistent", "semi": false, "printWidth": 140, - "tabWidth": 4 + "tabWidth": 4, + "sortPackageJson": false, + "ignorePatterns": ["rolldown.config.*.js"] } diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..c9c4b84 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "plugins": ["import", "promise", "node"], + "categories": { + "correctness": "deny", + "suspicious": "deny" + }, + "rules": { + "no-shadow": "off", + "no-extend-native": "off", + "no-underscore-dangle": "off", + "consistent-function-scoping": "off", + "promise/always-return": "off" + }, + "ignorePatterns": ["dist/**", "build/**", "rolldown.config.ts", "rolldown.config.*.js", "utils/**"], + "env": { + "builtin": true + } +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 39d09df..0000000 --- a/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -dist/ -node_modules/ -.rollup.cache/ -package-lock.json -build/ -.eslintrc.json diff --git a/README.md b/README.md index a0f63e9..cee9827 100644 --- a/README.md +++ b/README.md @@ -12,36 +12,82 @@ This package aims to provide a way to: ## API Usage -To initialize, create a new Lua state, register the standard library, set a global variable, execute a code and get a global variable: +Load the wasm module once, create a state on it, then set a global, run some Lua and read a global back: ```js -const { LuaFactory } = require('wasmoon') +import { LuaRuntime } from 'wasmoon' -// Initialize a new lua environment factory -// You can pass the wasm location as the first argument, useful if you are using wasmoon on a web environment and want to host the file by yourself -const factory = new LuaFactory() -// Create a standalone lua environment from the factory -const lua = await factory.createEngine() +// Loads the Lua wasm module. Every state created from it shares the filesystem and stdio. +const lua = await LuaRuntime.load() +// A standalone Lua state, with the standard library open +const state = lua.createState() try { // Set a JS function to be a global lua function - lua.global.set('sum', (x, y) => x + y) + state.set('sum', (x, y) => x + y) // Run a lua string - await lua.doString(` + await state.doString(` print(sum(10, 10)) function multiply(x, y) return x * y end `) // Get a global lua function as a JS function - const multiply = lua.global.get('multiply') + const multiply = state.get('multiply') console.log(multiply(10, 10)) } finally { - // Close the lua environment, so it can be freed - lua.global.close() + // Close the state, so it can be freed + state.close() } ``` +Loading the module is the expensive part, so keep one `LuaRuntime` around and create a state per +sandbox. `lua.close()` closes every state created from it, and both types support `using`: + +```js +await using lua = await LuaRuntime.load() +``` + +## Filesystem + +Every state on a runtime shares one filesystem. By default it is in memory: the same in Node, the +browser and a worker, and it holds nothing but what you put in it. + +```js +const lua = await LuaRuntime.load() +lua.writeFile('/scripts/greet.lua', 'return "hello"') + +const state = lua.createState() +await state.doString('return require("scripts.greet")') +``` + +`writeFile`, `readFile`, `readTextFile`, `exists`, `cwd` and `chdir` cover the everyday cases; +`lua.filesystem` is Emscripten's own API for everything else. + +### Reaching the host + +Two ways, and neither is the default: + +```js +// One host directory, at a path you choose. Node only. Nothing else on the host is reachable. +const lua = await LuaRuntime.load({ mounts: { '/scripts': './lua' } }) +await lua.createState().doString('return dofile("/scripts/init.lua")') + +// Or the real filesystem, with the process working directory, absolute paths and symlinks. +// Node only, and not a sandbox: Lua can read and write whatever the process can. +const cli = await LuaRuntime.load({ fs: 'host' }) +``` + +`mounts` maps the path Lua sees to a host directory that has to exist; mount points cannot nest, and +`lua.mount(virtualPath, hostPath)` / `lua.unmount(virtualPath)` do the same after loading. With +`fs: 'host'` there is nothing to mount, because every host path already resolves — and `lua.chdir` +moves the Node process itself, since a process has only one working directory. + +> [!WARNING] +> A filesystem is not a sandbox on its own. Lua's `os.execute` runs a real command through the shell +> in Node whichever filesystem you pick, and `os.exit` sets the host process exit code. Leave `os` +> out of the state (`createState({ libs: [...] })`) if untrusted code must not do either. + ## CLI Usage Although Wasmoon has been designed to be embedded, you can run it on command line as well, but, if you want something more robust on this, we recommend to take a look at [demoon](https://github.com/ceifa/demoon). @@ -74,136 +120,129 @@ $: ./sum.lua 10 30 ## When to use wasmoon and fengari -Wasmoon compiles the [official Lua code](https://github.com/lua/lua) to webassembly and creates an abstraction layer to interop between Lua and JS, instead of [fengari](https://github.com/fengari-lua/fengari), that is an entire Lua VM rewritten in JS. +Wasmoon compiles the [official Lua code](https://github.com/lua/lua) to WebAssembly and creates an abstraction layer to interop between Lua and JS, instead of [fengari](https://github.com/fengari-lua/fengari), which is an entire Lua VM rewritten in JS. ### Performance -Because of wasm, wasmoon will run Lua code much faster than fengari, but if you are going to interop a lot between JS and Lua, this may be not be true anymore, you probably should test on you specific use case to take the prove. +Because of WebAssembly, wasmoon runs Lua code significantly faster than fengari. The table below shows results from a [heap sort benchmark](https://github.com/ceifa/wasmoon/blob/main/bench/heapsort.lua) sorting a list of 2,000 numbers (100 iterations, 5 warmup): -This is the results running a [heap sort code](https://github.com/ceifa/wasmoon/blob/main/bench/heapsort.lua) in a list of 2k numbers 10x(less is better): +| | avg | median | min | max | stddev | relative | +| ----------- | --------- | --------- | --------- | --------- | -------- | -------- | +| **Wasmoon** | 13.41 ms | 13.07 ms | 12.20 ms | 16.23 ms | 1.12 ms | 1.00x | +| **Fengari** | 137.36 ms | 138.51 ms | 119.70 ms | 165.54 ms | 11.16 ms | 10.24x | -| wasmoon | fengari | -| -------- | --------- | -| 15.267ms | 389.923ms | +Wasmoon is **~10x faster** than fengari for pure Lua execution. If your use case involves heavy interop between JS and Lua, the difference may be smaller, benchmark your specific scenario. ### Size -Fengari is smaller than wasmoon, which can improve the user experience if in web environments: +Fengari is smaller than wasmoon, which can improve the user experience if in web environments. +Both minified, and wasmoon counted as its JS plus `glue.wasm`: -| | wasmoon | fengari | -| ----------- | ------- | ------- | -| **plain** | 393kB | 214kB | -| **gzipped** | 130kB | 69kB | +| | wasmoon | fengari | +| ----------- | ---------------- | ------- | +| **plain** | 294kB (97 + 197) | 228kB | +| **gzipped** | 124kB (29 + 95) | 74kB | -## Fixing common errors on web environment +Almost all of wasmoon's weight is the wasm, which is a separate file: it is fetched in parallel +with your JS rather than parsed as part of it, and it caches on its own across releases of your app. -Bundle/require errors can happen because wasmoon tries to safely import some node modules even in a browser environment, the bundler is not prepared to that since it tries to statically resolve everything on build time. -Polyfilling these modules is not the right solution because they are not actually being used, you just have to ignore them: +## Web environment -### Webpack +Bundlers need no configuration. wasmoon ships as ESM and marks the two node builtins it touches so +that a bundler targeting the browser skips them instead of failing to resolve them, and every +supported bundler produces a working browser build out of the box. -Add the `resolve.fallback` snippet to your config: +### Where `glue.wasm` comes from -```js -module.exports = { - entry: './src/index.js', // Here is your entry file - resolve: { - fallback: { - path: false, - fs: false, - child_process: false, - crypto: false, - url: false, - module: false, - }, - }, -} -``` +The wasm is resolved next to the bundle, with `new URL('glue.wasm', import.meta.url)`. What that +means depends on your bundler: -### Rollup +| bundler | what happens | +| ------- | ------------------------------------------------------------------ | +| Vite | inlines the wasm into the bundle, nothing else to do | +| webpack | emits the wasm as an asset next to your output, nothing else to do | +| esbuild | does not handle the asset, see below | +| Rollup | does not handle the asset, see below | -With the package [rollup-plugin-ignore](https://www.npmjs.com/package/rollup-plugin-ignore), add this snippet to your config: +esbuild and Rollup leave nothing beside the bundle to fetch, so wasmoon falls back to unpkg with a +warning. That works, but it is a request to a third party pinned to wasmoon's version, so prefer +copying the wasm next to your output as part of the build, after which nothing else is needed: ```js -export default { - input: 'src/index.js', // Here is your entry file, - plugins: [ignore(['path', 'fs', 'child_process', 'crypto', 'url', 'module'])], -} +import { copyFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +await copyFile(fileURLToPath(import.meta.resolve('wasmoon/glue.wasm')), 'dist/glue.wasm') ``` -### Angular - -Add the section browser on `package.json`: - -```json -{ - "main": "src/index.js", - "browser": { - "child_process": false, - "fs": false, - "path": false, - "crypto": false, - "url": false, - "module": false - } -} +Or host it wherever you like and say where it is: + +```js +const lua = await LuaRuntime.load({ wasmFile: '/assets/glue.wasm' }) ``` -## How to build +With Vite and webpack you want neither, since they already handle the asset. Asking for it again +(`wasmoon/glue.wasm?url` and friends) makes them ship the wasm twice. -Firstly download the lua submodule and install the other Node.JS dependencies: +### A page opened from `file://` -```sh -git submodule update --init # download lua submodule -npm i # install dependencies +A page loaded over `file://` cannot fetch anything next to it, so the wasm has to come from +somewhere else and wasmoon falls back to unpkg. To keep such a page self contained, inline the wasm +and hand it over as a data URL: + +```js +const lua = await LuaRuntime.load({ wasmFile: 'data:application/wasm;base64,...' }) ``` -### Windows / Linux / MacOS (Docker way) +Note that Chromium also refuses to load ES modules over `file://`, so the page has to carry your +bundle inline rather than in a ` +` + + // A module worker resolves the wasm the same way a page does, but with no `window` in scope. + const workerScript = ` +import LuaRuntime from './index.js' +self.onmessage = async () => { + try { + const lua = await LuaRuntime.load() + const state = lua.createState() + self.postMessage({ ok: await state.doString('return 2 + 5') }) + } catch (err) { + self.postMessage({ error: String(err) }) + } +} +` + + const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost`) + + if (url.pathname === '/' || url.pathname === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(testPage) + return + } + + if (url.pathname === '/worker.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }) + res.end(workerScript) + return + } + + const filePath = join(DIST_DIR, url.pathname) + + try { + const data = await readFile(filePath) + const ext = url.pathname.substring(url.pathname.lastIndexOf('.')) + res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }) + res.end(data) + } catch { + res.writeHead(404) + res.end('Not found') + } + }) + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + resolve({ server, port }) + }) + }) +} + +describe('Browser environment', () => { + let port, server, browser, context + + before(async function () { + this.timeout(30_000) + ;({ server, port } = await startServer()) + browser = await chromium.launch() + context = await browser.newContext() + }) + + after(async () => { + await browser?.close() + server?.close() + }) + + /** Every URL the page asked for, so a test can tell where the wasm was fetched from. */ + let requested = [] + + async function openPage() { + const page = await context.newPage() + requested = [] + page.on('request', (req) => requested.push(req.url())) + + const errors = [] + page.on('pageerror', (err) => errors.push(err)) + page.assertNoErrors = () => { + if (errors.length > 0) { + throw errors[0] + } + } + + await page.goto(`http://127.0.0.1:${port}/`) + await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) + return page + } + + function expectNoExternalRequests() { + expect(requested.filter((url) => !url.startsWith(`http://127.0.0.1:${port}/`))).to.be.empty + } + + async function runInBrowser(code) { + const page = await openPage() + + try { + const result = await page.evaluate(async (c) => { + return await window.__runTest(c) + }, code) + + page.assertNoErrors() + return result + } finally { + await page.close() + } + } + + it('load Lua engine in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState() + return state !== undefined + `) + expect(result).to.be.true + }) + + it('execute Lua code in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState() + return await state.doString('return 2 + 2') + `) + expect(result).to.be.equal(4) + }) + + it('pass JS values to Lua and back in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState({ inject: true }) + state.set('name', 'wasmoon') + return await state.doString('return "hello " .. name') + `) + expect(result).to.be.equal('hello wasmoon') + }) + + it('call JS function from Lua in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState({ inject: true }) + state.set('add', (a, b) => a + b) + return await state.doString('return add(10, 20)') + `) + expect(result).to.be.equal(30) + }) + + it('mount and require a file in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + lua.writeFile('mymodule.lua', 'return 42') + const state = lua.createState() + return await state.doString('return require("mymodule")') + `) + expect(result).to.be.equal(42) + }) + + it('handle Lua tables as JS objects in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState({ inject: true }) + const value = await state.doString('return { x = 10, y = 20 }') + return { x: value.x, y: value.y } + `) + expect(result).to.be.eql({ x: 10, y: 20 }) + }) + + it('handle errors in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState() + try { + await state.doString('error("test error")') + return false + } catch (e) { + return e.message.includes('test error') + } + `) + expect(result).to.be.true + }) + + it('use coroutines in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState() + return await state.doString(\` + local co = coroutine.create(function() + coroutine.yield(1) + coroutine.yield(2) + return 3 + end) + local results = {} + while true do + local ok, value = coroutine.resume(co) + if not ok then break end + results[#results + 1] = value + if coroutine.status(co) == "dead" then break end + end + return results[1] + results[2] + results[3] + \`) + `) + expect(result).to.be.equal(6) + }) + + it('yielding at the top level in browser should succeed', async function () { + this.timeout(30_000) + // Browsers have no setImmediate, which the yield path used to depend on. + const result = await runInBrowser(` + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState() + return await state.doString('coroutine.yield() return 7') + `) + expect(result).to.be.equal(7) + }) + + // Every test above passes an explicit wasmFile, which a consumer does not. Without one, the + // wrong default is a request to a CDN pinned to whatever version is in package.json. + describe('default wasm resolution', () => { + it('loads the wasm from the same origin when served over http', async function () { + this.timeout(30_000) + const result = await runInBrowser('await LuaRuntime.load(); return 1') + + expect(result).to.be.equal(1) + expect(requested).to.include(`http://127.0.0.1:${port}/glue.wasm`) + expectNoExternalRequests() + }) + + it('loads the wasm from the same origin inside a module worker', async function () { + this.timeout(30_000) + const page = await openPage() + + try { + const result = await page.evaluate(async () => { + const worker = new Worker('/worker.js', { type: 'module' }) + const message = new Promise((resolve) => worker.addEventListener('message', (e) => resolve(e.data))) + worker.postMessage('go') + return await message + }) + + page.assertNoErrors() + expect(result).to.be.eql({ ok: 7 }) + expect(requested).to.include(`http://127.0.0.1:${port}/glue.wasm`) + expectNoExternalRequests() + } finally { + await page.close() + } + }) + + it('falls back to the CDN for a page opened from file:', async function () { + this.timeout(60_000) + // Served locally rather than for real, to keep the suite offline and because the + // published wasm belongs to another version. + const { htmlFile, cleanup } = await writeInlinedPage() + const page = await context.newPage() + const cdnRequests = [] + + try { + await page.route('https://unpkg.com/**', async (route) => { + cdnRequests.push(route.request().url()) + await route.fulfill({ contentType: 'application/wasm', body: await readFile(join(DIST_DIR, 'glue.wasm')) }) + }) + + await page.goto(`file://${htmlFile}`) + await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) + const result = await page.evaluate(async () => { + const lua = await window.__LuaRuntime.load() + return await lua.createState().doString('return 3 * 3') + }) + + expect(result).to.be.equal(9) + expect(cdnRequests).to.have.lengthOf(1) + expect(cdnRequests[0]).to.match(/^https:\/\/unpkg\.com\/wasmoon@[^/]+\/dist\/glue\.wasm$/) + } finally { + await page.close() + await cleanup() + } + }) + }) +}) + +/** + * A single self contained HTML file, the only shape that works from `file:`: Chromium refuses to + * load an ES module over that protocol, so the bundle is inlined. It is already a single chunk, and + * its exported bindings are in scope of the inline module. + */ +async function writeInlinedPage() { + const tempDir = await mkdtemp(join(tmpdir(), 'wasmoon-file-')) + const bundle = await readFile(join(DIST_DIR, 'index.js'), 'utf8') + + const htmlFile = join(tempDir, 'page.html') + await writeFile( + htmlFile, + ` + +`, + ) + + return { htmlFile, cleanup: () => rm(tempDir, { recursive: true, force: true }) } +} diff --git a/test/bundling.test.js b/test/bundling.test.js new file mode 100644 index 0000000..05010f6 --- /dev/null +++ b/test/bundling.test.js @@ -0,0 +1,106 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { EventEmitter } from 'node:events' +import { expect } from 'chai' +import { rolldown } from 'rolldown' + +const DIST_DIR = fileURLToPath(new URL('../dist', import.meta.url)) +const WASM_FILE = join(DIST_DIR, 'glue.wasm') + +/** + * Consumers bundle us through a minifier, so anything in the published bundle that depends on a name + * surviving breaks in their build and not in ours. These run it against a minified copy. + */ +describe('Bundling', () => { + let tempDir + let minified + + before(async function () { + this.timeout(60_000) + tempDir = await mkdtemp(join(tmpdir(), 'wasmoon-bundling-')) + + const bundle = await rolldown({ + input: join(DIST_DIR, 'index.js'), + external: ['node:module'], + }) + // A directory rather than a single file, because the host filesystem glue stays a chunk of + // its own -- which is also how a consumer has to bundle us when they target Node. + await bundle.write({ + dir: tempDir, + entryFileNames: 'index.js', + chunkFileNames: 'glue-host.js', + format: 'esm', + minify: true, + }) + await bundle.close() + + minified = await import(join(tempDir, 'index.js')) + }) + + after(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + const getMinifiedState = async (config = {}) => { + const lua = await minified.LuaRuntime.load({ wasmFile: WASM_FILE }) + return lua.createState({ inject: true, ...config }) + } + + it('the published host glue is the one the browser field stubs out', async () => { + // The name lives in three places that nothing else connects: rolldown's chunkFileNames, the + // `browser` field, and the import in module.ts. A rename in one of them would either ship + // the node-only glue into browser bundles or break fs: 'host', both of them quietly. + const pkg = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')) + const stubbed = Object.keys(pkg.browser).filter((key) => key.includes('glue')) + + expect(stubbed).to.have.lengthOf(1) + expect(pkg.browser[stubbed[0]]).to.be.false + expect(join(DIST_DIR, stubbed[0].replace('./dist/', ''))).to.satisfy(existsSync) + expect(await readFile(join(DIST_DIR, 'index.js'), 'utf8')).to.include(stubbed[0].replace('./dist/', './')) + }) + + it('minifying the bundle renames the unwind classes', async () => { + // The premise of the tests below: a check by class name has nothing left to match on. + const code = await readFile(join(tempDir, 'index.js'), 'utf8') + expect(code).to.not.match(/class Emscripten/) + }) + + it('a yield across a C-call boundary still surfaces as an error when minified', async () => { + const state = await getMinifiedState() + const emitter = new EventEmitter() + state.set('yield', () => new Promise((resolve) => emitter.once('resolve', resolve))) + const resPromise = state.doString(` + local res = yield():next(function () + coroutine.yield() + return 15 + end) + print("res", res:await()) + `) + + emitter.emit('resolve') + await expect(resPromise).to.eventually.be.rejectedWith('Error: attempt to yield across a C-call boundary') + + expect(await state.doString('return 42')).to.equal(42) + }) + + it('a thread timeout inside a JS callback still surfaces as an error when minified', async () => { + const state = await getMinifiedState({ limits: { functionTimeout: 10 } }) + state.set('promise', Promise.resolve()) + const thread = state.newThread() + thread.loadString(` + promise:next(function () + while true do + -- nothing + end + end):await() + `) + await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') + + expect(await state.doString('return 42')).to.equal(42) + }) +}) diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..736ca71 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,303 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect } from 'chai' +import pkg from '../package.json' with { type: 'json' } + +// Absolute, so a test can hand the child a cwd of its own. +const CLI = fileURLToPath(new URL('../bin/wasmoon', import.meta.url)) + +// stdin defaults to closed, matching a piped invocation with nothing to read. Never a TTY here, +// so the REPL branch and `-i` stay out of reach. +const runCli = (args, { input, ...options } = {}) => { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI, ...args], { + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + ...options, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => (stdout += chunk)) + child.stderr.on('data', (chunk) => (stderr += chunk)) + child.on('error', reject) + child.on('close', (code) => resolve({ code, stdout, stderr })) + if (input !== undefined) { + child.stdin.end(input) + } + }) +} + +// For the runs that are expected to succeed, which is most of them: the only interesting part is +// what reached stdout. +const runOk = async (args, options) => { + const { code, stdout, stderr } = await runCli(args, options) + + expect(stderr, 'stderr').to.be.empty + expect(code, 'exit code').to.be.equal(0) + return stdout +} + +describe('CLI', function () { + this.timeout(30_000) + + let tempDir + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'wasmoon-cli-')) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + const writeScript = (name, body) => { + const path = join(tempDir, name) + writeFileSync(path, body) + return path + } + + describe('scripts and snippets', () => { + // Covers the single snippet case too, which is why there is no separate test for it. + it('should run several -e snippets in order', async () => { + expect(await runOk(['-e', 'print("first")', '-e', 'print("second")'])).to.be.equal('first\nsecond\n') + }) + + it('should run a script file', async () => { + const script = writeScript('hello.lua', 'print("from file")') + + expect(await runOk([script])).to.be.equal('from file\n') + }) + + it('should run a script piped to stdin', async () => { + expect(await runOk([], { input: 'print("from bare stdin")\n' })).to.be.equal('from bare stdin\n') + }) + + it('should run a script piped to stdin when given -', async () => { + expect(await runOk(['-'], { input: 'print("from dash")\n' })).to.be.equal('from dash\n') + }) + }) + + describe('arg table', () => { + it('should expose arg as a lua table to a script', async () => { + const script = writeScript('args.lua', 'print(type(arg), #arg, arg[1], arg[2], table.concat(arg, "+"))') + + const stdout = await runOk([script, 'first', 'second']) + + expect(stdout.trim()).to.be.equal('table\t2\tfirst\tsecond\tfirst+second') + }) + + // The table is aligned on the script name, so it sits at index 0 with the interpreter and + // the options that came before it at negative indices. + it('should align arg on the script name', async () => { + const script = writeScript('align.lua', 'print(arg[0], arg[-1], arg[-2])') + + const stdout = await runOk(['-e', '', script]) + + expect(stdout.trim().split('\t')).to.deep.equal([script, '', '-e']) + }) + + // Without a script the alignment falls back to the interpreter, which puts the options + // themselves at positive indices. + it('should align arg on the interpreter when there is no script', async () => { + const stdout = await runOk(['-e', 'print(type(arg), #arg, arg[1], arg[2])']) + + expect(stdout.trim()).to.be.equal('table\t2\t-e\tprint(type(arg), #arg, arg[1], arg[2])') + }) + + it('should pass the script arguments to the script as varargs', async () => { + const script = writeScript('varargs.lua', 'print(...)') + + expect((await runOk([script, 'first', 'second'])).trim()).to.be.equal('first\tsecond') + }) + + it('should treat whatever follows -- as the script, even when it looks like an option', async () => { + const script = writeScript('-notaflag.lua', 'print("ran", ...)') + + expect((await runOk(['--', script, 'tail'])).trim()).to.be.equal('ran\ttail') + }) + + it('should run nothing but the options when -- ends the arguments', async () => { + expect(await runOk(['-e', 'print("only e")', '--'])).to.be.equal('only e\n') + }) + }) + + describe('options', () => { + it('-v should report both versions', async () => { + const stdout = await runOk(['-v']) + + expect(stdout.trim()).to.match(/^wasmoon \S+ \(Lua [\d.]+\)$/) + expect(stdout).to.include(`wasmoon ${pkg.version} `) + }) + + // -v reports and carries on, rather than being a request to do nothing else. + it('-v should still run whatever else was asked for', async () => { + const stdout = await runOk(['-v', '-e', 'print("ran")']) + + expect(stdout.trim().split('\n')).to.have.lengthOf(2) + expect(stdout).to.match(/^wasmoon .*\nran\n$/) + }) + + // It still counts as something to do, so stdin is not read as a program on top of it. + it('-v alone should not run stdin', async () => { + expect(await runOk(['-v'], { input: 'print("should not run")\n' })).to.not.include('should not run') + }) + + it('should run -e and -l in the order they were given', async () => { + writeScript('noisy.lua', 'print("module") return {}') + + const [eFirst, lFirst] = await Promise.all([ + runOk(['-e', 'print("snippet")', '-l', 'noisy'], { cwd: tempDir }), + runOk(['-l', 'noisy', '-e', 'print("snippet")'], { cwd: tempDir }), + ]) + + expect(eFirst).to.be.equal('snippet\nmodule\n') + expect(lFirst).to.be.equal('module\nsnippet\n') + }) + + // The global has to hold the very table package.loaded does, not a copy of it. + it('-l should require a module into a global of the same name', async () => { + writeScript('mymod.lua', 'return { v = 5 }') + + const stdout = await runOk(['-l', 'mymod', '-e', 'print(mymod.v, type(mymod), mymod == package.loaded.mymod)'], { + cwd: tempDir, + }) + + expect(stdout.trim()).to.be.equal('5\ttable\ttrue') + }) + + it('-l g=mod should require a module into a renamed global', async () => { + writeScript('mymod.lua', 'return { v = 5 }') + + const stdout = await runOk(['-l', 'g=mymod', '-e', 'print(g.v, mymod)'], { cwd: tempDir }) + + expect(stdout.trim()).to.be.equal('5\tnil') + }) + + it('-E should hide the host environment', async () => { + const env = { ...process.env, WASMOON_CLI_TEST: 'secret' } + + const [withEnv, without] = await Promise.all([ + runOk(['-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), + runOk(['-E', '-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), + ]) + + expect(withEnv.trim()).to.be.equal('secret') + expect(without.trim()).to.be.equal('nil') + }) + + it('an unrecognized option should print usage and fail', async () => { + const { code, stdout, stderr } = await runCli(['-Z']) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include(`unrecognized option: '-Z'`) + expect(stderr).to.include('usage: wasmoon') + }) + + it('a missing argument after -e should fail', async () => { + const { code, stderr } = await runCli(['-e']) + + expect(code).to.be.equal(1) + expect(stderr).to.include('Missing argument after -e') + }) + }) + + describe('errors', () => { + it('should report a runtime error with a traceback and fail', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'error("boom")']) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include('wasmoon: (command line):1: boom') + expect(stderr).to.include('stack traceback:') + // A Lua error is a diagnostic, not a crash of the interpreter itself. + expect(stderr).to.not.include('LuaError') + }) + + it('should report a script that cannot be opened without a traceback', async () => { + const missing = join(tempDir, 'missing.lua') + + const { code, stdout, stderr } = await runCli([missing]) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include(`wasmoon: cannot open ${missing}`) + expect(stderr).to.not.include('stack traceback:') + }) + + it('should stop before the script when a -e snippet fails', async () => { + const script = writeScript('unreached.lua', 'print("unreached")') + + const { code, stdout } = await runCli(['-e', 'error("boom")', script]) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + }) + + it('should report a failing -l without running the rest', async () => { + const { code, stdout, stderr } = await runCli(['-l', 'nosuchmodule', '-e', 'print("unreached")']) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include("wasmoon: module 'nosuchmodule' not found") + }) + }) + + describe('os.exit', () => { + it('should exit with the status the script asked for', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print("bye") os.exit(3)']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(3) + expect(stdout).to.be.equal('bye\n') + }) + + it('should treat a boolean status the way lua does', async () => { + const [ok, notOk] = await Promise.all([runCli(['-e', 'os.exit(true)']), runCli(['-e', 'os.exit(false)'])]) + + expect(ok.code).to.be.equal(0) + expect(notOk.code).to.be.equal(1) + expect(ok.stderr).to.be.empty + expect(notOk.stderr).to.be.empty + }) + + it('should exit successfully with no status', async () => { + const { code, stderr } = await runCli(['-e', 'os.exit()']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + }) + }) + + describe('stdin', () => { + it('should read piped input from within a script', async () => { + const stdout = await runOk(['-e', 'print(io.read("l")) print(io.read("l")) print(io.read("l") == nil)'], { + input: 'olá mundo\nsegunda linha\n', + }) + + expect(stdout).to.be.equal('olá mundo\nsegunda linha\ntrue\n') + }) + + it('should not add a line break to input that does not end with one', async () => { + expect(await runOk(['-e', 'print(#io.read("a"))'], { input: 'abc' })).to.be.equal('3\n') + }) + + // A named '-' is a script and is called like one; stdin falling in because nothing else was + // named is not, so the options in front of it are not arguments to it. + it('should only pass varargs to stdin when it was named with -', async () => { + const [named, fallback] = await Promise.all([ + runOk(['-', 'one', 'two'], { input: 'print("varargs:", ...)\n' }), + runOk(['-W'], { input: 'print("varargs:", ...)\n' }), + ]) + + expect(named.trim()).to.be.equal('varargs:\tone\ttwo') + expect(fallback.trim()).to.be.equal('varargs:') + }) + + it('should not run piped input as a script when -e is given', async () => { + expect(await runOk(['-e', 'print("from -e")'], { input: 'this is not lua code\n' })).to.be.equal('from -e\n') + }) + }) +}) diff --git a/test/debug.js b/test/debug.js index 00daa23..9f6e871 100644 --- a/test/debug.js +++ b/test/debug.js @@ -1,10 +1,10 @@ -import { getEngine } from './utils.js' +import { getState } from './utils.js' // This file was created as a sandbox to test and debug on vscode -const engine = await getEngine() -engine.global.set('potato', { +const state = await getState() +state.set('potato', { test: true, hello: ['world'], }) -engine.global.get('potato') -engine.doStringSync('print(potato.hello[1])') +state.get('potato') +state.doStringSync('print(potato.hello[1])') diff --git a/test/engine.test.js b/test/engine.test.js deleted file mode 100644 index 23e6361..0000000 --- a/test/engine.test.js +++ /dev/null @@ -1,818 +0,0 @@ -import { EventEmitter } from 'events' -import { LuaLibraries, LuaReturn, LuaThread, LuaType, decorate, decorateProxy, decorateUserdata } from '../dist/index.js' -import { expect } from 'chai' -import { getEngine, getFactory } from './utils.js' -import { setTimeout } from 'node:timers/promises' -import { mock } from 'node:test' - -class TestClass { - static hello() { - return 'world' - } - - constructor(name) { - this.name = name - } - - getName() { - return this.name - } -} - -describe('Engine', () => { - let intervals = [] - const setIntervalSafe = (callback, interval) => { - intervals.push(setInterval(() => callback(), interval)) - } - - afterEach(() => { - for (const interval of intervals) { - clearInterval(interval) - } - intervals = [] - }) - - it('receive lua table on JS function should succeed', async () => { - const engine = await getEngine() - engine.global.set('stringify', (table) => { - return JSON.stringify(table) - }) - - await engine.doString('value = stringify({ test = 1 })') - - expect(engine.global.get('value')).to.be.equal(JSON.stringify({ test: 1 })) - }) - - it('get a global table inside a JS function called by lua should succeed', async () => { - const engine = await getEngine() - engine.global.set('t', { test: 1 }) - engine.global.set('test', () => { - return engine.global.get('t') - }) - - const value = await engine.doString('return test(2)') - - expect(value).to.be.eql({ test: 1 }) - }) - - it('receive JS object on lua should succeed', async () => { - const engine = await getEngine() - - engine.global.set('test', () => { - return { - aaaa: 1, - bbb: 'hey', - test() { - return 22 - }, - } - }) - const value = await engine.doString('return test().test()') - - expect(value).to.be.equal(22) - }) - - it('receive JS object with circular references on lua should succeed', async () => { - const engine = await getEngine() - const obj = { - hello: 'world', - } - obj.self = obj - engine.global.set('obj', obj) - - const value = await engine.doString('return obj.self.self.self.hello') - - expect(value).to.be.equal('world') - }) - - it('receive Lua object with circular references on JS should succeed', async () => { - const engine = await getEngine() - const value = await engine.doString(` - local obj1 = { - hello = 'world', - } - obj1.self = obj1 - local obj2 = { - 5, - hello = 'everybody', - array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, - fn = function() - return 'hello' - end - } - obj2.self = obj2 - return { obj1 = obj1, obj2 } - `) - - const obj = { - obj1: { - hello: 'world', - }, - 1: { - 1: 5, - hello: 'everybody', - array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - // Emulate the lua function - fn: value[1].fn, - }, - } - obj.obj1.self = obj.obj1 - obj[1].self = obj[1] - expect(value).to.deep.eql(obj) - }) - - it('receive lua array with circular references on JS should succeed', async () => { - const engine = await getEngine() - const value = await engine.doString(` - obj = { - "hello", - "world" - } - table.insert(obj, obj) - return obj - `) - - const arr = ['hello', 'world'] - arr.push(arr) - expect(value).to.be.eql(arr) - }) - - it('receive JS object with multiple circular references on lua should succeed', async () => { - const engine = await getEngine() - const obj1 = { - hello: 'world', - } - obj1.self = obj1 - const obj2 = { - hello: 'everybody', - } - obj2.self = obj2 - engine.global.set('obj', { obj1, obj2 }) - - await engine.doString(` - assert(obj.obj1.self.self.hello == "world") - assert(obj.obj2.self.self.hello == "everybody") - `) - }) - - it('receive JS object with null prototype on lua should succeed', async () => { - const engine = await getEngine() - const obj = Object.create(null) - obj.hello = 'world' - engine.global.set('obj', obj) - - const value = await engine.doString(`return obj.hello`) - - expect(value).to.be.equal('world') - }) - - it('a lua error should throw on JS', async () => { - const engine = await getEngine() - - await expect(engine.doString(`x -`)).to.eventually.be.rejected - }) - - it('call a lua function from JS should succeed', async () => { - const engine = await getEngine() - - await engine.doString(`function sum(x, y) return x + y end`) - const sum = engine.global.get('sum') - - expect(sum(10, 50)).to.be.equal(60) - }) - - it('scheduled lua calls should succeed', async () => { - const engine = await getEngine() - engine.global.set('setInterval', setIntervalSafe) - - await engine.doString(` - test = "" - setInterval(function() - test = test .. "i" - end, 1) - `) - await setTimeout(20) - - const test = engine.global.get('test') - expect(test).length.above(3) - expect(test).length.below(21) - expect(test).to.be.equal(''.padEnd(test.length, 'i')) - }) - - it('scheduled lua calls should fail silently if invalid', async () => { - const engine = await getEngine() - engine.global.set('setInterval', setIntervalSafe) - const originalConsoleWarn = console.warn - console.warn = mock.fn() - - await engine.doString(` - test = 0 - setInterval(function() - test = test + 1 - end, 5) - `) - engine.global.close() - await setTimeout(5 + 5) - console.warn = originalConsoleWarn - }) - - it('call lua function from JS passing an array argument should succeed', async () => { - const engine = await getEngine() - - const sum = await engine.doString(` - return function(arr) - local sum = 0 - for k, v in ipairs(arr) do - sum = sum + v - end - return sum - end - `) - - expect(sum([10, 50, 25])).to.be.equal(85) - }) - - it('call a global function with multiple returns should succeed', async () => { - const engine = await getEngine() - - await engine.doString(` - function f(x,y) - return 1,x,y,"Hello World",{},function() end - end - `) - - const returns = engine.global.call('f', 10, 25) - expect(returns).to.have.length(6) - expect(returns.slice(0, -1)).to.eql([1, 10, 25, 'Hello World', {}]) - expect(returns.at(-1)).to.be.a('function') - }) - - it('get a lua thread should succeed', async () => { - const engine = await getEngine() - - const thread = await engine.doString(` - return coroutine.create(function() - print("hey") - end) - `) - - expect(thread).to.be.instanceOf(LuaThread) - expect(thread).to.not.be.equal(0) - }) - - it('a JS error should pause lua execution', async () => { - const engine = await getEngine() - const check = mock.fn() - engine.global.set('check', check) - engine.global.set('throw', () => { - throw new Error('expected error') - }) - - await expect( - engine.doString(` - throw() - check() - `), - ).eventually.to.be.rejected - expect(check.mock.calls).to.have.length(0) - }) - - it('catch a JS error with pcall should succeed', async () => { - const engine = await getEngine() - const check = mock.fn() - engine.global.set('check', check) - engine.global.set('throw', () => { - throw new Error('expected error') - }) - - await engine.doString(` - local success, err = pcall(throw) - assert(success == false) - assert(tostring(err) == "Error: expected error") - check() - `) - - expect(check.mock.calls).to.have.length(1) - }) - - it('call a JS function in a different thread should succeed', async () => { - const engine = await getEngine() - const sum = mock.fn((x, y) => x + y) - engine.global.set('sum', sum) - - await engine.doString(` - coroutine.resume(coroutine.create(function() - sum(10, 20) - end)) - `) - - expect(sum.mock.calls).to.have.length(1) - expect(sum.mock.calls[0].arguments).to.be.eql([10, 20]) - }) - - it('get callable table as function should succeed', async () => { - const engine = await getEngine() - await engine.doString(` - _G['sum'] = setmetatable({}, { - __call = function(self, x, y) - return x + y - end - }) - `) - - engine.global.lua.lua_getglobal(engine.global.address, 'sum') - const sum = engine.global.getValue(-1, LuaType.Function) - - expect(sum(10, 30)).to.be.equal(40) - }) - - it('lua_resume with yield succeeds', async () => { - const engine = await getEngine() - const thread = engine.global.newThread() - thread.loadString(` - local yieldRes = coroutine.yield(10) - return yieldRes - `) - - const resumeResult = thread.resume(0) - expect(resumeResult.result).to.be.equal(LuaReturn.Yield) - expect(resumeResult.resultCount).to.be.equal(1) - - const yieldValue = thread.getValue(-1) - expect(yieldValue).to.be.equal(10) - - thread.pop(resumeResult.resultCount) - thread.pushValue(yieldValue * 2) - - const finalResumeResult = thread.resume(1) - expect(finalResumeResult.result).to.be.equal(LuaReturn.Ok) - expect(finalResumeResult.resultCount).to.be.equal(1) - - const finalValue = thread.getValue(-1) - expect(finalValue).to.be.equal(20) - }) - - it('get memory with allocation tracing should succeeds', async () => { - const engine = await getEngine({ traceAllocations: true }) - expect(engine.global.getMemoryUsed()).to.be.greaterThan(0) - }) - - it('get memory should return correct', async () => { - const engine = await getEngine({ traceAllocations: true }) - - const totalMemory = await engine.doString(` - collectgarbage() - local x = 10 - local batata = { dawdwa = 1 } - return collectgarbage('count') * 1024 - `) - - expect(engine.global.getMemoryUsed()).to.be.equal(totalMemory) - }) - - it('get memory without tracing should throw', async () => { - const engine = await getEngine({ traceAllocations: false }) - - expect(() => engine.global.getMemoryUsed()).to.throw() - }) - - it('limit memory use causes program loading failure succeeds', async () => { - const engine = await getEngine({ traceAllocations: true }) - engine.global.setMemoryMax(engine.global.getMemoryUsed()) - expect(() => { - engine.global.loadString(` - local a = 10 - local b = 20 - return a + b - `) - }).to.throw('not enough memory') - - // Remove the limit and retry - engine.global.setMemoryMax(undefined) - engine.global.loadString(` - local a = 10 - local b = 20 - return a + b - `) - }) - - it('limit memory use causes program runtime failure succeeds', async () => { - const engine = await getEngine({ traceAllocations: true }) - engine.global.loadString(` - local tab = {} - for i = 1, 50, 1 do - tab[i] = i - end - `) - engine.global.setMemoryMax(engine.global.getMemoryUsed()) - - await expect(engine.global.run()).to.eventually.be.rejectedWith('not enough memory') - }) - - it('table supported circular dependencies', async () => { - const engine = await getEngine() - - const a = { name: 'a' } - const b = { name: 'b' } - b.a = a - a.b = b - - engine.global.pushValue(a) - const res = engine.global.getValue(-1) - - expect(res.b.a).to.be.eql(res) - }) - - it('wrap a js object (with metatable)', async () => { - const engine = await getEngine() - engine.global.set('TestClass', { - create: (name) => { - return decorate( - { - instance: decorateUserdata(new TestClass(name)), - }, - { - metatable: { - __name: 'js_TestClass', - __index: (self, key) => { - if (key === 'name') { - return self.instance.getName() - } - return null - }, - }, - }, - ) - }, - }) - - const res = await engine.doString(` - local instance = TestClass.create("demo name") - return instance.name - `) - expect(res).to.be.equal('demo name') - }) - - it('wrap a js object using proxy', async () => { - const engine = await getEngine() - engine.global.set('TestClass', { - create: (name) => new TestClass(name), - }) - const res = await engine.doString(` - local instance = TestClass.create("demo name 2") - return instance:getName() - `) - expect(res).to.be.equal('demo name 2') - }) - - it('wrap a js object using proxy and apply metatable in lua', async () => { - const engine = await getEngine() - engine.global.set('TestClass', { - create: (name) => new TestClass(name), - }) - const res = await engine.doString(` - local instance = TestClass.create("demo name 2") - - -- Based in the simple lua classes tutotial - local Wrapped = {} - Wrapped.__index = Wrapped - - function Wrapped:create(name) - local wrapped = {} - wrapped.instance = TestClass.create(name) - setmetatable(wrapped, Wrapped) - return wrapped - end - - function Wrapped:getName() - return "wrapped: "..self.instance:getName() - end - - local wr = Wrapped:create("demo") - return wr:getName() - `) - expect(res).to.be.equal('wrapped: demo') - }) - - it('classes should be a userdata when proxied', async () => { - const engine = await getEngine() - engine.global.set('obj', { TestClass }) - - const testClass = await engine.doString(` - return obj.TestClass - `) - - expect(testClass).to.be.equal(TestClass) - }) - - it('timeout blocking lua program', async () => { - const engine = await getEngine() - engine.global.loadString(` - local i = 0 - while true do i = i + 1 end - `) - - await expect(engine.global.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') - }) - - it('overwrite lib function', async () => { - const engine = await getEngine() - - let output = '' - engine.global.getTable(LuaLibraries.Base, (index) => { - engine.global.setField(index, 'print', (val) => { - // Not a proper print implementation. - output += `${val}\n` - }) - }) - - await engine.doString(` - print("hello") - print("world") - `) - - expect(output).to.be.equal('hello\nworld\n') - }) - - it('inject a userdata with a metatable should succeed', async () => { - const engine = await getEngine() - const obj = decorate( - {}, - { - metatable: { __index: (_, k) => `Hello ${k}!` }, - }, - ) - engine.global.set('obj', obj) - - const res = await engine.doString('return obj.World') - - expect(res).to.be.equal('Hello World!') - }) - - it('a userdata should be collected', async () => { - const engine = await getEngine() - const obj = {} - engine.global.set('obj', obj) - const refIndex = engine.global.lua.getLastRefIndex() - const oldRef = engine.global.lua.getRef(refIndex) - - await engine.doString(` - local weaktable = {} - setmetatable(weaktable, { __mode = "v" }) - table.insert(weaktable, obj) - obj = nil - collectgarbage() - assert(next(weaktable) == nil) - `) - - expect(oldRef).to.be.equal(obj) - const newRef = engine.global.lua.getRef(refIndex) - expect(newRef).to.be.equal(undefined) - }) - - it('environment variables should be set', async () => { - const factory = getFactory({ TEST: 'true' }) - const engine = await factory.createEngine() - - const testEnvVar = await engine.doString(`return os.getenv('TEST')`) - - expect(testEnvVar).to.be.equal('true') - }) - - it('static methods should be callable on classes', async () => { - const engine = await getEngine() - engine.global.set('TestClass', TestClass) - - const testHello = await engine.doString(`return TestClass.hello()`) - - expect(testHello).to.be.equal('world') - }) - - it('should be possible to access function properties', async () => { - const engine = await getEngine() - const testFunction = () => undefined - testFunction.hello = 'world' - engine.global.set('TestFunction', decorateProxy(testFunction, { proxy: true })) - - const testHello = await engine.doString(`return TestFunction.hello`) - - expect(testHello).to.be.equal('world') - }) - - it('throw error includes stack trace', async () => { - const engine = await getEngine() - try { - await engine.doString(` - local function a() - error("function a threw error") - end - local function b() a() end - local function c() b() end - c() - `) - throw new Error('should not be reached') - } catch (err) { - expect(err.message).to.includes('[string "..."]:3: function a threw error') - expect(err.message).to.includes('stack traceback:') - expect(err.message).to.includes(`[string "..."]:3: in upvalue 'a'`) - expect(err.message).to.includes(`[string "..."]:5: in upvalue 'b'`) - expect(err.message).to.includes(`[string "..."]:6: in local 'c'`) - expect(err.message).to.includes(`[string "..."]:7: in main chunk`) - } - }) - - it('should get only the last result on run', async () => { - const engine = await getEngine() - - const a = await engine.doString(`return 1`) - const b = await engine.doString(`return 3`) - const c = engine.doStringSync(`return 2`) - const d = engine.doStringSync(`return 5`) - - expect(a).to.be.equal(1) - expect(b).to.be.equal(3) - expect(c).to.be.equal(2) - expect(d).to.be.equal(5) - }) - - it('should get only the return values on call function', async () => { - const engine = await getEngine() - engine.global.set('hello', (name) => `Hello ${name}!`) - - const a = await engine.doString(`return 1`) - const b = engine.doStringSync(`return 5`) - const values = engine.global.call('hello', 'joao') - - expect(a).to.be.equal(1) - expect(b).to.be.equal(5) - expect(values).to.have.length(1) - expect(values[0]).to.be.equal('Hello joao!') - }) - - it('create a large string variable should succeed', async () => { - const engine = await getEngine() - const str = 'a'.repeat(1000000) - - engine.global.set('str', str) - - const res = await engine.doString('return str') - - expect(res).to.be.equal(str) - }) - - it('execute a large string should succeed', async () => { - const engine = await getEngine() - const str = 'a'.repeat(1000000) - - const res = await engine.doString(`return [[${str}]]`) - - expect(res).to.be.equal(str) - }) - - it('negative integers should be pushed and retrieved as string', async () => { - const engine = await getEngine() - engine.global.set('value', -1) - - const res = await engine.doString(`return tostring(value)`) - - expect(res).to.be.equal('-1') - }) - - it('negative integers should be pushed and retrieved as number', async () => { - const engine = await getEngine() - engine.global.set('value', -1) - - const res = await engine.doString(`return value`) - - expect(res).to.be.equal(-1) - }) - - it('number greater than 32 bit int should be pushed and retrieved as string', async () => { - const engine = await getEngine() - const value = 1689031554550 - engine.global.set('value', value) - - const res = await engine.doString(`return tostring(value)`) - - expect(res).to.be.equal(`${String(value)}`) - }) - - it('number greater than 32 bit int should be pushed and retrieved as number', async () => { - const engine = await getEngine() - const value = 1689031554550 - engine.global.set('value', value) - - const res = await engine.doString(`return value`) - - expect(res).to.be.equal(value) - }) - - it('number greater than 32 bit int should be usable as a format argument', async () => { - const engine = await getEngine() - const value = 1689031554550 - engine.global.set('value', value) - - const res = await engine.doString(`return ("%d"):format(value)`) - - expect(res).to.be.equal('1689031554550') - }) - - it('yielding in a JS callback into Lua does not break lua state', async () => { - // When yielding within a callback the error 'attempt to yield across a C-call boundary'. - // This test just checks that throwing that error still allows the lua global to be - // re-used and doesn't cause JS to abort or some nonsense. - const engine = await getEngine() - const testEmitter = new EventEmitter() - engine.global.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) - const resPromise = engine.doString(` - local res = yield():next(function () - coroutine.yield() - return 15 - end) - print("res", res:await()) - `) - - testEmitter.emit('resolve') - await expect(resPromise).to.eventually.be.rejectedWith('Error: attempt to yield across a C-call boundary') - - expect(await engine.doString(`return 42`)).to.equal(42) - }) - - it('forced yield within JS callback from Lua doesnt cause vm to crash', async () => { - const engine = await getEngine({ functionTimeout: 10 }) - engine.global.set('promise', Promise.resolve()) - const thread = engine.global.newThread() - thread.loadString(` - promise:next(function () - while true do - -- nothing - end - end):await() - `) - await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') - - expect(await engine.doString(`return 42`)).to.equal(42) - }) - - it('function callback timeout still allows timeout of caller thread', async () => { - const engine = await getEngine() - engine.global.set('promise', Promise.resolve()) - const thread = engine.global.newThread() - thread.loadString(` - promise:next(function () - -- nothing - end):await() - while true do end - `) - await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') - }) - - it('null injected and valid', async () => { - const engine = await getEngine() - engine.global.loadString(` - local args = { ... } - assert(args[1] == null, string.format("expected first argument to be null, got %s", tostring(args[1]))) - return null, args[1], tostring(null) - `) - engine.global.pushValue(null) - const res = await engine.global.run(1) - expect(res).to.deep.equal([null, null, 'null']) - }) - - it('null injected as nil', async () => { - const engine = await getEngine({ injectObjects: false }) - engine.global.loadString(` - local args = { ... } - assert(type(args[1]) == "nil", string.format("expected first argument to be nil, got %s", type(args[1]))) - return nil, args[1], tostring(nil) - `) - engine.global.pushValue(null) - const res = await engine.global.run(1) - expect(res).to.deep.equal([null, null, 'nil']) - }) - - it('Nested callback from JS to Lua', async () => { - const engine = await getEngine() - engine.global.set('call', (fn) => fn()) - const res = await engine.doString(` - return call(function () - return call(function () - return 10 - end) - end) - `) - expect(res).to.equal(10) - }) - - it('lots of doString calls should succeed', async () => { - const engine = await getEngine() - const length = 10000; - - for (let i = 0; i < length; i++) { - const a = Math.floor(Math.random() * 100); - const b = Math.floor(Math.random() * 100); - const result = await engine.doString(`return ${a} + ${b};`); - expect(result).to.equal(a + b); - } - }) -}) diff --git a/test/errors.test.js b/test/errors.test.js new file mode 100644 index 0000000..9a7b19a --- /dev/null +++ b/test/errors.test.js @@ -0,0 +1,150 @@ +import { LuaRuntime, LuaError, decorate } from '../dist/index.js' +import { expect } from 'chai' +import { getState } from './utils.js' + +describe('LuaError', () => { + it('keeps the raised value, message and traceback apart', async () => { + using state = await getState() + + try { + await state.doString(`error({ code = 7 })`) + throw new Error('should not be reached') + } catch (err) { + expect(err).to.be.instanceOf(LuaError) + expect(err.code).to.be.equal(2) + expect(err.luaValue).to.be.eql({ code: 7 }) + expect(err.message).to.not.include('stack traceback:') + expect(err.traceback).to.include('stack traceback:') + } + }) + + it('a JS error thrown through Lua keeps its own stack', async () => { + using state = await getState() + const thrown = new Error('from js') + state.set('boom', () => { + throw thrown + }) + + try { + await state.doString('boom()') + throw new Error('should not be reached') + } catch (err) { + expect(err.luaValue).to.be.equal(thrown) + expect(err.stack).to.be.equal(thrown.stack) + } + }) +}) + +describe('errors option', () => { + const setThrow = (state) => + state.set('boom', () => { + throw new Error('kaboom') + }) + const catchIt = (expression) => `local ok, err = pcall(boom) return ${expression}` + + // The "Error: " prefix is the tell: js_error renders the bare message, the proxy layer + // leaves tostring to Error.prototype. + const matrix = [ + ['off by default', undefined, 'Error: kaboom'], + ['on when asked for', { errors: true }, 'kaboom'], + // errors used to sit below proxy, so opting in while leaving objects alone did nothing. + ['on when asked for even though objects is proxy', { objects: 'proxy', errors: true }, 'kaboom'], + ['on by default when objects is copy', { objects: 'copy' }, 'kaboom'], + ] + + for (const [name, options, expected] of matrix) { + it(`should be ${name}`, async () => { + using state = await getState(options) + setThrow(state) + + expect(await state.doString(catchIt('tostring(err)'))).to.be.equal(expected) + }) + } + + it('should leave the real properties reachable when off', async () => { + using state = await getState() + setThrow(state) + + expect(await state.doString(catchIt('type(err.stack)'))).to.be.equal('string') + }) + + it('should expose the message when on', async () => { + using state = await getState({ errors: true }) + setThrow(state) + + expect(await state.doString(catchIt('err.message'))).to.be.equal('kaboom') + }) + + it('should be overridable to off when objects is copy', async () => { + using state = await getState({ objects: 'copy', errors: false }) + setThrow(state) + + expect(await state.doString(catchIt('type(err)'))).to.be.equal('table') + }) + + // Claiming every Error on type alone would swallow these, the way proxy used to swallow it. + describe('should defer to an explicit decoration', () => { + const cases = [ + ['userdata', 'userdata', 'js_userdata'], + ['proxy', 'userdata', 'Error: kaboom'], + ['value', 'table', 'table:'], + ] + + for (const [as, luaType, rendering] of cases) { + it(`as: '${as}'`, async () => { + using state = await getState({ errors: true }) + state.set('decorated', decorate(new Error('kaboom'), { as })) + + expect(await state.doString('return type(decorated)')).to.be.equal(luaType) + expect(await state.doString('return tostring(decorated)')).to.include(rendering) + }) + } + }) + + it('should expose an Error constructor to lua when injecting', async () => { + using state = await getState({ errors: true }) + + expect(await state.doString('return tostring(Error.create("made in lua"))')).to.be.equal('made in lua') + expect(await state.doString('return Error.create("made in lua").message')).to.be.equal('made in lua') + }) + + it('should not expose the Error constructor when the extension is off', async () => { + using state = await getState() + + expect(await state.doString('return type(Error)')).to.be.equal('nil') + }) +}) + +describe('Unrepresentable values', () => { + it('getValue throws instead of returning an opaque handle', async () => { + using state = await getState() + const thread = state.newThread() + thread.module.lua_newuserdatauv(thread.address, 4, 0) + + expect(() => thread.getValue(-1)).to.throw('has no JS representation') + }) + + it('the address is still reachable through getPointer', async () => { + using state = await getState() + const thread = state.newThread() + thread.module.lua_newuserdatauv(thread.address, 4, 0) + + expect(thread.getPointer(-1)).to.be.greaterThan(0) + }) +}) + +describe('Warnings', () => { + it('onWarn receives diagnostics instead of the console', async () => { + const lua = await LuaRuntime.load() + const seen = [] + using state = lua.createState({ onWarn: (message) => seen.push(message) }) + + state.getTable('_G', () => { + // Leaves the stack unbalanced on purpose. + state.module.lua_pushnil(state.address) + }) + + expect(seen).to.have.lengthOf(1) + expect(seen[0]).to.include('getTable: expected stack size') + }) +}) diff --git a/test/filesystem.test.js b/test/filesystem.test.js index 849f933..19da1a8 100644 --- a/test/filesystem.test.js +++ b/test/filesystem.test.js @@ -1,66 +1,138 @@ import { expect } from 'chai' -import { getEngine, getFactory } from './utils.js' +import { getState, getLua } from './utils.js' describe('Filesystem', () => { - it('mount a file and require inside lua should succeed', async () => { - const factory = getFactory() - await factory.mountFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') - const engine = await factory.createEngine() + it('write a file and require inside lua should succeed', async () => { + const lua = await getLua() + lua.writeFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') + const state = lua.createState() - await engine.doString('require("test")') + await state.doString('require("test")') - expect(engine.global.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) + expect(state.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) }) - it('mount a file in a complex directory and require inside lua should succeed', async () => { - const factory = getFactory() - await factory.mountFile('yolo/sofancy/test.lua', 'return 42') - const engine = await factory.createEngine() + it('write a file in a complex directory and require inside lua should succeed', async () => { + const lua = await getLua() + lua.writeFile('yolo/sofancy/test.lua', 'return 42') + const state = lua.createState() - const value = await engine.doString('return require("yolo/sofancy/test")') + const value = await state.doString('return require("yolo/sofancy/test")') expect(value).to.be.equal(42) }) - it('mount a init file and require the module inside lua should succeed', async () => { - const factory = getFactory() - await factory.mountFile('hello/init.lua', 'return 42') - const engine = await factory.createEngine() + it('write a init file and require the module inside lua should succeed', async () => { + const lua = await getLua() + lua.writeFile('hello/init.lua', 'return 42') + const state = lua.createState() - const value = await engine.doString('return require("hello")') + const value = await state.doString('return require("hello")') expect(value).to.be.equal(42) }) - it('require a file which is not mounted should throw', async () => { - const engine = await getEngine() + it('require a file which was not written should throw', async () => { + using state = await getState() - await expect(engine.doString('require("nothing")')).to.eventually.be.rejected + await expect(state.doString('require("nothing")')).to.eventually.be.rejectedWith(/module 'nothing' not found/) }) - it('mount a file and run it should succeed', async () => { - const factory = getFactory() - const engine = await factory.createEngine() + it('write a file and run it should succeed', async () => { + const lua = await getLua() + const state = lua.createState() - await factory.mountFile('init.lua', `return 42`) - const value = await engine.doFile('init.lua') + lua.writeFile('init.lua', `return 42`) + const value = await state.doFile('init.lua') expect(value).to.be.equal(42) }) - it('run a file which is not mounted should throw', async () => { - const engine = await getEngine() + it('run a file which was not written should throw', async () => { + using state = await getState() - await expect(engine.doFile('init.lua')).to.eventually.be.rejected + await expect(state.doFile('init.lua')).to.eventually.be.rejectedWith(/cannot open init\.lua/) }) - it('mount a file with a large content should succeed', async () => { - const factory = getFactory() - const engine = await factory.createEngine() + it('write a file with binary content should succeed', async () => { + const lua = await getLua() + using state = lua.createState() + + lua.writeFile('binary.lua', new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50])) // "return 42" + + expect(await state.doString('return dofile("binary.lua")')).to.be.equal(42) + }) + + it('doFileSync should run a written file synchronously', async () => { + const lua = await getLua() + lua.writeFile('sync.lua', 'return 5 * 5') + using state = lua.createState() + + expect(state.doFileSync('sync.lua')).to.be.equal(25) + }) + + it('the default filesystem should be the in-memory one', async () => { + const lua = await getLua() + + expect(lua.module.fs).to.be.equal('memory') + // Nothing of the host is reachable, and relative paths resolve from the root. + expect(lua.cwd()).to.be.equal('/') + }) + + it('readFile should return what lua wrote', async () => { + const lua = await getLua() + using state = lua.createState() + + await state.doString(` + local f = io.open("/written.txt", "w") + f:write("from lua") + f:close() + `) + + expect(lua.readTextFile('/written.txt')).to.be.equal('from lua') + expect(lua.readFile('/written.txt')).to.be.deep.equal(new Uint8Array([102, 114, 111, 109, 32, 108, 117, 97])) + }) + + it('exists should report both a written and a missing file', async () => { + const lua = await getLua() + lua.writeFile('/present.lua', 'return 1') + + expect(lua.exists('/present.lua')).to.be.true + expect(lua.exists('/absent.lua')).to.be.false + }) + + it('chdir should move where relative paths resolve from', async () => { + const lua = await getLua() + lua.writeFile('/somewhere/mod.lua', 'return "found"') + using state = lua.createState() + + lua.chdir('/somewhere') + + expect(lua.cwd()).to.be.equal('/somewhere') + expect(await state.doFile('mod.lua')).to.be.equal('found') + }) + + it('filesystem should expose the shared emscripten FS', async () => { + const lua = await getLua() + lua.writeFile('fs-probe.lua', 'return 1') + + expect(lua.filesystem.analyzePath('/fs-probe.lua').exists).to.be.true + }) + + it('path should expose the emscripten path helpers', async () => { + const lua = await getLua() + + expect(lua.path.dirname('/a/b/c.lua')).to.be.equal('/a/b') + expect(lua.path.basename('/a/b/c.lua')).to.be.equal('c.lua') + }) + + it('write a file with a large content should succeed', async () => { + const lua = await getLua() + const state = lua.createState() const content = 'a'.repeat(1000000) - await factory.mountFile('init.lua', `local a = "${content}" return a`) - const value = await engine.doFile('init.lua') + lua.writeFile('init.lua', `local a = "${content}" return a`) + const value = await state.doFile('init.lua') expect(value).to.be.equal(content) }) diff --git a/test/host-filesystem.test.js b/test/host-filesystem.test.js new file mode 100644 index 0000000..51c007d --- /dev/null +++ b/test/host-filesystem.test.js @@ -0,0 +1,341 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, symlinkSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from 'chai' +import { LuaRuntime } from '../dist/index.js' +import { luaPath, readFileFromLua, useTempDirs } from './utils.js' + +describe('Host filesystem', () => { + // fs: 'host' has no working directory of its own -- chdir moves the Node process, so a test that + // moves into a temp directory has to come back. Registered before useTempDirs so it runs before + // the directory the process may be sitting in is removed. + const originalCwd = process.cwd() + afterEach(() => process.chdir(originalCwd)) + + const createTempDir = useTempDirs('host') + let tempDir + let lua + let state + + beforeEach(async () => { + tempDir = createTempDir() + lua = await LuaRuntime.load({ fs: 'host' }) + state = lua.createState() + }) + + afterEach(() => lua.close()) + + /** Absolute host path of `name` inside the temp directory, as a Lua string literal. */ + const at = (...name) => luaPath(join(tempDir, ...name)) + + it('read a host file should succeed', () => { + writeFileSync(join(tempDir, 'hello.txt'), 'hello world') + + expect(readFileFromLua(state, at('hello.txt'))).to.be.equal('hello world') + }) + + it('write a file to host should succeed', async () => { + await state.doString(` + local f = io.open("${at('output.txt')}", "w") + f:write("written from lua") + f:close() + `) + + expect(readFileSync(join(tempDir, 'output.txt'), 'utf8')).to.be.equal('written from lua') + }) + + it('append to a host file should succeed', async () => { + writeFileSync(join(tempDir, 'append.txt'), 'first line\n') + + await state.doString(` + local f = io.open("${at('append.txt')}", "a") + f:write("second line\\n") + f:close() + `) + + expect(readFileSync(join(tempDir, 'append.txt'), 'utf8')).to.be.equal('first line\nsecond line\n') + }) + + it('read file line by line should succeed', async () => { + writeFileSync(join(tempDir, 'lines.txt'), 'alpha\nbeta\ngamma\n') + + const result = await state.doString(` + local lines = {} + for line in io.lines("${at('lines.txt')}") do + lines[#lines + 1] = line + end + return table.concat(lines, ",") + `) + + expect(result).to.be.equal('alpha,beta,gamma') + }) + + it('read binary file should succeed', async () => { + writeFileSync(join(tempDir, 'binary.dat'), Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe])) + + const result = await state.doString(` + local f = io.open("${at('binary.dat')}", "rb") + local data = f:read("*a") + f:close() + return #data + `) + + expect(result).to.be.equal(5) + }) + + it('write and read back binary data should succeed', async () => { + await state.doString(` + local f = io.open("${at('binout.dat')}", "wb") + f:write(string.char(72, 101, 108, 108, 111)) + f:close() + `) + + expect(readFileSync(join(tempDir, 'binout.dat'), 'utf8')).to.be.equal('Hello') + }) + + it('check if file exists using io.open should succeed', async () => { + writeFileSync(join(tempDir, 'exists.txt'), 'yes') + + const result = await state.doString(` + local f = io.open("${at('exists.txt')}", "r") + local exists = f ~= nil + if f then f:close() end + + local f2 = io.open("${at('nope.txt')}", "r") + local not_exists = f2 == nil + if f2 then f2:close() end + + return exists and not_exists + `) + + expect(result).to.be.true + }) + + it('read file size should succeed', async () => { + writeFileSync(join(tempDir, 'sized.txt'), 'abcdefghij') + + const result = await state.doString(` + local f = io.open("${at('sized.txt')}", "r") + local size = f:seek("end") + f:close() + return size + `) + + expect(result).to.be.equal(10) + }) + + it('seek within a file should succeed', async () => { + writeFileSync(join(tempDir, 'seek.txt'), 'abcdefghij') + + const result = await state.doString(` + local f = io.open("${at('seek.txt')}", "r") + f:seek("set", 3) + local chunk = f:read(4) + f:close() + return chunk + `) + + expect(result).to.be.equal('defg') + }) + + it('doFile from host filesystem should succeed', async () => { + writeFileSync(join(tempDir, 'script.lua'), 'return 7 * 6') + + expect(await state.doFile(at('script.lua'))).to.be.equal(42) + }) + + it('require a host lua file should succeed', async () => { + const luaDir = join(tempDir, 'lualibs') + mkdirSync(luaDir, { recursive: true }) + writeFileSync(join(luaDir, 'mymod.lua'), 'local M = {}; M.value = 99; return M') + + using injected = lua.createState({ inject: true }) + const result = await injected.doString(` + package.path = "${at('lualibs')}/?.lua;" .. package.path + local m = require("mymod") + return m.value + `) + + expect(result).to.be.equal(99) + }) + + it('read from nested directories should succeed', () => { + const nested = join(tempDir, 'a', 'b', 'c') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'deep.txt'), 'deep content') + + expect(readFileFromLua(state, join(nested, 'deep.txt'))).to.be.equal('deep content') + }) + + it('read through a host symlink should succeed', () => { + // A mirror of the host namespace resolves an absolute symlink target against itself rather + // than against the host, so this only works when the filesystem really is the host's. + writeFileSync(join(tempDir, 'target.txt'), 'behind the link') + symlinkSync(join(tempDir, 'target.txt'), join(tempDir, 'absolute.link')) + symlinkSync('target.txt', join(tempDir, 'relative.link')) + + expect(readFileFromLua(state, at('absolute.link'))).to.be.equal('behind the link') + expect(readFileFromLua(state, at('relative.link'))).to.be.equal('behind the link') + }) + + it('create directory from lua with os.execute should succeed', async () => { + await state.doString(`os.execute("mkdir -p '${at('newdir')}'")`) + + expect(existsSync(join(tempDir, 'newdir'))).to.be.true + }) + + it('remove a host file from lua with os.remove should succeed', async () => { + writeFileSync(join(tempDir, 'removeme.txt'), 'to be deleted') + + expect(await state.doString(`return os.remove("${at('removeme.txt')}")`)).to.be.true + expect(existsSync(join(tempDir, 'removeme.txt'))).to.be.false + }) + + it('rename a host file from lua with os.rename should succeed', async () => { + writeFileSync(join(tempDir, 'old.txt'), 'rename me') + + const result = await state.doString(`return os.rename("${at('old.txt')}", "${at('new.txt')}")`) + + expect(result).to.be.true + expect(existsSync(join(tempDir, 'old.txt'))).to.be.false + expect(readFileSync(join(tempDir, 'new.txt'), 'utf8')).to.be.equal('rename me') + }) + + it('the working directory should be the host process one', () => { + expect(lua.cwd()).to.be.equal(process.cwd()) + }) + + it('a relative path should resolve against the host working directory', async () => { + writeFileSync(join(tempDir, 'relative.lua'), 'return "from cwd"') + + lua.chdir(tempDir) + + // realpath, because the temp directory sits behind a symlink on macOS and the working + // directory is always the resolved one. + expect(lua.cwd()).to.be.equal(realpathSync(tempDir)) + // chdir moved the process, so Node agrees on where "here" is. + expect(process.cwd()).to.be.equal(realpathSync(tempDir)) + expect(await state.doFile('relative.lua')).to.be.equal('from cwd') + }) + + it('writeFile should write to the real filesystem', async () => { + lua.writeFile(at('nested', 'written.lua'), 'return "written"') + + expect(readFileSync(join(tempDir, 'nested', 'written.lua'), 'utf8')).to.be.equal('return "written"') + expect(await state.doFile(at('nested', 'written.lua'))).to.be.equal('written') + }) + + it('write large file should succeed', async () => { + const lineCount = 10000 + + await state.doString(` + local f = io.open("${at('large.txt')}", "w") + for i = 1, ${lineCount} do + f:write("line " .. i .. "\\n") + end + f:close() + `) + + const lines = readFileSync(join(tempDir, 'large.txt'), 'utf8').trimEnd().split('\n') + expect(lines.length).to.be.equal(lineCount) + expect(lines[0]).to.be.equal('line 1') + expect(lines[lineCount - 1]).to.be.equal(`line ${lineCount}`) + }) + + it('overwrite existing file should succeed', async () => { + writeFileSync(join(tempDir, 'overwrite.txt'), 'original content') + + await state.doString(` + local f = io.open("${at('overwrite.txt')}", "w") + f:write("new content") + f:close() + `) + + expect(readFileSync(join(tempDir, 'overwrite.txt'), 'utf8')).to.be.equal('new content') + }) + + it('read UTF-8 content should succeed', () => { + const content = 'héllo wörld 日本語 🌍' + writeFileSync(join(tempDir, 'utf8.txt'), content) + + expect(readFileFromLua(state, at('utf8.txt'))).to.be.equal(content) + }) + + it('write UTF-8 content should succeed', async () => { + await state.doString(` + local f = io.open("${at('utf8out.txt')}", "w") + f:write("café ñ 中文") + f:close() + `) + + expect(readFileSync(join(tempDir, 'utf8out.txt'), 'utf8')).to.be.equal('café ñ 中文') + }) + + it('read empty file should succeed', async () => { + writeFileSync(join(tempDir, 'empty.txt'), '') + + const result = await state.doString(` + local f = io.open("${at('empty.txt')}", "r") + local data = f:read("*a") + f:close() + return #data + `) + + expect(result).to.be.equal(0) + }) + + it('open non-existent file for reading should return nil and a message', async () => { + const result = await state.doString(` + local f, err = io.open("${at('nonexistent.txt')}", "r") + return f == nil and type(err) == "string" and #err > 0 + `) + + expect(result).to.be.true + }) + + it('io.tmpfile should succeed', async () => { + const result = await state.doString(` + local f = io.tmpfile() + f:write("temp data") + f:seek("set") + local data = f:read("*a") + f:close() + return data + `) + + expect(result).to.be.equal('temp data') + }) + + it('multiple states sharing the same filesystem should succeed', async () => { + using other = lua.createState() + + await state.doString(` + local f = io.open("${at('shared.txt')}", "w") + f:write("from state1") + f:close() + `) + + expect(readFileFromLua(other, at('shared.txt'))).to.be.equal('from state1') + }) + + it('read number from file should succeed', async () => { + writeFileSync(join(tempDir, 'numbers.txt'), '42\n3.14\n100') + + const result = await state.doString(` + local f = io.open("${at('numbers.txt')}", "r") + local n1 = f:read("*n") + local n2 = f:read("*n") + local n3 = f:read("*n") + f:close() + return n1 + n2 + n3 + `) + + expect(result).to.be.closeTo(145.14, 0.001) + }) + + it('mounts should be rejected, since every host path is already reachable', async () => { + await expect(LuaRuntime.load({ fs: 'host', mounts: { '/scripts': tempDir } })).to.eventually.be.rejectedWith( + /mounts belong to fs: 'memory'/, + ) + expect(() => lua.mount('/scripts', tempDir)).to.throw(/mounts belong to fs: 'memory'/) + }) +}) diff --git a/test/initialization.test.js b/test/initialization.test.js index 72d1684..3042477 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -1,28 +1,230 @@ -import { LuaFactory } from '../dist/index.js' +import { LUA_LIB_BITS, LuaRuntime } from '../dist/index.js' import { expect } from 'chai' +import { getLua, getState } from './utils.js' describe('Initialization', () => { - it('create engine should succeed', async () => { - await new LuaFactory().createEngine() + it('create state should succeed', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + expect(state.isClosed()).to.be.false + expect(state.address).to.be.greaterThan(0) + }) + + it('create multiple states should keep their globals independent', async () => { + const lua = await LuaRuntime.load() + using state1 = lua.createState() + using state2 = lua.createState() + + await state1.doString('x = 10') + await state2.doString('x = 20') + + expect(state1.address).to.not.be.equal(state2.address) + expect(await state1.doString('return x')).to.be.equal(10) + expect(await state2.doString('return x')).to.be.equal(20) }) - it('create engine with options should succeed', async () => { - await new LuaFactory().createEngine({ - enableProxy: true, - injectObjects: true, - openStandardLibs: true, - traceAllocations: true, + it('create state with options should apply them', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState({ + objects: 'proxy', + inject: true, + libs: true, + memory: { trace: true }, }) + + expect(state.memory.used).to.be.greaterThan(0) + expect(await state.doString('return type(null)')).to.be.equal('userdata') }) it('create with environment variables should succeed', async () => { const env = { ENV_TEST: 'test', } - const engine = await new LuaFactory(undefined, env).createEngine() + const lua = await LuaRuntime.load({ env }) + const state = lua.createState() - const value = await engine.doString('return os.getenv("ENV_TEST")') + const value = await state.doString('return os.getenv("ENV_TEST")') expect(value).to.be.equal(env.ENV_TEST) }) }) + +describe('Standard libraries', () => { + // LUA_LIB_BITS is transcribed by hand from lualib.h, so each bit is checked against the + // global its library installs. A reordering on a Lua version bump fails here. + const cases = [ + ['base', 'print'], + ['package', 'require'], + ['coroutine', 'coroutine'], + ['debug', 'debug'], + ['io', 'io'], + ['math', 'math'], + ['os', 'os'], + ['string', 'string'], + ['table', 'table'], + ['utf8', 'utf8'], + ] + + for (const [lib, global] of cases) { + it(`libs: ['${lib}'] installs ${global} and nothing else`, async () => { + const lua = await LuaRuntime.load() + // base comes along so `type` exists; io holds FILE* userdata with no JS + // representation, so presence is asserted from Lua rather than marshalled. + using state = lua.createState({ libs: lib === 'base' ? ['base'] : ['base', lib] }) + const typeOf = (name) => state.doStringSync(`return type(${name})`) + + expect(typeOf(global), `${lib} should install ${global}`).to.not.be.equal('nil') + + for (const [otherLib, otherGlobal] of cases) { + if (otherLib === lib || otherLib === 'base' || otherGlobal === global) { + continue + } + expect(typeOf(otherGlobal), `${lib} should not install ${otherGlobal}`).to.be.equal('nil') + } + }) + } + + it('libs: false leaves the state empty', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState({ libs: false }) + + // Without the base library there is no `type` either, so this reads the globals directly. + expect(state.get('print')).to.be.null + expect(state.get('tostring')).to.be.null + }) + + it('libs defaults to all of them', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + for (const [, global] of cases) { + expect(state.doStringSync(`return type(${global})`), global).to.not.be.equal('nil') + } + }) + + it('an unknown library name is rejected, naming the valid ones', async () => { + const lua = await LuaRuntime.load() + + expect(() => lua.createState({ libs: ['nope'] })).to.throw("unknown Lua library 'nope'") + expect(() => lua.createState({ libs: ['nope'] })).to.throw(Object.keys(LUA_LIB_BITS).join(', ')) + }) +}) + +describe('Disposal', () => { + it('using closes the state at the end of the block', async () => { + const lua = await LuaRuntime.load() + let escaped + { + using state = lua.createState() + escaped = state + expect(state.isClosed()).to.be.false + } + + expect(escaped.isClosed()).to.be.true + }) + + it('closing the runtime closes every state it created', async () => { + const lua = await LuaRuntime.load() + const first = lua.createState() + const second = lua.createState() + + lua.close() + + expect(first.isClosed()).to.be.true + expect(second.isClosed()).to.be.true + }) + + it('a closed state is released by the runtime that made it', async () => { + const lua = await LuaRuntime.load() + + // Without this the Set grows for the runtime's lifetime, which a create/close loop hits. + for (let index = 0; index < 100; index++) { + lua.createState().close() + } + const retained = Object.values(lua).find((value) => value instanceof Set) + + expect(retained.size).to.be.equal(0) + }) + + it('await using disposes the runtime', async () => { + let escaped + { + await using lua = await LuaRuntime.load() + escaped = lua.createState() + } + + expect(escaped.isClosed()).to.be.true + }) + + // lua_close hands the lua_State back to the wasm allocator, so a call that gets through + // afterwards is reading and writing memory something else may already own. These used to reach + // the bindings: some trapped with `table index is out of bounds`, `get` returned whatever was + // in the freed struct, and `call` reported the global as missing. + describe('use after close', () => { + // The guard is one check per operation and none per stack value, so these are every entry + // point on the guarded side of that line. Split by shape rather than wrapped in a promise, + // to hold each one to throwing the way it does when the state is open. + const syncEntryPoints = { + get: (state) => state.get('print'), + set: (state) => state.set('x', 1), + doStringSync: (state) => state.doStringSync('return 1'), + doFileSync: (state) => state.doFileSync('script.lua'), + call: (state) => state.call('print', 'hi'), + getTable: (state) => state.getTable('_G', () => undefined), + loadString: (state) => state.loadString('return 1'), + loadFile: (state) => state.loadFile('script.lua'), + newThread: (state) => state.newThread(), + resetThread: (state) => state.resetThread(), + resume: (state) => state.resume(), + runSync: (state) => state.runSync(), + setLimits: (state) => state.setLimits({ maxInstructions: 10 }), + setDeadline: (state) => state.setDeadline(Date.now() + 10), + } + + for (const [name, use] of Object.entries(syncEntryPoints)) { + it(`${name} throws on a closed state instead of using freed memory`, async () => { + using state = await getState() + state.close() + + expect(() => use(state)).to.throw('the Lua state is closed') + }) + } + + for (const name of ['doString', 'doFile']) { + it(`${name} rejects on a closed state instead of using freed memory`, async () => { + using state = await getState() + state.close() + + await expect(state[name]('return 1')).to.eventually.be.rejectedWith('the Lua state is closed') + }) + } + + it('a state closed by its runtime is refused the same way', async () => { + const lua = await getLua() + const state = lua.createState() + lua.close() + + expect(() => state.doStringSync('return 1')).to.throw('the Lua state is closed') + }) + + it('a thread outlives neither the state nor the check', async () => { + using state = await getState() + const thread = state.newThread() + state.close() + + expect(thread.isClosed()).to.be.true + expect(() => thread.loadString('return 1')).to.throw('the Lua state is closed') + }) + + it('a state closed while a run is parked stops rather than resuming into freed memory', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + + const running = state.doString('sleep(10):await() return 1') + state.close() + + await expect(running).to.eventually.be.rejectedWith('the Lua state is closed') + }) + }) +}) diff --git a/test/limits.test.js b/test/limits.test.js new file mode 100644 index 0000000..16f9c16 --- /dev/null +++ b/test/limits.test.js @@ -0,0 +1,122 @@ +import { LuaInterruptError, LuaTimeoutError, LuaInstructionLimitError, LuaAbortError } from '../dist/index.js' +import { expect } from 'chai' +import { getState } from './utils.js' + +describe('Run limits', () => { + const busyLoop = 'local x = 0 for i = 1, 1e9 do x = x + 1 end return x' + + it('timeout interrupts a tight loop', async function () { + this.timeout(20_000) + using state = await getState() + + await expect(state.doString(busyLoop, { timeout: 20 })).to.eventually.be.rejectedWith(LuaTimeoutError) + }) + + it('maxInstructions interrupts a tight loop', async function () { + this.timeout(20_000) + using state = await getState() + + await expect(state.doString(busyLoop, { maxInstructions: 5_000 })).to.eventually.be.rejectedWith(LuaInstructionLimitError) + }) + + it('an already aborted signal stops the run', async function () { + this.timeout(20_000) + using state = await getState() + + await expect(state.doString(busyLoop, { signal: AbortSignal.abort() })).to.eventually.be.rejectedWith(LuaAbortError) + }) + + it('a signal aborted while parked on a promise stops the run', async function () { + this.timeout(20_000) + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + const controller = new AbortController() + setTimeout(() => controller.abort(), 5) + + await expect(state.doString('sleep(30):await() return 1', { signal: controller.signal })).to.eventually.be.rejectedWith( + LuaAbortError, + ) + }) + + it('maxInstructions from the state options applies to every run', async function () { + this.timeout(20_000) + using state = await getState({ limits: { maxInstructions: 5_000 } }) + + await expect(state.doString(busyLoop)).to.eventually.be.rejectedWith(LuaInstructionLimitError) + expect(await state.doString('return 1 + 1')).to.be.equal(2) + }) + + it('setLimits on the state reaches both the sync and the async path', async function () { + this.timeout(20_000) + using state = await getState() + state.setLimits({ maxInstructions: 5_000 }) + + await expect(state.doString(busyLoop)).to.eventually.be.rejectedWith(LuaInstructionLimitError) + expect(() => state.doStringSync(busyLoop)).to.throw(LuaInstructionLimitError) + }) + + it('every limit shares one catchable base', async function () { + this.timeout(20_000) + using state = await getState() + + for (const options of [{ timeout: 20 }, { maxInstructions: 5_000 }, { signal: AbortSignal.abort() }]) { + await expect(state.doString(busyLoop, options)).to.eventually.be.rejectedWith(LuaInterruptError) + } + }) + + it('limits are restored after a scoped run', async () => { + using state = await getState() + state.setLimits({ maxInstructions: 999 }) + + await state.doString('return 1', { maxInstructions: 10_000 }) + + expect(state.getLimits().maxInstructions).to.be.equal(999) + }) + + // An interrupt used to be pushed into Lua as a JS error and recognised on the way back out by + // its identity, which only survived in a state whose extensions marshal an Error by reference. + // `objects: 'copy'` with `errors: false` -- a sandbox that keeps JS errors away from Lua, and so + // exactly the state most likely to set a limit in the first place -- turned every interrupt into + // an opaque `LuaError: table: 0x...`. + describe('state configurations', () => { + const configs = [ + ['the default proxy state', {}], + ['objects: copy', { objects: 'copy' }], + ['objects: copy with errors off', { objects: 'copy', errors: false }], + ['no injected globals', { inject: false }], + ['no standard libraries', { libs: false }], + ['no libraries and nothing to marshal errors', { libs: false, objects: 'copy', errors: false, inject: false }], + ] + + for (const [description, config] of configs) { + it(`reports every interrupt as itself in ${description}`, async function () { + this.timeout(20_000) + using state = await getState(config) + + await expect(state.doString(busyLoop, { maxInstructions: 5_000 })).to.eventually.be.rejectedWith(LuaInstructionLimitError) + await expect(state.doString(busyLoop, { timeout: 20 })).to.eventually.be.rejectedWith(LuaTimeoutError) + await expect(state.doString(busyLoop, { signal: AbortSignal.abort() })).to.eventually.be.rejectedWith(LuaAbortError) + expect(() => state.doStringSync(busyLoop, { maxInstructions: 5_000 })).to.throw(LuaInstructionLimitError) + }) + } + }) + + it('an interrupt the script caught does not stand in for the error that followed', async function () { + this.timeout(20_000) + using state = await getState({ objects: 'copy', errors: false }) + + // The budget is spent inside the pcall, so the interrupt is raised and swallowed there. The + // hook re-raises within another hookCount instructions, which is room enough for the error + // below and nothing much else. + const failure = state.doString( + `pcall(function () ${busyLoop} end) + error('raised after the interrupt was caught')`, + { maxInstructions: 5_000 }, + ) + + await expect(failure).to.eventually.be.rejected.then((error) => { + expect(error).to.not.be.an.instanceOf(LuaInterruptError) + expect(error.message).to.contain('raised after the interrupt was caught') + }) + }) +}) diff --git a/test/luatests.js b/test/luatests.js index 3b08dd2..c75a8a8 100644 --- a/test/luatests.js +++ b/test/luatests.js @@ -1,37 +1,44 @@ -import { LuaFactory } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' import { fileURLToPath } from 'node:url' -import { readFile, readdir } from 'node:fs/promises' -import { resolve } from 'node:path' +import { readFile, glob } from 'node:fs/promises' -async function* walk(dir) { - const dirents = await readdir(dir, { withFileTypes: true }) - for (const dirent of dirents) { - const res = resolve(dir, dirent.name) - if (dirent.isDirectory()) { - yield* walk(res) - } else { - yield res - } - } -} - -const factory = new LuaFactory() +const lua = await LuaRuntime.load() const testsPath = import.meta.resolve('../lua/testes') const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) -for await (const file of walk(filePath)) { +if (!lua.exists('/dev/full')) { + const deviceMode = lua.filesystem.lookupPath('/dev/null').node.mode + const fullDevice = lua.filesystem.makedev(64, 0) + lua.filesystem.registerDevice(fullDevice, { + open(stream) { + stream.seekable = false + }, + close() {}, + read() { + return 0 + }, + write() { + throw new lua.filesystem.ErrnoError(28) + }, + llseek() { + throw new lua.filesystem.ErrnoError(70) + }, + }) + lua.filesystem.mkdev('/dev/full', deviceMode, fullDevice) +} + +for await (const file of glob(`${filePath}/**/*.lua`)) { const relativeFile = file.replace(`${filePath}/`, '') - await factory.mountFile(relativeFile, await readFile(file)) + lua.writeFile(relativeFile, await readFile(file)) } -const lua = await factory.createEngine() -const luamodule = await factory.getLuaModule() -luamodule.lua_warning(lua.global.address, '@on', 0) -lua.global.set('arg', ['lua', 'all.lua']) -lua.global.set('_port', true) -lua.global.getTable('os', (i) => { - lua.global.setField(i, 'setlocale', (locale) => { +const state = lua.createState() +lua.module.lua_warning(state.address, '@on', 0) +state.set('arg', ['lua', 'all.lua']) +state.set('_port', true) +state.getTable('os', (i) => { + state.setField(i, 'setlocale', (locale) => { return locale && locale !== 'C' ? false : 'C' }) }) -lua.doFileSync('all.lua') +state.doFileSync('all.lua') diff --git a/test/mounts.test.js b/test/mounts.test.js new file mode 100644 index 0000000..1c3bf46 --- /dev/null +++ b/test/mounts.test.js @@ -0,0 +1,141 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from 'chai' +import { LuaRuntime } from '../dist/index.js' +import { readFileFromLua, useTempDirs } from './utils.js' + +describe('Mounts', () => { + const createTempDir = useTempDirs('mounts') + let hostDir + let outsideDir + + beforeEach(() => { + hostDir = createTempDir() + outsideDir = createTempDir() + writeFileSync(join(hostDir, 'inside.txt'), 'mounted content') + writeFileSync(join(outsideDir, 'outside.txt'), 'should stay unreachable') + }) + + it('the in-memory filesystem alone should reach nothing on the host', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + expect(readFileFromLua(state, join(hostDir, 'inside.txt'))).to.be.equal('BLOCKED') + }) + + it('a mount should expose the host directory at the given path', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + expect(readFileFromLua(state, '/scripts/inside.txt')).to.be.equal('mounted content') + }) + + it('a mount should leave the rest of the host unreachable', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + expect(readFileFromLua(state, join(outsideDir, 'outside.txt'))).to.be.equal('BLOCKED') + // Not even the host path the mount points at, since only the mount point leads there. + expect(readFileFromLua(state, join(hostDir, 'inside.txt'))).to.be.equal('BLOCKED') + }) + + it('a write through a mount should land on the host', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + state.doStringSync(` + local f = io.open("/scripts/written.txt", "w") + f:write("from lua") + f:close() + `) + + expect(readFileSync(join(hostDir, 'written.txt'), 'utf8')).to.be.equal('from lua') + }) + + it('several mounts should coexist with files written to memory', async () => { + const other = createTempDir() + writeFileSync(join(other, 'other.txt'), 'the other one') + + const lua = await LuaRuntime.load({ mounts: { '/a': hostDir, '/b/deeper': other } }) + using state = lua.createState() + lua.writeFile('/virtual/only.txt', 'in memory') + + expect(readFileFromLua(state, '/a/inside.txt')).to.be.equal('mounted content') + expect(readFileFromLua(state, '/b/deeper/other.txt')).to.be.equal('the other one') + expect(readFileFromLua(state, '/virtual/only.txt')).to.be.equal('in memory') + }) + + it('a relative host path should resolve against the process working directory', async () => { + const lua = await LuaRuntime.load({ mounts: { '/here': '.' } }) + using state = lua.createState() + + expect(readFileFromLua(state, '/here/package.json')).to.match(/"name": "wasmoon"/) + }) + + it('require should find a module inside a mount', async () => { + mkdirSync(join(hostDir, 'lib'), { recursive: true }) + writeFileSync(join(hostDir, 'lib', 'mymod.lua'), 'return { value = 7 }') + + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState({ inject: true }) + + expect( + state.doStringSync(` + package.path = "/scripts/lib/?.lua;" .. package.path + return require("mymod").value + `), + ).to.be.equal(7) + }) + + it('mounting after loading should expose the directory', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + expect(readFileFromLua(state, '/late/inside.txt')).to.be.equal('BLOCKED') + + lua.mount('/late', hostDir) + + expect(readFileFromLua(state, '/late/inside.txt')).to.be.equal('mounted content') + }) + + it('unmounting should make the directory unreachable again, and free the mount point', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + lua.unmount('/scripts') + + expect(readFileFromLua(state, '/scripts/inside.txt')).to.be.equal('BLOCKED') + + // The mount point is no longer taken, so the same path can be mounted again. + lua.mount('/scripts', outsideDir) + expect(readFileFromLua(state, '/scripts/outside.txt')).to.be.equal('should stay unreachable') + }) + + describe('validation', () => { + // Each case is the same call with a different mounts object, so they are listed rather than + // written out: [what is wrong, mounts, the message it has to be reported with]. + const cases = [ + ['a host directory that does not exist', () => ({ '/scripts': join(hostDir, 'missing') }), /could not be read/], + ['a host path that is a file', () => ({ '/scripts': join(hostDir, 'inside.txt') }), /is not a directory/], + ['a mount point that is not absolute', () => ({ scripts: hostDir }), /has to be an absolute path below the root/], + ['the root as a mount point', () => ({ '/': hostDir }), /has to be an absolute path below the root/], + ['an empty segment in the mount point', () => ({ '//scripts': hostDir }), /has to be an absolute path below the root/], + ['a mount point that is not normalized', () => ({ '/scripts/../etc': hostDir }), /has to be a normalized path/], + ['nested mount points', () => ({ '/scripts': hostDir, '/scripts/inner': outsideDir }), /it overlaps the mount point/], + ['the same mount point twice', () => ({ '/scripts': hostDir, '/scripts/': outsideDir }), /it overlaps the mount point/], + ['a host path starting with ~', () => ({ '/home': '~/wasmoon' }), /'~' is not expanded/], + ] + + for (const [what, mounts, message] of cases) { + it(`${what} should throw`, async () => { + await expect(LuaRuntime.load({ mounts: mounts() })).to.eventually.be.rejectedWith(message) + }) + } + + it('a mount that overlaps one already made should throw', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + + expect(() => lua.mount('/scripts/inner', outsideDir)).to.throw(/it overlaps the mount point '\/scripts'/) + }) + }) +}) diff --git a/test/promises.test.js b/test/promises.test.js index d7b1d60..c839f88 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -1,16 +1,16 @@ import { expect } from 'chai' -import { getEngine, tick } from './utils.js' +import { getLua, getState, tick } from './utils.js' import { mock } from 'node:test' describe('Promises', () => { it('use promise next should succeed', async () => { - const engine = await getEngine() + using state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - engine.global.set('promise', promise) + state.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` promise:next(check) `) @@ -21,13 +21,13 @@ describe('Promises', () => { }) it('chain promises with next should succeed', async () => { - const engine = await getEngine() + using state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => resolve(60)) - engine.global.set('promise', promise) + state.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` promise:next(function(value) return value * 2 end):next(check):next(check) @@ -42,12 +42,12 @@ describe('Promises', () => { }) it('call an async function should succeed', async () => { - const engine = await getEngine() - engine.global.set('asyncFunction', async () => Promise.resolve(60)) + using state = await getState() + state.set('asyncFunction', async () => Promise.resolve(60)) const check = mock.fn() - engine.global.set('check', check) + state.set('check', check) - const res = engine.doString(` + const res = state.doString(` asyncFunction():next(check) `) @@ -57,10 +57,10 @@ describe('Promises', () => { }) it('return an async function should succeed', async () => { - const engine = await getEngine() - engine.global.set('asyncFunction', async () => Promise.resolve(60)) + using state = await getState() + state.set('asyncFunction', async () => Promise.resolve(60)) - const asyncFunction = await engine.doString(` + const asyncFunction = await state.doString(` return asyncFunction `) const value = await asyncFunction() @@ -69,10 +69,10 @@ describe('Promises', () => { }) it('return a chained promise should succeed', async () => { - const engine = await getEngine() - engine.global.set('asyncFunction', async () => Promise.resolve(60)) + using state = await getState() + state.set('asyncFunction', async () => Promise.resolve(60)) - const asyncFunction = await engine.doString(` + const asyncFunction = await state.doString(` return asyncFunction():next(function(x) return x * 2 end) `) const value = await asyncFunction @@ -81,13 +81,13 @@ describe('Promises', () => { }) it('await an promise inside coroutine should succeed', async () => { - const engine = await getEngine() + using state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - engine.global.set('promise', promise) + state.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` local co = coroutine.create(function() local value = promise:await() check(value) @@ -108,13 +108,13 @@ describe('Promises', () => { }) it('awaited coroutines should ignore resume until it resolves the promise', async () => { - const engine = await getEngine() + using state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - engine.global.set('promise', promise) + state.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` local co = coroutine.create(function() local value = promise:await() check(value) @@ -133,9 +133,9 @@ describe('Promises', () => { }) it('await a thread run with async calls should succeed', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const asyncThread = engine.global.newThread() + using state = await getState() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const asyncThread = state.newThread() asyncThread.loadString(` sleep(1):await() @@ -147,9 +147,9 @@ describe('Promises', () => { }) it('run thread with async calls and yields should succeed', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const asyncThread = engine.global.newThread() + using state = await getState() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const asyncThread = state.newThread() asyncThread.loadString(` coroutine.yield() @@ -165,9 +165,9 @@ describe('Promises', () => { }) it('reject a promise should succeed', async () => { - const engine = await getEngine() - engine.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) - const asyncThread = engine.global.newThread() + using state = await getState() + state.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) + const asyncThread = state.newThread() asyncThread.loadString(` throw():await() @@ -178,9 +178,9 @@ describe('Promises', () => { }) it('pcall a promise await should succeed', async () => { - const engine = await getEngine() - engine.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) - const asyncThread = engine.global.newThread() + using state = await getState() + state.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) + const asyncThread = state.newThread() asyncThread.loadString(` local succeed, err = pcall(function() throw():await() end) @@ -192,13 +192,13 @@ describe('Promises', () => { }) it('catch a promise rejection should succeed', async () => { - const engine = await getEngine() + using state = await getState() const fulfilled = mock.fn() const rejected = mock.fn() - engine.global.set('handlers', { fulfilled, rejected }) - engine.global.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) + state.set('handlers', { fulfilled, rejected }) + state.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) - const res = engine.doString(` + const res = state.doString(` throw:next(handlers.fulfilled, handlers.rejected):catch(function() end) `) @@ -209,10 +209,10 @@ describe('Promises', () => { }) it('run with async callback', async () => { - const engine = await getEngine() - const thread = engine.global.newThread() + using state = await getState() + const thread = state.newThread() - engine.global.set('asyncCallback', async (input) => { + state.set('asyncCallback', async (input) => { return Promise.resolve(input * 2) }) @@ -232,8 +232,8 @@ describe('Promises', () => { }) it('promise creation from js', async () => { - const engine = await getEngine() - const res = await engine.doString(` + using state = await getState() + const res = await state.doString(` local promise = Promise.create(function (resolve) resolve(10) end) @@ -248,8 +248,8 @@ describe('Promises', () => { }) it('reject promise creation from js', async () => { - const engine = await getEngine() - const res = await engine.doString(` + using state = await getState() + const res = await state.doString(` local rejection = Promise.create(function (resolve, reject) reject("expected rejection") end) @@ -261,9 +261,9 @@ describe('Promises', () => { }) it('resolve multiple promises with promise.all', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const resPromise = engine.doString(` + using state = await getState() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const resPromise = state.doString(` local promises = {} for i = 1, 10 do table.insert(promises, sleep(5):next(function () @@ -278,9 +278,9 @@ describe('Promises', () => { }) it('error in promise next catchable', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const resPromise = engine.doString(` + using state = await getState() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const resPromise = state.doString(` return sleep(1):next(function () error("sleep done") end):await() @@ -289,11 +289,80 @@ describe('Promises', () => { }) it('should not be possible to await in synchronous run', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + using state = await getState() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) expect(() => { - engine.doStringSync(`sleep(5):await()`) - }).to.throw('cannot await in the main thread') + state.doStringSync(`sleep(5):await()`) + }).to.throw('cannot await in a thread that cannot yield') + }) + + it('an await abandoned mid flight should not leak a function table slot', async function () { + // Building the trampolines the probe needs is what makes this slow, not the Lua work. + this.timeout(30000) + const lua = await getLua() + // The table object itself is not exported, so its length is probed instead: Emscripten + // reuses freed slots before growing the table, so a batch bigger than any plausible + // freelist exhausts that and the highest index handed out tracks the real length. + const tableHighWater = () => { + const pointers = [] + for (let i = 0; i < 200; i++) { + pointers.push(lua.module.addFunction(() => 0, 'ii')) + } + const max = Math.max(...pointers) + for (const pointer of pointers) { + lua.module.removeFunction(pointer) + } + return max + } + + const runAbandonedAwait = async () => { + using state = lua.createState({ inject: true }) + state.set('slow', () => new Promise((resolve) => setTimeout(resolve, 50))) + await expect(state.doString('slow():await()', { timeout: 5 })).eventually.to.be.rejectedWith('timeout') + } + + // Once first, so nothing the first state builds is counted as growth. + await runAbandonedAwait() + const before = tableHighWater() + for (let i = 0; i < 10; i++) { + await runAbandonedAwait() + } + + expect(tableHighWater()).to.equal(before) + }) + + it('several coroutines awaiting at once should each get their own result', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))) + + // Resumed in the opposite order to the one they settle in, so a continuation that mixed up + // which thread it belonged to would show up here. + const res = await state.doString(` + local threads, results = {}, {} + for i = 1, 5 do + threads[i] = coroutine.create(function() + return sleep((6 - i) * 5):await() + end) + end + local done = 0 + while done < 5 do + -- Hand control back to JS so the timers behind the promises can fire. + coroutine.yield() + for i = 1, 5 do + if coroutine.status(threads[i]) == 'suspended' then + local ok, value = coroutine.resume(threads[i]) + if not ok then error(value) end + if coroutine.status(threads[i]) == 'dead' then + done = done + 1 + results[i] = value + end + end + end + end + return table.concat(results, ',') + `) + + expect(res).to.equal('25,20,15,10,5') }) }) diff --git a/test/state.test.js b/test/state.test.js new file mode 100644 index 0000000..8fc1c08 --- /dev/null +++ b/test/state.test.js @@ -0,0 +1,1239 @@ +import { EventEmitter } from 'events' +import { LuaReturn, LuaThread, LuaType, decorate } from '../dist/index.js' +import { expect } from 'chai' +import { getState, getLua } from './utils.js' +import { setTimeout } from 'node:timers/promises' +import { mock } from 'node:test' + +class TestClass { + static hello() { + return 'world' + } + + constructor(name) { + this.name = name + } + + getName() { + return this.name + } +} + +describe('State', () => { + let intervals = [] + const setIntervalSafe = (callback, interval) => { + const handle = setInterval(() => callback(), interval) + intervals.push(handle) + return () => clearInterval(handle) + } + + afterEach(() => { + for (const interval of intervals) { + clearInterval(interval) + } + intervals = [] + }) + + it('receive lua table on JS function should succeed', async () => { + using state = await getState() + state.set('stringify', (table) => { + return JSON.stringify(table) + }) + + await state.doString('value = stringify({ test = 1 })') + + expect(state.get('value')).to.be.equal(JSON.stringify({ test: 1 })) + }) + + it('get a global table inside a JS function called by lua should succeed', async () => { + using state = await getState() + state.set('t', { test: 1 }) + state.set('test', () => { + return state.get('t') + }) + + const value = await state.doString('return test(2)') + + expect(value).to.be.eql({ test: 1 }) + }) + + it('receive JS object on lua should succeed', async () => { + using state = await getState() + + state.set('test', () => { + return { + aaaa: 1, + bbb: 'hey', + test() { + return 22 + }, + } + }) + const value = await state.doString('return test().test()') + + expect(value).to.be.equal(22) + }) + + it('receive JS object with circular references on lua should succeed', async () => { + using state = await getState() + const obj = { + hello: 'world', + } + obj.self = obj + state.set('obj', obj) + + const value = await state.doString('return obj.self.self.self.hello') + + expect(value).to.be.equal('world') + }) + + it('receive Lua object with circular references on JS should succeed', async () => { + using state = await getState() + const value = await state.doString(` + local obj1 = { + hello = 'world', + } + obj1.self = obj1 + local obj2 = { + 5, + hello = 'everybody', + array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + fn = function() + return 'hello' + end + } + obj2.self = obj2 + return { obj1 = obj1, obj2 } + `) + + const obj = { + obj1: { + hello: 'world', + }, + 1: { + 1: 5, + hello: 'everybody', + array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + // Emulate the lua function + fn: value[1].fn, + }, + } + obj.obj1.self = obj.obj1 + obj[1].self = obj[1] + expect(value).to.deep.eql(obj) + }) + + it('receive lua array with circular references on JS should succeed', async () => { + using state = await getState() + const value = await state.doString(` + obj = { + "hello", + "world" + } + table.insert(obj, obj) + return obj + `) + + const arr = ['hello', 'world'] + arr.push(arr) + expect(value).to.be.eql(arr) + }) + + it('receive JS object with multiple circular references on lua should succeed', async () => { + using state = await getState() + const obj1 = { + hello: 'world', + } + obj1.self = obj1 + const obj2 = { + hello: 'everybody', + } + obj2.self = obj2 + state.set('obj', { obj1, obj2 }) + + await state.doString(` + assert(obj.obj1.self.self.hello == "world") + assert(obj.obj2.self.self.hello == "everybody") + `) + }) + + it('receive JS object with null prototype on lua should succeed', async () => { + using state = await getState() + const obj = Object.create(null) + obj.hello = 'world' + state.set('obj', obj) + + const value = await state.doString(`return obj.hello`) + + expect(value).to.be.equal('world') + }) + + it('inherited properties should not be copied into the lua table', async () => { + using state = await getState({ objects: 'copy' }) + const obj = Object.create({ inherited: 'yes' }) + obj.own = 'mine' + state.set('t', obj) + + const keys = await state.doString(` + local out = {} + for key in pairs(t) do out[#out + 1] = key end + table.sort(out) + return table.concat(out, ',') + `) + + expect(keys).to.be.equal('own') + }) + + it('non enumerable properties should not be copied into the lua table', async () => { + using state = await getState({ objects: 'copy' }) + const obj = { own: 'mine' } + Object.defineProperty(obj, 'hidden', { value: 1, enumerable: false }) + state.set('t', obj) + + expect(await state.doString('return t.hidden == nil')).to.be.true + expect(await state.doString('return t.own')).to.be.equal('mine') + }) + + it('nested arrays and objects should be copied into the lua table', async () => { + using state = await getState({ objects: 'copy' }) + state.set('t', { list: [10, 20, 30], nested: { deep: [{ value: 5 }] } }) + + expect(await state.doString('return #t.list')).to.be.equal(3) + expect(await state.doString('return t.list[1]')).to.be.equal(10) + expect(await state.doString('return t.nested.deep[1].value')).to.be.equal(5) + }) + + it('a lua syntax error should throw on JS', async () => { + using state = await getState() + + await expect(state.doString(`x -`)).to.eventually.be.rejectedWith(/syntax error near/) + }) + + it('call a lua function from JS should succeed', async () => { + using state = await getState() + + await state.doString(`function sum(x, y) return x + y end`) + const sum = state.get('sum') + + expect(sum(10, 50)).to.be.equal(60) + }) + + it('scheduled lua calls should succeed', async () => { + using state = await getState() + state.set('setInterval', setIntervalSafe) + + await state.doString(` + test = "" + done = false + local stop + stop = setInterval(function() + test = test .. "i" + if #test >= 5 then + stop() + done = true + end + end, 1) + `) + + const deadline = Date.now() + 1000 + while (!state.get('done')) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for scheduled lua calls') + } + await setTimeout(5) + } + + expect(state.get('test')).to.be.equal('iiiii') + }) + + it('calling a lua function after close should throw', async () => { + using state = await getState() + + const callback = await state.doString(` + test = 0 + return function() + test = test + 1 + end + `) + state.close() + + expect(() => callback()).to.throw('cannot call a Lua function after its state has been closed') + }) + + it('call lua function from JS passing an array argument should succeed', async () => { + using state = await getState() + + const sum = await state.doString(` + return function(arr) + local sum = 0 + for k, v in ipairs(arr) do + sum = sum + v + end + return sum + end + `) + + expect(sum([10, 50, 25])).to.be.equal(85) + }) + + it('call a global function with multiple returns should succeed', async () => { + using state = await getState() + + await state.doString(` + function f(x,y) + return 1,x,y,"Hello World",{},function() end + end + `) + + const returns = state.call('f', 10, 25) + expect(returns).to.have.length(6) + expect(returns.slice(0, -1)).to.eql([1, 10, 25, 'Hello World', {}]) + expect(returns.at(-1)).to.be.a('function') + }) + + it('get a lua thread should succeed', async () => { + using state = await getState() + + const thread = await state.doString(` + return coroutine.create(function() + print("hey") + end) + `) + + expect(thread).to.be.instanceOf(LuaThread) + expect(thread).to.not.be.equal(0) + }) + + it('a JS error should pause lua execution', async () => { + using state = await getState() + const check = mock.fn() + state.set('check', check) + state.set('throw', () => { + throw new Error('expected error') + }) + + await expect( + state.doString(` + throw() + check() + `), + ).eventually.to.be.rejected + expect(check.mock.calls).to.have.length(0) + }) + + it('catch a JS error with pcall should succeed', async () => { + using state = await getState() + const check = mock.fn() + state.set('check', check) + state.set('throw', () => { + throw new Error('expected error') + }) + + await state.doString(` + local success, err = pcall(throw) + assert(success == false) + assert(tostring(err) == "Error: expected error") + check() + `) + + expect(check.mock.calls).to.have.length(1) + }) + + it('call a JS function in a different thread should succeed', async () => { + using state = await getState() + const sum = mock.fn((x, y) => x + y) + state.set('sum', sum) + + await state.doString(` + coroutine.resume(coroutine.create(function() + sum(10, 20) + end)) + `) + + expect(sum.mock.calls).to.have.length(1) + expect(sum.mock.calls[0].arguments).to.be.eql([10, 20]) + }) + + it('get callable table as function should succeed', async () => { + using state = await getState() + await state.doString(` + _G['sum'] = setmetatable({}, { + __call = function(self, x, y) + return x + y + end + }) + `) + + state.module.lua_getglobal(state.address, 'sum') + const sum = state.getValue(-1, LuaType.Function) + + expect(sum(10, 30)).to.be.equal(40) + }) + + it('lua_resume with yield succeeds', async () => { + using state = await getState() + const thread = state.newThread() + thread.loadString(` + local yieldRes = coroutine.yield(10) + return yieldRes + `) + + const resumeResult = thread.resume(0) + expect(resumeResult.result).to.be.equal(LuaReturn.Yield) + expect(resumeResult.resultCount).to.be.equal(1) + + const yieldValue = thread.getValue(-1) + expect(yieldValue).to.be.equal(10) + + thread.pop(resumeResult.resultCount) + thread.pushValue(yieldValue * 2) + + const finalResumeResult = thread.resume(1) + expect(finalResumeResult.result).to.be.equal(LuaReturn.Ok) + expect(finalResumeResult.resultCount).to.be.equal(1) + + const finalValue = thread.getValue(-1) + expect(finalValue).to.be.equal(20) + }) + + it('get memory with allocation tracing should succeeds', async () => { + using state = await getState({ memory: { trace: true } }) + expect(state.memory.used).to.be.greaterThan(0) + }) + + it('get memory should return correct', async () => { + using state = await getState({ memory: { trace: true } }) + + const totalMemory = await state.doString(` + collectgarbage() + local x = 10 + local batata = { dawdwa = 1 } + return collectgarbage('count') * 1024 + `) + + expect(state.memory.used).to.be.equal(totalMemory) + }) + + it('memory is undefined without tracing', async () => { + using state = await getState() + + expect(state.memory).to.be.undefined + }) + + it('limit memory use causes program loading failure succeeds', async () => { + using state = await getState({ memory: { trace: true } }) + state.memory.max = state.memory.used + expect(() => { + state.loadString(` + local a = 10 + local b = 20 + return a + b + `) + }).to.throw('not enough memory') + + // Remove the limit and retry + state.memory.max = undefined + state.loadString(` + local a = 10 + local b = 20 + return a + b + `) + }) + + it('limit memory use causes program runtime failure succeeds', async () => { + using state = await getState({ memory: { trace: true } }) + state.loadString(` + local tab = {} + for i = 1, 50, 1 do + tab[i] = i + end + `) + state.memory.max = state.memory.used + + await expect(state.run()).to.eventually.be.rejectedWith('not enough memory') + }) + + it('table supported circular dependencies', async () => { + using state = await getState() + + const a = { name: 'a' } + const b = { name: 'b' } + b.a = a + a.b = b + + state.pushValue(a) + const res = state.getValue(-1) + + expect(res.b.a).to.be.eql(res) + }) + + it('wrap a js object (with metatable)', async () => { + using state = await getState() + state.set('TestClass', { + create: (name) => { + return decorate( + { + instance: decorate(new TestClass(name), { as: 'userdata' }), + }, + { + metatable: { + __name: 'js_TestClass', + __index: (self, key) => { + if (key === 'name') { + return self.instance.getName() + } + return null + }, + }, + }, + ) + }, + }) + + const res = await state.doString(` + local instance = TestClass.create("demo name") + return instance.name + `) + expect(res).to.be.equal('demo name') + }) + + it('wrap a js object using proxy', async () => { + using state = await getState() + state.set('TestClass', { + create: (name) => new TestClass(name), + }) + const res = await state.doString(` + local instance = TestClass.create("demo name 2") + return instance:getName() + `) + expect(res).to.be.equal('demo name 2') + }) + + it('wrap a js object using proxy and apply metatable in lua', async () => { + using state = await getState() + state.set('TestClass', { + create: (name) => new TestClass(name), + }) + const res = await state.doString(` + local instance = TestClass.create("demo name 2") + + -- Based in the simple lua classes tutotial + local Wrapped = {} + Wrapped.__index = Wrapped + + function Wrapped:create(name) + local wrapped = {} + wrapped.instance = TestClass.create(name) + setmetatable(wrapped, Wrapped) + return wrapped + end + + function Wrapped:getName() + return "wrapped: "..self.instance:getName() + end + + local wr = Wrapped:create("demo") + return wr:getName() + `) + expect(res).to.be.equal('wrapped: demo') + }) + + it('classes should be a userdata when proxied', async () => { + using state = await getState() + state.set('obj', { TestClass }) + + const testClass = await state.doString(` + return obj.TestClass + `) + + expect(testClass).to.be.equal(TestClass) + }) + + it('timeout blocking lua program', async () => { + using state = await getState() + state.loadString(` + local i = 0 + while true do i = i + 1 end + `) + + await expect(state.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') + }) + + it('the most recently set timeout should be the one that applies', async function () { + this.timeout(30_000) + using state = await getState() + const thread = state.newThread() + + thread.setDeadline(Date.now() + 20) + thread.setDeadline(Date.now() + 20_000) + thread.loadString('local x = 0 for i = 1, 20000000 do x = x + 1 end return x') + + expect((await thread.run(0))[0]).to.be.equal(20000000) + }) + + it('clearing a timeout should disable the hook', async function () { + this.timeout(30_000) + using state = await getState() + const thread = state.newThread() + + thread.setDeadline(Date.now() + 10) + thread.setDeadline(undefined) + thread.loadString('local x = 0 for i = 1, 5000000 do x = x + 1 end return 7') + + expect(thread.getDeadline()).to.be.undefined + expect((await thread.run(0))[0]).to.be.equal(7) + }) + + it('overwrite lib function', async () => { + using state = await getState() + + let output = '' + state.getTable('_G', (index) => { + state.setField(index, 'print', (val) => { + // Not a proper print implementation. + output += `${val}\n` + }) + }) + + await state.doString(` + print("hello") + print("world") + `) + + expect(output).to.be.equal('hello\nworld\n') + }) + + it('inject a userdata with a metatable should succeed', async () => { + using state = await getState() + const obj = decorate( + {}, + { + metatable: { __index: (_, k) => `Hello ${k}!` }, + }, + ) + state.set('obj', obj) + + const res = await state.doString('return obj.World') + + expect(res).to.be.equal('Hello World!') + }) + + it('a userdata should be collected', async () => { + using state = await getState() + const obj = {} + state.set('obj', obj) + const refIndex = state.module.getLastRefIndex() + const oldRef = state.module.getRef(refIndex) + + await state.doString(` + local weaktable = {} + setmetatable(weaktable, { __mode = "v" }) + table.insert(weaktable, obj) + obj = nil + collectgarbage() + assert(next(weaktable) == nil) + `) + + expect(oldRef).to.be.equal(obj) + const newRef = state.module.getRef(refIndex) + expect(newRef).to.be.equal(undefined) + }) + + it('environment variables should be set', async () => { + const lua = await getLua({ env: { TEST: 'true' } }) + const state = lua.createState() + + const testEnvVar = await state.doString(`return os.getenv('TEST')`) + + expect(testEnvVar).to.be.equal('true') + }) + + it('static methods should be callable on classes', async () => { + using state = await getState() + state.set('TestClass', TestClass) + + const testHello = await state.doString(`return TestClass.hello()`) + + expect(testHello).to.be.equal('world') + }) + + it('should be possible to access function properties', async () => { + using state = await getState() + const testFunction = () => undefined + testFunction.hello = 'world' + state.set('TestFunction', decorate(testFunction, { as: 'proxy' })) + + const testHello = await state.doString(`return TestFunction.hello`) + + expect(testHello).to.be.equal('world') + }) + + it('throw error includes stack trace', async () => { + using state = await getState() + try { + await state.doString(` + local function a() + error("function a threw error") + end + local function b() a() end + local function c() b() end + c() + `) + throw new Error('should not be reached') + } catch (err) { + expect(err.message).to.includes('[string "..."]:3: function a threw error') + expect(err.message).to.not.includes('stack traceback:') + expect(err.luaMessage).to.be.equal(err.message) + expect(err.traceback).to.includes('stack traceback:') + expect(err.traceback).to.includes(`[string "..."]:3: in upvalue 'a'`) + expect(err.traceback).to.includes(`[string "..."]:5: in upvalue 'b'`) + expect(err.traceback).to.includes(`[string "..."]:6: in local 'c'`) + expect(err.traceback).to.includes(`[string "..."]:7: in main chunk`) + // Still reachable through the default logging path. + expect(err.stack).to.includes('stack traceback:') + } + }) + + it('should get only the last result on run', async () => { + using state = await getState() + + const a = await state.doString(`return 1`) + const b = await state.doString(`return 3`) + const c = state.doStringSync(`return 2`) + const d = state.doStringSync(`return 5`) + + expect(a).to.be.equal(1) + expect(b).to.be.equal(3) + expect(c).to.be.equal(2) + expect(d).to.be.equal(5) + }) + + it('should get only the return values on call function', async () => { + using state = await getState() + state.set('hello', (name) => `Hello ${name}!`) + + const a = await state.doString(`return 1`) + const b = state.doStringSync(`return 5`) + const values = state.call('hello', 'joao') + + expect(a).to.be.equal(1) + expect(b).to.be.equal(5) + expect(values).to.have.length(1) + expect(values[0]).to.be.equal('Hello joao!') + }) + + it('create a large string variable should succeed', async () => { + using state = await getState() + const str = 'a'.repeat(1000000) + + state.set('str', str) + + const res = await state.doString('return str') + + expect(res).to.be.equal(str) + }) + + it('execute a large string should succeed', async () => { + using state = await getState() + const str = 'a'.repeat(1000000) + + const res = await state.doString(`return [[${str}]]`) + + expect(res).to.be.equal(str) + }) + + it('a large multibyte string should keep its byte length', async function () { + this.timeout(30_000) + using state = await getState() + const str = 'á'.repeat(500000) + + state.set('str', str) + + expect(await state.doString('return #str')).to.be.equal(1000000) + expect(await state.doString('return str')).to.be.equal(str) + }) + + it('a string containing NUL should be pushed with its full length', async () => { + using state = await getState() + state.set('str', 'a\0b') + + expect(await state.doString('return #str')).to.be.equal(3) + }) + + it('a string containing NUL should be retrieved with its full length', async () => { + using state = await getState() + + const res = await state.doString('return "x\\0y"') + + expect(res).to.be.equal('x\0y') + }) + + it('a string containing NUL should round trip unchanged', async () => { + using state = await getState() + const str = 'before\0middle\0after' + state.set('str', str) + + expect(await state.doString('return str')).to.be.equal(str) + }) + + it('an empty string should round trip unchanged', async () => { + using state = await getState() + state.set('str', '') + + expect(await state.doString('return #str')).to.be.equal(0) + expect(await state.doString('return str')).to.be.equal('') + }) + + it('getStringBytes should expose bytes that are not valid UTF-8', async () => { + using state = await getState() + await state.doString('value = string.char(0, 255, 128, 65)') + + state.module.lua_getglobal(state.address, 'value') + const bytes = state.getStringBytes(-1) + state.pop() + + expect(Array.from(bytes)).to.be.eql([0, 255, 128, 65]) + }) + + it('getStringBytes should return undefined for a non string value', async () => { + using state = await getState() + state.pushValue({}) + + expect(state.getStringBytes(-1)).to.be.undefined + + state.pop() + }) + + it('bytecode should round trip through the byte accessors', async () => { + using state = await getState() + await state.doString('bytecode = string.dump(load("return 42"))') + + state.module.lua_getglobal(state.address, 'bytecode') + const bytes = state.getStringBytes(-1) + state.pop() + + state.pushStringBytes(bytes) + state.module.lua_setglobal(state.address, 'roundtripped') + + expect(await state.doString('return load(roundtripped)()')).to.be.equal(42) + }) + + it('negative integers should be pushed and retrieved as string', async () => { + using state = await getState() + state.set('value', -1) + + const res = await state.doString(`return tostring(value)`) + + expect(res).to.be.equal('-1') + }) + + it('negative integers should be pushed and retrieved as number', async () => { + using state = await getState() + state.set('value', -1) + + const res = await state.doString(`return value`) + + expect(res).to.be.equal(-1) + }) + + it('number greater than 32 bit int should be pushed and retrieved as string', async () => { + using state = await getState() + const value = 1689031554550 + state.set('value', value) + + const res = await state.doString(`return tostring(value)`) + + expect(res).to.be.equal(`${String(value)}`) + }) + + it('number greater than 32 bit int should be pushed and retrieved as number', async () => { + using state = await getState() + const value = 1689031554550 + state.set('value', value) + + const res = await state.doString(`return value`) + + expect(res).to.be.equal(value) + }) + + it('number greater than 32 bit int should be usable as a format argument', async () => { + using state = await getState() + const value = 1689031554550 + state.set('value', value) + + const res = await state.doString(`return ("%d"):format(value)`) + + expect(res).to.be.equal('1689031554550') + }) + + it('64-bit integers pushed through the raw Lua API should keep integer semantics', async () => { + using state = await getState() + const value = 9223372036854775807n + + state.module.lua_pushinteger(state.address, value) + state.module.lua_setglobal(state.address, 'value') + + const asString = await state.doString(`return tostring(value)`) + const asFormatted = await state.doString(`return ("%d"):format(value)`) + + state.module.lua_getglobal(state.address, 'value') + const roundTrip = state.module.lua_tointegerx(state.address, -1, null) + state.pop() + + expect(asString).to.be.equal('9223372036854775807') + expect(asFormatted).to.be.equal('9223372036854775807') + expect(roundTrip).to.be.equal(value) + }) + + it('integers outside the JS safe range should be retrieved as bigint', async () => { + using state = await getState() + + expect(await state.doString('return math.maxinteger')).to.be.equal(9223372036854775807n) + expect(await state.doString('return math.mininteger')).to.be.equal(-9223372036854775808n) + }) + + it('integers inside the JS safe range should be retrieved as number', async () => { + using state = await getState() + + const res = await state.doString('return 1689031554550') + + expect(res).to.be.equal(1689031554550) + expect(res).to.be.a('number') + }) + + it('an integral number should be pushed as a lua integer', async () => { + using state = await getState() + state.set('value', 2) + + expect(await state.doString('return math.type(value)')).to.be.equal('integer') + }) + + it('floats should be retrieved as number', async () => { + using state = await getState() + + expect(await state.doString('return 1.5')).to.be.equal(1.5) + expect(await state.doString('return math.type(1.5)')).to.be.equal('float') + }) + + it('a bigint should be pushed as a 64 bit lua integer', async () => { + using state = await getState() + state.set('value', 9223372036854775807n) + + expect(await state.doString('return tostring(value)')).to.be.equal('9223372036854775807') + expect(await state.doString('return math.type(value)')).to.be.equal('integer') + expect(await state.doString('return value')).to.be.equal(9223372036854775807n) + }) + + it('a bigint outside the lua integer range should throw', async () => { + using state = await getState() + + expect(() => state.set('value', 2n ** 70n)).to.throw(RangeError) + }) + + it('an integral number too large for a lua integer should be pushed as a float', async () => { + using state = await getState() + state.set('value', 1e300) + + expect(await state.doString('return math.type(value)')).to.be.equal('float') + expect(await state.doString('return value')).to.be.equal(1e300) + }) + + it('yielding in a JS callback into Lua does not break lua state', async () => { + // When yielding within a callback the error 'attempt to yield across a C-call boundary'. + // This test just checks that throwing that error still allows the lua global to be + // re-used and doesn't cause JS to abort or some nonsense. + using state = await getState() + const testEmitter = new EventEmitter() + state.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) + const resPromise = state.doString(` + local res = yield():next(function () + coroutine.yield() + return 15 + end) + print("res", res:await()) + `) + + testEmitter.emit('resolve') + await expect(resPromise).to.eventually.be.rejectedWith('Error: attempt to yield across a C-call boundary') + + expect(await state.doString(`return 42`)).to.equal(42) + }) + + it('forced yield within JS callback from Lua doesnt cause vm to crash', async () => { + using state = await getState({ limits: { functionTimeout: 10 } }) + state.set('promise', Promise.resolve()) + const thread = state.newThread() + thread.loadString(` + promise:next(function () + while true do + -- nothing + end + end):await() + `) + await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') + + expect(await state.doString(`return 42`)).to.equal(42) + }) + + it('function callback timeout still allows timeout of caller thread', async () => { + using state = await getState() + state.set('promise', Promise.resolve()) + const thread = state.newThread() + thread.loadString(` + promise:next(function () + -- nothing + end):await() + while true do end + `) + await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') + }) + + it('null injected and valid', async () => { + using state = await getState() + state.loadString(` + local args = { ... } + assert(args[1] == null, string.format("expected first argument to be null, got %s", tostring(args[1]))) + return null, args[1], tostring(null) + `) + state.pushValue(null) + const res = await state.run(1) + expect(res).to.deep.equal([null, null, 'null']) + }) + + it('null injected as nil', async () => { + using state = await getState({ inject: false }) + state.loadString(` + local args = { ... } + assert(type(args[1]) == "nil", string.format("expected first argument to be nil, got %s", type(args[1]))) + return nil, args[1], tostring(nil) + `) + state.pushValue(null) + const res = await state.run(1) + expect(res).to.deep.equal([null, null, 'nil']) + }) + + it('reassigning the null global should not affect pushing null', async () => { + using state = await getState() + await state.doString('null = 5') + + state.set('value', null) + + expect(await state.doString('return type(value)')).to.be.equal('userdata') + expect(state.get('value')).to.be.null + }) + + it('a pushed null should equal the injected null global', async () => { + using state = await getState() + state.set('value', null) + + expect(await state.doString('return value == null')).to.be.true + }) + + it('Nested callback from JS to Lua', async () => { + using state = await getState() + state.set('call', (fn) => fn()) + const res = await state.doString(` + return call(function () + return call(function () + return 10 + end) + end) + `) + expect(res).to.equal(10) + }) + + it('lots of doString calls should succeed', async () => { + using state = await getState() + const length = 10000 + + for (let i = 0; i < length; i++) { + const a = Math.floor(Math.random() * 100) + const b = Math.floor(Math.random() * 100) + const result = await state.doString(`return ${a} + ${b};`) + expect(result).to.equal(a + b) + } + }) + + it('lots of doStringSync calls should not grow the lua stack', async function () { + this.timeout(30_000) + using state = await getState() + const startTop = state.getTop() + + for (let i = 0; i < 500; i++) { + expect(state.doStringSync(`return ${i}`)).to.be.equal(i) + } + + expect(state.getTop()).to.be.equal(startTop) + }) + + it('a failing doStringSync should not grow the lua stack', async () => { + using state = await getState() + const startTop = state.getTop() + + expect(() => state.doStringSync('error("boom")')).to.throw('boom') + + expect(state.getTop()).to.be.equal(startTop) + }) + + it('values returned by doStringSync should outlive the stack reset', async () => { + using state = await getState() + + const fn = state.doStringSync('return function() return 3 end') + const table = state.doStringSync('return { a = 1, b = { 2, 3 } }') + + expect(fn()).to.be.equal(3) + expect(table).to.be.eql({ a: 1, b: [2, 3] }) + }) + + it('many concurrent doString calls should succeed', async function () { + this.timeout(15000) + using state = await getState() + const length = 55 + + const promises = [] + for (let i = 0; i < length; i++) { + promises.push(state.doString(`return ${i}`)) + } + const results = await Promise.all(promises) + + for (let i = 0; i < length; i++) { + expect(results[i]).to.equal(i) + } + }) +}) + +describe('Load mode', () => { + it('loadString refuses bytecode by default', async () => { + using state = await getState() + // Produced and reloaded inside Lua so the bytes never round trip through a JS string. + expect( + state.doStringSync(` + local d = string.dump(function() return 42 end) + local f, err = load(d, 'c', 't') + return err + `), + ).to.include('attempt to load a binary chunk') + }) + + it('the host loader refuses a binary chunk by default', async () => { + using state = await getState() + + expect(() => state.loadString('\x1bLua\x55\x00')).to.throw('attempt to load a binary chunk') + }) + + it('mode bt opts back in', async () => { + using state = await getState() + + expect( + state.doStringSync(` + local d = string.dump(function() return 42 end) + return load(d, 'c', 'bt')() + `), + ).to.be.equal(42) + }) +}) + +describe('Decoration', () => { + class Thing { + constructor(name) { + this.name = name + } + getName() { + return this.name + } + } + + it('as userdata hides the members', async () => { + using state = await getState() + state.set('thing', decorate(new Thing('bob'), { as: 'userdata' })) + + // An opaque userdata has no __index of its own, so Lua refuses to index it at all. + await expect(state.doString('return thing.name')).to.eventually.be.rejectedWith('attempt to index a js_userdata value') + }) + + it('as userdata still cannot take a metatable of its own', async () => { + using state = await getState() + + // The userdata extension owns the metatable slot. That is a constraint of the extension, + // not of the decoration API, so a custom metatable has to go on a wrapper. + expect(() => state.set('thing', decorate(new Thing('bob'), { as: 'userdata', metatable: { __name: 'js_thing' } }))).to.throw( + 'data already has associated metatable: js_userdata', + ) + }) + + it('a wrapper carries the metatable around an opaque userdata', async () => { + using state = await getState() + state.set( + 'thing', + decorate( + { inner: decorate(new Thing('bob'), { as: 'userdata' }) }, + { metatable: { __name: 'js_thing', __index: (self, key) => (key === 'name' ? self.inner.getName() : null) } }, + ), + ) + + expect(await state.doString('return thing.name')).to.be.equal('bob') + }) + + it('as value copies an object instead of proxying it', async () => { + using state = await getState() + state.set('plain', decorate({ a: 1 }, { as: 'value' })) + + expect(await state.doString('return type(plain)')).to.be.equal('table') + }) + + it('as proxy forces a function through the proxy layer', async () => { + using state = await getState() + const fn = () => undefined + fn.hello = 'world' + state.set('fn', decorate(fn, { as: 'proxy' })) + + expect(await state.doString('return fn.hello')).to.be.equal('world') + }) +}) + +describe('Memory', () => { + it('memory.max turns tracing on by itself', async () => { + const lua = await getLua() + using state = lua.createState({ memory: { max: 4 * 1024 * 1024 } }) + + expect(state.memory).to.not.be.undefined + expect(state.memory.max).to.be.equal(4 * 1024 * 1024) + expect(state.memory.used).to.be.greaterThan(0) + }) + + it('a max too small to hold a state is reported as such', async () => { + const lua = await getLua() + + expect(() => lua.createState({ memory: { max: 1000 } })).to.throw('memory.max of 1000 bytes') + }) + + it('memory is a live view', async () => { + using state = await getState({ memory: { trace: true } }) + + const before = state.memory.used + await state.doString('big = {} for i = 1, 5000 do big[i] = i end') + + expect(state.memory.used).to.be.greaterThan(before) + }) +}) + +describe('Synchronous runs', () => { + it('do not leak stack onto the state between calls', async () => { + using state = await getState() + const top = state.getTop() + + for (let i = 0; i < 50; i++) { + state.doStringSync('return 1') + } + + expect(state.getTop()).to.be.equal(top) + }) + + it('a hook installed for a sync run does not outlive it', async () => { + using state = await getState() + + state.doStringSync('return 1', { timeout: 1000 }) + + expect(state.getDeadline()).to.be.undefined + }) +}) diff --git a/test/stdio.test.js b/test/stdio.test.js new file mode 100644 index 0000000..f34e000 --- /dev/null +++ b/test/stdio.test.js @@ -0,0 +1,112 @@ +import { expect } from 'chai' +import { LuaRuntime } from '../dist/index.js' + +const collectOutput = async (stream, script) => { + const output = [] + const lua = await LuaRuntime.load({ [stream]: (content) => output.push(content) }) + await lua.createState().doString(script) + return output +} + +// The empty string is what signals EOF, so reads past the end have to return it rather than undefined. +const stdinFrom = (chunks) => { + let index = 0 + return () => chunks[index++] ?? '' +} + +describe('Custom stdout', () => { + it('should receive what the script prints', async () => { + const output = await collectOutput('stdout', 'print("hello from node")') + + expect(output).to.be.deep.equal(['hello from node']) + }) + + it('should keep multi byte characters intact', async () => { + const output = await collectOutput('stdout', 'print("héllo 日本語 🎉")') + + expect(output).to.be.deep.equal(['héllo 日本語 🎉']) + }) + + it('should keep a character that is flushed in the middle intact', async () => { + // The two halves of 日 land in different writes, with the engine handing control back to + // JS in between. + const output = [] + const lua = await LuaRuntime.load({ stdout: (content) => output.push(content) }) + const state = lua.createState() + state.set( + 'pause', + () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }), + ) + + await state.doString('io.write("\\xE6") io.flush() pause():await() io.write("\\x97\\xA5 ok\\n")') + + expect(output).to.be.deep.equal(['日 ok']) + }) + + it('should split on line breaks only', async () => { + const output = await collectOutput('stdout', 'print("first") print("") io.write("a\\rb\\n")') + + expect(output).to.be.deep.equal(['first', '', 'a\rb']) + }) + + it('should receive flushed output that does not end in a line break', async () => { + const output = await collectOutput('stdout', 'io.write("no newline") io.flush()') + + expect(output).to.be.deep.equal(['no newline']) + }) + + it('should not split a line that is written in parts', async () => { + const output = await collectOutput('stdout', 'io.write("one ") io.flush() io.write("line\\n")') + + expect(output).to.be.deep.equal(['one line']) + }) + + it('should handle lines longer than its buffer', async () => { + const output = await collectOutput('stdout', 'print(string.rep("ção ", 1000))') + + expect(output).to.be.deep.equal(['ção '.repeat(1000)]) + }) +}) + +describe('Custom stderr', () => { + it('should receive what the script writes', async () => { + const errors = await collectOutput('stderr', 'io.stderr:write("error output\\n")') + + expect(errors).to.be.deep.equal(['error output']) + }) + + it('should keep multi byte characters intact', async () => { + const errors = await collectOutput('stderr', 'io.stderr:write("erro ✗ 日本\\n")') + + expect(errors).to.be.deep.equal(['erro ✗ 日本']) + }) +}) + +describe('Custom stdin', () => { + it('should be read line by line', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['um\n', 'dois\n']) }) + + const result = await lua.createState().doString('return { io.read("l"), io.read("l"), io.read("l") == nil }') + + expect(result).to.be.deep.equal(['um', 'dois', true]) + }) + + it('should keep multi byte characters intact', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['héllo 日本語 🎉\n']) }) + + const result = await lua.createState().doString('return io.read("l")') + + expect(result).to.be.equal('héllo 日本語 🎉') + }) + + it('should be read until it signals the end of the input', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['abc\n', 'déf\n']) }) + + const result = await lua.createState().doString('return io.read("a")') + + expect(result).to.be.equal('abc\ndéf\n') + }) +}) diff --git a/test/utils.js b/test/utils.js index 0bd5368..7650f16 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,12 +1,16 @@ -import { LuaFactory } from '../dist/index.js' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LuaRuntime } from '../dist/index.js' -export const getFactory = (env) => { - return new LuaFactory(undefined, env) +export const getLua = (options) => { + return LuaRuntime.load(options) } -export const getEngine = (config = {}) => { - return new LuaFactory().createEngine({ - injectObjects: true, +export const getState = async (config = {}) => { + const lua = await LuaRuntime.load() + return lua.createState({ + inject: true, ...config, }) } @@ -15,3 +19,41 @@ export const getEngine = (config = {}) => { export const tick = () => { return new Promise((resolve) => setImmediate(resolve)) } + +/** Windows paths cannot go into a Lua string literal as they are. */ +export const luaPath = (path) => path.replace(/\\/g, '/') + +/** + * Hands out temporary directories and removes every one of them afterwards, so a test that needs a + * second or third one does not have to carry its own try/finally. + */ +export const useTempDirs = (prefix) => { + const created = [] + const createTempDir = () => { + const dir = mkdtempSync(join(tmpdir(), `wasmoon-${prefix}-`)) + created.push(dir) + return dir + } + + afterEach(() => { + for (const dir of created.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + return createTempDir +} + +/** + * Reads a file from inside Lua. The sentinel distinguishes "empty" from "not reachable at all", + * which a bare read cannot. + */ +export const readFileFromLua = (state, path) => { + return state.doStringSync(` + local f = io.open("${luaPath(path)}", "r") + if not f then return "BLOCKED" end + local content = f:read("*a") + f:close() + return content + `) +} diff --git a/tsconfig.json b/tsconfig.json index f911e66..94f684f 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,14 @@ { "compilerOptions": { "incremental": false, - "moduleResolution": "node", + "module": "ES2022", + "moduleResolution": "Bundler", "inlineSources": false, "removeComments": false, "sourceMap": false, - "target": "ES2018", + "target": "ES2024", "skipLibCheck": true, + "types": ["emscripten", "node"], "lib": ["ESNEXT", "DOM"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, @@ -16,9 +18,11 @@ "noUnusedLocals": true, "importHelpers": true, "strict": true, - "resolveJsonModule": true, - + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true }, - "include": ["src/**/*", "test/**/*", "bench/**/*", "eslint.config.js"], + // test/ and bench/ are plain JS, so without allowJs they were only nominally covered. + "include": ["src/**/*"], "exclude": ["node_modules"] } diff --git a/utils/build-wasm.js b/utils/build-wasm.js new file mode 100644 index 0000000..4913605 --- /dev/null +++ b/utils/build-wasm.js @@ -0,0 +1,49 @@ +import { execSync } from 'node:child_process' +import { resolve } from 'node:path' + +const isUnix = process.platform !== 'win32' +const rootdir = resolve(import.meta.dirname, '..') +const args = process.argv.slice(2) + +const execute = (command) => { + console.log(`Running: ${command}`) + try { + execSync(command, { stdio: 'inherit' }) + } catch (error) { + console.error(`Error running command: ${command}`) + process.exit(1) + } +} + +execute('git submodule update --init --recursive') + +if (isUnix) { + let emccInstalled = false + try { + const version = execSync('emcc --version', { encoding: 'utf-8' }) + console.log('Emscripten is installed:', version) + + emccInstalled = true + } catch (error) { + console.error('Emscripten is not installed or not in your PATH. Will try to build using Docker.') + } + + if (emccInstalled) { + const command = `${resolve(rootdir, 'utils/build-wasm.sh')} ${args.join(' ')}` + execute(command) + + process.exit(0) + } +} + +try { + execSync('docker --version', { encoding: 'utf-8' }) +} catch (error) { + console.error('Docker is not installed or not in your PATH. Please install Docker to build the WASM file.') + process.exit(1) +} + +const dockerVolume = `${rootdir}:/wasmoon` +const command = `docker run --rm -v "${dockerVolume}" emscripten/emsdk /wasmoon/utils/build-wasm.sh ${args.join(' ')}` + +execute(command) diff --git a/build.sh b/utils/build-wasm.sh similarity index 60% rename from build.sh rename to utils/build-wasm.sh index 9d54f96..c7d5f98 100755 --- a/build.sh +++ b/utils/build-wasm.sh @@ -1,47 +1,63 @@ #!/bin/bash -e cd $(dirname $0) -mkdir -p build +mkdir -p ../build/host -LUA_SRC=$(ls ./lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") +LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") -extension="" +# Do not add --closure here: it renames properties, which would strip the brand the JS build puts on +# the glue's longjmp unwind classes (see rolldown.config.ts) and turn every unwind into a Lua error. if [ "$1" == "dev" ]; then - extension="-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2" + extension=(-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2) else - extension="-O3" + # TEXTDECODER=1 keeps Emscripten's short string decode path, which -Oz would otherwise drop + # in favour of always calling TextDecoder. LuaModule.readString relies on it. + extension=(-Oz -fno-inline-functions -s TEXTDECODER=1 -s BINARYEN_EXTRA_PASSES=gufa-optimizing) fi -emcc \ - -s WASM=1 $extension -o ./build/glue.js \ +# Everything that is not about the filesystem, shared by both glues below so they can share one +# `glue.wasm`. +COMMON=( + -s WASM=1 + "${extension[@]}" -s EXPORTED_RUNTIME_METHODS="[ - 'ccall', \ 'addFunction', \ 'removeFunction', \ 'FS', \ + 'PATH', \ 'ENV', \ 'getValue', \ 'setValue', \ 'lengthBytesUTF8', \ 'stringToUTF8', \ - 'stringToNewUTF8' - ]" \ + 'stringToNewUTF8', \ + 'stringToUTF8OnStack', \ + 'stackSave', \ + 'stackRestore', \ + 'UTF8ToString', \ + 'HEAPU8', \ + 'HEAPU32' + ]" + -s DEFAULT_LIBRARY_FUNCS_TO_INCLUDE="[ + '\$FS_mkdirTree', \ + '\$PATH', \ + '\$PATH_FS' + ]" -s INCOMING_MODULE_JS_API="[ 'locateFile', \ - 'preRun' - ]" \ - -s ENVIRONMENT="web,worker,node" \ - -s MODULARIZE=1 \ - -s ALLOW_TABLE_GROWTH=1 \ - -s EXPORT_NAME="initWasmModule" \ - -s ALLOW_MEMORY_GROWTH=1 \ - -s STRICT=1 \ - -s EXPORT_ES6=1 \ - -s NODEJS_CATCH_EXIT=0 \ - -s NODEJS_CATCH_REJECTION=0 \ - -s MALLOC=emmalloc \ - -s STACK_SIZE=1MB \ - -s WASM_BIGINT \ + 'preRun', \ + 'print', \ + 'printErr' \ + ]" + -s MODULARIZE=1 + -s ALLOW_TABLE_GROWTH=1 + -s EXPORT_NAME="initWasmModule" + -s ALLOW_MEMORY_GROWTH=1 + -s STRICT=1 + -s EXPORT_ES6=1 + -s MALLOC=emmalloc + -s STACK_SIZE=1MB + -s WASM_BIGINT -s EXPORTED_FUNCTIONS="[ '_malloc', \ '_free', \ @@ -92,7 +108,7 @@ emcc \ '_lua_newstate', \ '_lua_close', \ '_lua_newthread', \ - '_lua_resetthread', \ + '_lua_closethread', \ '_lua_atpanic', \ '_lua_version', \ '_lua_absindex', \ @@ -182,7 +198,6 @@ emcc \ '_lua_gethook', \ '_lua_gethookmask', \ '_lua_gethookcount', \ - '_lua_setcstacklimit', \ '_luaopen_base', \ '_luaopen_coroutine', \ '_luaopen_table', \ @@ -193,6 +208,37 @@ emcc \ '_luaopen_math', \ '_luaopen_debug', \ '_luaopen_package', \ - '_luaL_openlibs' \ - ]" \ + '_luaL_openselectedlibs' \ + ]" +) + +# The default glue, for every environment. Its filesystem is Emscripten's in-memory one, and +# -lnodefs.js adds the NODEFS backend so a host directory can be mounted into it (LuaModuleOptions +# `mounts`). Nothing here reaches the host on its own. +emcc "${COMMON[@]}" \ + -lnodefs.js \ + -s ENVIRONMENT="web,worker,node" \ + -o ../build/glue.js \ ${LUA_SRC} + +# The glue for `filesystem: 'host'`, which Emscripten only supports under Node. NODERAWFS forwards +# every file operation straight to node:fs, so paths, the working directory, symlinks and +# permissions are the host's rather than a mirror of them. NODE_HOST_ENV stays off so the +# environment is still whatever LuaModuleOptions `env` says, as it is in the default glue. +emcc "${COMMON[@]}" \ + -s NODERAWFS=1 \ + -s NODE_HOST_ENV=0 \ + -s ENVIRONMENT="node" \ + -o ../build/host/glue.js \ + ${LUA_SRC} + +# Both glues are the same wasm with a different filesystem bolted on in JS, which is what lets the +# package ship one binary and pick a glue at load time. Checked rather than assumed, because a +# divergence would otherwise surface as a corrupt module for host users only. +if ! cmp -s ../build/glue.wasm ../build/host/glue.wasm; then + echo "build-wasm: the host glue produced a different glue.wasm, so the two cannot share one binary" >&2 + exit 1 +fi +# Dropped so only one copy is published; the host glue resolves 'glue.wasm' next to itself, and the +# bundle puts both of them in dist/ beside the shared binary. +rm ../build/host/glue.wasm diff --git a/utils/create-bindings.js b/utils/create-bindings.cjs old mode 100755 new mode 100644 similarity index 100% rename from utils/create-bindings.js rename to utils/create-bindings.cjs diff --git a/utils/create-bindings.sh b/utils/create-bindings.sh index b47efb3..036c15e 100755 --- a/utils/create-bindings.sh +++ b/utils/create-bindings.sh @@ -1,3 +1,3 @@ #!/bin/sh -e -find ../lua/ -name "*.h" | ./create-bindings.js +find ../lua/ -name "*.h" | ./create-bindings.cjs diff --git a/utils/reference-types.js b/utils/reference-types.js new file mode 100644 index 0000000..4fd1fc1 --- /dev/null +++ b/utils/reference-types.js @@ -0,0 +1,17 @@ +// The public declarations use `@types/emscripten`'s ambient globals (EmscriptenModule, FS, +// Emscripten.*). Those only resolve for a consumer whose own tsconfig happens to list +// "emscripten" in `types`, which nothing about depending on wasmoon makes them do -- so without +// this the published .d.ts fails to compile under, say, `"types": ["node"]`. +// +// A `/// ` in the entry declaration pulls the package in for everyone. +// It has to be added here rather than written in the source, because tsc drops the directive on +// its way through declaration emit. +import { readFile, writeFile } from 'node:fs/promises' + +const ENTRY = 'dist/index.d.ts' +const DIRECTIVE = '/// ' + +const contents = await readFile(ENTRY, 'utf8') +if (!contents.startsWith(DIRECTIVE)) { + await writeFile(ENTRY, `${DIRECTIVE}\n${contents}`) +}