diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e9388be..85de207 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,7 @@ Node-API Conformance Test Suite: A pure ECMAScript test suite for Node-API imple ## General Principles - **Keep it minimal**: Avoid unnecessary dependencies, configuration, or complexity - **Pure ECMAScript tests**: To lower the barrier for implementors to run the tests, all tests are written as single files of pure ECMAScript, with no import / export statements and no use of Node.js runtime APIs outside of the language standard (such as `process`, `require`, `node:*` modules). -- **TypeScript tooling**: The tooling around the tests are written in TypeScript and expects to be ran in a Node.js compatible runtime with type-stripping enabled. +- **TypeScript tooling**: The tooling around the tests are written in TypeScript and expects to be ran in a Node.js compatible runtime with type-stripping enabled. Never rely on `ts-node`, `node --loader ts-node/esm`, or `--experimental-strip-types`; use only stable, built-in Node.js capabilities. - **Implementor Flexibility**: Native code building and loading is delegated to implementors, with the test-suite providing defaults that work for Node.js. - **Extra convenience**: Extra (generated) code is provided to make it easier for implementors to load and run tests, such as extra package exports exposing test functions that implementors can integrate with their preferred test frameworks. - **Process Isolation**: The built-in runner for Node.js, run each test in isolation to prevent crashes from aborting entire test suite. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3a7f774 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test Node.js implementation + +on: [push, pull_request] + +jobs: + test: + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node-version: + - 20.x + - 22.x + - 24.x + - 25.x + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ matrix.node-version }} + - name: Check Node.js installation + run: | + node --version + npm --version + - name: Install dependencies + run: npm ci + - name: npm test + run: npm run node:test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md index 326acdb..a8b60f7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,19 @@ Written in ECMAScript & C/C++ with an implementor customizable harness. ## Overview -The tests are divided into two buckets, based on the two header files declaring the Node-API functions: +### Tests + +The tests are divided into three buckets: + +- `tests/harness/*` exercising the implementor's test harness. + +and two parts based on the two header files declaring the Node-API functions: - `tests/js-native-api/*` testing the engine-specific part of Node-API defined in the [`js_native_api.h`](https://github.com/nodejs/node-api-headers/blob/main/include/js_native_api.h) header. - `tests/node-api/*` testing the runtime-specific part of Node-API defined in the [`node_api.h`](https://github.com/nodejs/node-api-headers/blob/main/include/node_api.h) header. + +### Implementors + +This repository offers an opportunity for implementors of Node-API to maintain (parts of) their implementor-specific harness inside this repository, in a sub-directory of the `implementors` directory. We do this in hope of increased velocity from faster iteration and potentials for reuse of code across the harnesses. + +We maintain a list of other runtimes implementing Node-API in [doc/node-api-engine-bindings.md](https://github.com/nodejs/abi-stable-node/blob/doc/node-api-engine-bindings.md#node-api-bindings-in-other-runtimes) of the `nodejs/abi-stable-node` repository. diff --git a/implementors/node/assert.js b/implementors/node/assert.js new file mode 100644 index 0000000..8a5039f --- /dev/null +++ b/implementors/node/assert.js @@ -0,0 +1,8 @@ + +import { ok } from "node:assert/strict"; + +const assert = (value, message) => { + ok(value, message); +}; + +Object.assign(globalThis, { assert }); diff --git a/implementors/node/run-tests.ts b/implementors/node/run-tests.ts new file mode 100644 index 0000000..a0fa138 --- /dev/null +++ b/implementors/node/run-tests.ts @@ -0,0 +1,104 @@ +import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { test, type TestContext } from "node:test"; + +const ROOT_PATH = path.resolve(import.meta.dirname, "..", ".."); +const TESTS_ROOT_PATH = path.join(ROOT_PATH, "tests"); +const ASSERT_MODULE_PATH = path.join( + ROOT_PATH, + "implementors", + "node", + "assert.js" +); + +async function listDirectoryEntries(dir: string) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const directories: string[] = []; + const files: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + directories.push(entry.name); + } else if (entry.isFile() && entry.name.endsWith(".js")) { + files.push(entry.name); + } + } + + directories.sort(); + files.sort(); + + return { directories, files }; +} + +function runFileInSubprocess(filePath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + "--import", + ASSERT_MODULE_PATH, + filePath, + ]); + + let stderrOutput = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderrOutput += chunk; + }); + + child.stdout.pipe(process.stdout); + + child.on("error", reject); + + child.on("close", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + const reason = + code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`; + const trimmedStderr = stderrOutput.trim(); + const stderrSuffix = trimmedStderr + ? `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` + : ""; + reject( + new Error( + `Test file ${path.relative( + TESTS_ROOT_PATH, + filePath + )} failed (${reason})${stderrSuffix}` + ) + ); + }); + }); +} + +async function populateSuite( + testContext: TestContext, + dir: string +): Promise { + const { directories, files } = await listDirectoryEntries(dir); + + for (const file of files) { + const filePath = path.join(dir, file); + await testContext.test(file, () => runFileInSubprocess(filePath)); + } + + for (const directory of directories) { + await testContext.test(directory, async (subTest) => { + await populateSuite(subTest, path.join(dir, directory)); + }); + } +} + +test("harness", async (t) => { + await populateSuite(t, path.join(TESTS_ROOT_PATH, "harness")); +}); + +test("js-native-api", async (t) => { + await populateSuite(t, path.join(TESTS_ROOT_PATH, "js-native-api")); +}); + +test("node-api", async (t) => { + await populateSuite(t, path.join(TESTS_ROOT_PATH, "node-api")); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dddcb54 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "node-api-cts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-api-cts", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.10.1" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c2483a1 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "node-api-cts", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "node:test": "node --test ./implementors/node/run-tests.ts" + }, + "devDependencies": { + "@types/node": "^24.10.1" + } +} diff --git a/tests/harness/assert.js b/tests/harness/assert.js new file mode 100644 index 0000000..47b89f3 --- /dev/null +++ b/tests/harness/assert.js @@ -0,0 +1,33 @@ +if (typeof assert !== 'function') { + throw new Error('Expected a global assert function'); +} + +try { + assert(true, 'assert(true, message) should not throw'); +} catch (error) { + throw new Error(`Global assert(true, message) must not throw: ${String(error)}`); +} + +const failureMessage = 'assert(false, message) should throw this message'; +let threw = false; + +try { + assert(false, failureMessage); +} catch (error) { + threw = true; + + if (!(error instanceof Error)) { + throw new Error(`Global assert(false, message) must throw an Error instance but got: ${String(error)}`); + } + + const actualMessage = error.message; + if (actualMessage !== failureMessage) { + throw new Error( + `Global assert(false, message) must throw message "${failureMessage}" but got "${actualMessage}"`, + ); + } +} + +if (!threw) { + throw new Error('Global assert(false, message) must throw'); +} diff --git a/tests/js-native-api/tumbleweed b/tests/js-native-api/tumbleweed new file mode 100644 index 0000000..e69de29 diff --git a/tests/node-api/tumbleweed b/tests/node-api/tumbleweed new file mode 100644 index 0000000..e69de29