diff --git a/e2e/runtime-register/fixtures-native/native-type-module.test.ts b/e2e/runtime-register/fixtures-native/native-type-module.test.ts index ec8e07adf..27f44707f 100644 --- a/e2e/runtime-register/fixtures-native/native-type-module.test.ts +++ b/e2e/runtime-register/fixtures-native/native-type-module.test.ts @@ -43,7 +43,13 @@ const createFixture = (extension: 'ts' | 'cts') => { return fixtureDir; }; -test('keeps native node semantics for cjs-style .ts in type module scope', async ({ +// Previously this asserted the native failure (`module is not defined`) for a +// cjs-style `.ts` in a `type: module` scope. `runtimeTsTransform` (default on, +// Node >= 22.22.3 / >= 24.11.1) now transforms exactly that mismatch, so the +// load succeeds. The scope here is a tmpdir outside the project root, which +// `e2e/runtimeTsTransform/` does not cover. See `e2e/runtimeTsTransform/` for +// the feature's own coverage, including the opt-out repro. +test('loads cjs-style .ts in a type module scope via runtimeTsTransform', async ({ onTestFinished, }) => { if (!(await supportsNativeTypeScript())) { @@ -57,15 +63,7 @@ test('keeps native node semantics for cjs-style .ts in type module scope', async pathToFileURL(join(fixtureDir, 'loader.mjs')).href, ); - try { - expect(require('./plugin.ts')).toEqual({}); - } catch (error) { - if (!(error instanceof ReferenceError)) { - throw error; - } - - expect(error.message).toContain('module is not defined'); - } + expect(require('./plugin.ts')).toEqual({ value: 1 }); }); test('loads cjs-style TypeScript when the runtime file uses .cts', async ({ diff --git a/e2e/runtime-register/index.test.ts b/e2e/runtime-register/index.test.ts index 2d2383ebd..c649765ca 100644 --- a/e2e/runtime-register/index.test.ts +++ b/e2e/runtime-register/index.test.ts @@ -34,7 +34,7 @@ describe('runtime node register behavior', () => { await runFixture(registerFixtureDir, onTestFinished); }); - it('should preserve native node semantics for late-loaded TypeScript files', async ({ + it('should handle late-loaded TypeScript files outside the project root', async ({ onTestFinished, }) => { await runFixture(nativeFixtureDir, onTestFinished); diff --git a/e2e/runtimeTsTransform/fixtures-cjs-scope/esmPlugin.ts b/e2e/runtimeTsTransform/fixtures-cjs-scope/esmPlugin.ts new file mode 100644 index 000000000..81ea91ca8 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-cjs-scope/esmPlugin.ts @@ -0,0 +1,2 @@ +// The mirror case: ESM-style TypeScript inside a `"type": "commonjs"` scope. +export const name: string = 'esm-plugin'; diff --git a/e2e/runtimeTsTransform/fixtures-cjs-scope/index.test.ts b/e2e/runtimeTsTransform/fixtures-cjs-scope/index.test.ts new file mode 100644 index 000000000..4d579f7c3 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-cjs-scope/index.test.ts @@ -0,0 +1,8 @@ +import { createRequire } from 'node:module'; +import { expect, test } from '@rstest/core'; + +const require = createRequire(import.meta.url); + +test('createRequire loads an esm-style .ts at runtime in a type commonjs scope', () => { + expect(require('./esmPlugin.ts').name).toBe('esm-plugin'); +}); diff --git a/e2e/runtimeTsTransform/fixtures-cjs-scope/package.json b/e2e/runtimeTsTransform/fixtures-cjs-scope/package.json new file mode 100644 index 000000000..273d8751f --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-cjs-scope/package.json @@ -0,0 +1,5 @@ +{ + "name": "e2e-runtime-ts-transform-commonjs-scope", + "private": true, + "type": "commonjs" +} diff --git a/e2e/runtimeTsTransform/fixtures-cjs-scope/rstest.config.ts b/e2e/runtimeTsTransform/fixtures-cjs-scope/rstest.config.ts new file mode 100644 index 000000000..1244782f1 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-cjs-scope/rstest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['*.test.ts'], + pool: { + maxWorkers: 1, + }, +}); diff --git a/e2e/runtimeTsTransform/fixtures-coexist/index.test.ts b/e2e/runtimeTsTransform/fixtures-coexist/index.test.ts new file mode 100644 index 000000000..a2800aec4 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-coexist/index.test.ts @@ -0,0 +1,14 @@ +import { createRequire } from 'node:module'; +import { expect, test } from '@rstest/core'; + +const require = createRequire(import.meta.url); + +// Verified on Node v22.22.3: once a third party assigns `Module._extensions['.ts']`, +// Node's CJS loader gives it precedence and rstest's sync load hook never fires +// on the `require()` path at all. The third-party loader wins outright. +test('a third-party .ts extension keeps ownership of the require path', () => { + const plugin = require('./plugin.ts'); + + expect(plugin.__loadedBy).toBe('third-party-ts-extension'); + expect(plugin.name).toBe('cjs-plugin'); +}); diff --git a/e2e/runtimeTsTransform/fixtures-coexist/package.json b/e2e/runtimeTsTransform/fixtures-coexist/package.json new file mode 100644 index 000000000..d796774bc --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-coexist/package.json @@ -0,0 +1,5 @@ +{ + "name": "e2e-runtime-ts-transform-coexist", + "private": true, + "type": "module" +} diff --git a/e2e/runtimeTsTransform/fixtures-coexist/plugin.ts b/e2e/runtimeTsTransform/fixtures-coexist/plugin.ts new file mode 100644 index 000000000..1125bfeea --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-coexist/plugin.ts @@ -0,0 +1,4 @@ +// No type annotations: the third-party loader under test compiles sources +// verbatim (like `e2e/runtime-register/fixtures/cjs-register.cjs`) and does not +// strip types. +module.exports = { name: 'cjs-plugin' }; diff --git a/e2e/runtimeTsTransform/fixtures-coexist/rstest.config.ts b/e2e/runtimeTsTransform/fixtures-coexist/rstest.config.ts new file mode 100644 index 000000000..535b581ad --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-coexist/rstest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['*.test.ts'], + setupFiles: ['./setupTsExtension.ts'], + pool: { + maxWorkers: 1, + }, +}); diff --git a/e2e/runtimeTsTransform/fixtures-coexist/setupTsExtension.ts b/e2e/runtimeTsTransform/fixtures-coexist/setupTsExtension.ts new file mode 100644 index 000000000..470a99381 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-coexist/setupTsExtension.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +/** + * Stands in for a third-party TS loader (ts-node / tsx / @swc-node), which is + * what the docs told users to register before `runtimeTsTransform` existed. + * `require.extensions` IS `Module._extensions` — the same object rstest + * snapshots at hook registration time. + * + * The wrapper stamps `__loadedBy` onto whatever it compiles, so a test can prove + * the compile call reached THIS loader rather than rstest's load hook. + */ +createRequire(import.meta.url).extensions['.ts'] = (mod, filename) => { + const source = readFileSync(filename, 'utf-8'); + (mod as unknown as { _compile: (c: string, f: string) => void })._compile( + `${source}\nmodule.exports.__loadedBy = 'third-party-ts-extension';\n`, + filename, + ); +}; diff --git a/e2e/runtimeTsTransform/fixtures-opt-out/index.test.ts b/e2e/runtimeTsTransform/fixtures-opt-out/index.test.ts new file mode 100644 index 000000000..f07e44f1c --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-opt-out/index.test.ts @@ -0,0 +1,11 @@ +import { createRequire } from 'node:module'; +import { expect, test } from '@rstest/core'; + +const require = createRequire(import.meta.url); + +// With `runtimeTsTransform: false` the hook never registers, so this hits the +// native failure the feature exists to fix. The run is expected to FAIL — that +// is what proves the fixture is a real repro and that the flag gates the hook. +test('fails natively when runtimeTsTransform is disabled', () => { + expect(require('./plugin.ts')).toEqual({ name: 'cjs-plugin' }); +}); diff --git a/e2e/runtimeTsTransform/fixtures-opt-out/package.json b/e2e/runtimeTsTransform/fixtures-opt-out/package.json new file mode 100644 index 000000000..690061e8d --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-opt-out/package.json @@ -0,0 +1,5 @@ +{ + "name": "e2e-runtime-ts-transform-opt-out", + "private": true, + "type": "module" +} diff --git a/e2e/runtimeTsTransform/fixtures-opt-out/plugin.ts b/e2e/runtimeTsTransform/fixtures-opt-out/plugin.ts new file mode 100644 index 000000000..f8ee5c932 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-opt-out/plugin.ts @@ -0,0 +1,6 @@ +// This file has no top-level `import`/`export`, so TypeScript treats it as a +// global script: the name must not collide with a `lib.dom` global (`name`) nor +// with the other fixtures in this e2e tsconfig program. +const optOutPluginName: string = 'cjs-plugin'; + +module.exports = { name: optOutPluginName }; diff --git a/e2e/runtimeTsTransform/fixtures-opt-out/rstest.config.ts b/e2e/runtimeTsTransform/fixtures-opt-out/rstest.config.ts new file mode 100644 index 000000000..68eb6e313 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures-opt-out/rstest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['*.test.ts'], + runtimeTsTransform: false, + pool: { + maxWorkers: 1, + }, +}); diff --git a/e2e/runtimeTsTransform/fixtures/index.test.ts b/e2e/runtimeTsTransform/fixtures/index.test.ts new file mode 100644 index 000000000..3fd92df54 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures/index.test.ts @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from '@rstest/core'; + +const require = createRequire(import.meta.url); + +test('createRequire loads a cjs-style .ts at runtime in a type module scope', () => { + expect(require('./plugin.ts')).toEqual({ name: 'cjs-plugin' }); +}); + +test('dynamic import of a cjs-style .ts at runtime in a type module scope', async () => { + // The path lives in a variable so Rspack cannot bundle it: the import falls + // through to a native `import()` and reaches the load hook. + const pluginPath = pathToFileURL(join(import.meta.dirname, 'plugin.ts')).href; + const mod = await import(pluginPath); + + expect((mod.default ?? mod).name).toBe('cjs-plugin'); +}); diff --git a/e2e/runtimeTsTransform/fixtures/package.json b/e2e/runtimeTsTransform/fixtures/package.json new file mode 100644 index 000000000..57c59bc93 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures/package.json @@ -0,0 +1,5 @@ +{ + "name": "e2e-runtime-ts-transform-module-scope", + "private": true, + "type": "module" +} diff --git a/e2e/runtimeTsTransform/fixtures/plugin.ts b/e2e/runtimeTsTransform/fixtures/plugin.ts new file mode 100644 index 000000000..e19a7d084 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures/plugin.ts @@ -0,0 +1,9 @@ +// CJS-style TypeScript inside a `"type": "module"` scope: Node's type stripping +// erases the annotation but never converts the module system, so natively this +// throws `ReferenceError: module is not defined`. +// This file has no top-level `import`/`export`, so TypeScript treats it as a +// global script: the name must not collide with a `lib.dom` global (`name`) nor +// with the other fixtures in this e2e tsconfig program. +const cjsPluginName: string = 'cjs-plugin'; + +module.exports = { name: cjsPluginName }; diff --git a/e2e/runtimeTsTransform/fixtures/rstest.config.ts b/e2e/runtimeTsTransform/fixtures/rstest.config.ts new file mode 100644 index 000000000..1244782f1 --- /dev/null +++ b/e2e/runtimeTsTransform/fixtures/rstest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['*.test.ts'], + pool: { + maxWorkers: 1, + }, +}); diff --git a/e2e/runtimeTsTransform/index.test.ts b/e2e/runtimeTsTransform/index.test.ts new file mode 100644 index 000000000..47e85ddc7 --- /dev/null +++ b/e2e/runtimeTsTransform/index.test.ts @@ -0,0 +1,109 @@ +import Module from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { onTestFinished as onRstestFinished } from '@rstest/core'; +import { describe, it } from '@rstest/core'; +import { runRstestCli } from '../scripts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Re-derived from `process.versions.node` rather than imported from + * `@rstest/core` internals — the fixtures run in a child process on this same + * Node, so the predicate must match `supportsRuntimeTsHook()` in + * `packages/core/src/runtime/worker/runtimeTsHook.ts`. + * + * Sync-hook CJS loading was only made reentrancy-safe by + * https://github.com/nodejs/node/pull/59929 (v22.22.3 / v24.11.1 / v25.1.0 / + * v26.0.0). Below those versions the feature stays inactive by design, so these + * tests have nothing to assert. + */ +const supportsRuntimeTsHook = (): boolean => { + if (typeof Module.registerHooks !== 'function') { + return false; + } + + const [major = 0, minor = 0, patch = 0] = process.versions.node + .split('.') + .map(Number); + const atLeast = (targetMinor: number, targetPatch: number) => + minor > targetMinor || (minor === targetMinor && patch >= targetPatch); + + if (major >= 26) return true; + if (major === 25) return atLeast(1, 0); + if (major === 24) return atLeast(11, 1); + if (major === 22) return atLeast(22, 3); + return false; +}; + +const runFixture = async ( + fixture: string, + onTestFinished: typeof onRstestFinished, +) => + runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + // This test spawns nested `rstest` runs. In the e2e `test:no-isolate` + // step we set `ISOLATE=false`, which would be inherited by the child + // process and make the nested run non-isolated as well (flaky on CI). + env: { ISOLATE: undefined }, + cwd: join(__dirname, fixture), + }, + }, + }); + +describe.skipIf(!supportsRuntimeTsHook())( + `runtimeTsTransform (requires Node >= 22.22.3 / >= 24.11.1, current: ${process.versions.node})`, + () => { + it('should load a cjs-style .ts at runtime in a type module scope', async ({ + onTestFinished, + }) => { + const { expectExecSuccess } = await runFixture( + 'fixtures', + onTestFinished, + ); + + await expectExecSuccess(); + }); + + it('should fail natively when runtimeTsTransform is disabled', async ({ + onTestFinished, + }) => { + const { expectExecFailed, expectStderrLog } = await runFixture( + 'fixtures-opt-out', + onTestFinished, + ); + + await expectExecFailed(); + + // `expectStderrLog` matches per line, so keep this single-line. + expectStderrLog(/module is not defined/); + }); + + it('should load an esm-style .ts at runtime in a type commonjs scope', async ({ + onTestFinished, + }) => { + const { expectExecSuccess } = await runFixture( + 'fixtures-cjs-scope', + onTestFinished, + ); + + await expectExecSuccess(); + }); + + it('should let a third-party .ts extension keep ownership of the require path', async ({ + onTestFinished, + }) => { + const { expectExecSuccess } = await runFixture( + 'fixtures-coexist', + onTestFinished, + ); + + await expectExecSuccess(); + }); + }, +); diff --git a/packages/browser/src/configValidation.ts b/packages/browser/src/configValidation.ts index 5597b2339..e3c171db8 100644 --- a/packages/browser/src/configValidation.ts +++ b/packages/browser/src/configValidation.ts @@ -55,6 +55,13 @@ const ignoredKeyWarnings: Partial< isNonDefault: (config) => config.logHeapUsage === true, message: () => 'Ignoring logHeapUsage in browser mode.', }, + runtimeTsTransform: { + // Defaults to `true`, so opting OUT is the non-default value to warn about. + isNonDefault: (config) => config.runtimeTsTransform === false, + message: () => + 'Ignoring runtimeTsTransform: false in browser mode: it relies on the ' + + 'node module loader hooks.', + }, }; /** diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7c98a2a6f..3a515d7ea 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -288,6 +288,7 @@ const createDefaultConfig = (): NormalizedConfig => ({ detectAsyncLeaks: false, bail: 0, includeTaskLocation: false, + runtimeTsTransform: true, browser: { enabled: false, provider: 'playwright', diff --git a/packages/core/src/core/browserGlobalSetup.ts b/packages/core/src/core/browserGlobalSetup.ts index 25db63139..ce30137a5 100644 --- a/packages/core/src/core/browserGlobalSetup.ts +++ b/packages/core/src/core/browserGlobalSetup.ts @@ -207,6 +207,9 @@ export async function runBrowserGlobalSetupStage( sourceMaps: item.sourceMaps, interopDefault: true, outputModule: item.project.outputModule, + // The globalSetup fork is a node process even for browser projects, so + // the hook applies here regardless of the browser wire's `stripped` row. + runtimeTsTransform: item.project.normalizedConfig.runtimeTsTransform, }); if (success) { ranAnySetup = true; diff --git a/packages/core/src/core/executorCapabilities.ts b/packages/core/src/core/executorCapabilities.ts index ddb80cfa6..e47ef9cbd 100644 --- a/packages/core/src/core/executorCapabilities.ts +++ b/packages/core/src/core/executorCapabilities.ts @@ -58,6 +58,8 @@ export const executorCapabilities: Record< chaiConfig: { node: 'supported', browser: 'supported' }, includeTaskLocation: { node: 'supported', browser: 'supported' }, silent: { node: 'supported', browser: 'supported' }, + // Node module loader mechanism (`module.registerHooks`). + runtimeTsTransform: { node: 'supported', browser: 'stripped' }, }; const runtimeConfigKeys = Object.keys( diff --git a/packages/core/src/core/executors/nodeExecutor.ts b/packages/core/src/core/executors/nodeExecutor.ts index 767a38929..500428e2f 100644 --- a/packages/core/src/core/executors/nodeExecutor.ts +++ b/packages/core/src/core/executors/nodeExecutor.ts @@ -367,6 +367,7 @@ export function createNodeExecutor( sourceMaps, interopDefault: true, outputModule: p.outputModule, + runtimeTsTransform: p.normalizedConfig.runtimeTsTransform, }), globalSetupTraceArgs, ); diff --git a/packages/core/src/core/globalSetup.ts b/packages/core/src/core/globalSetup.ts index 4824f8a3c..514273afa 100644 --- a/packages/core/src/core/globalSetup.ts +++ b/packages/core/src/core/globalSetup.ts @@ -191,12 +191,14 @@ export async function runGlobalSetup({ sourceMaps, interopDefault, outputModule, + runtimeTsTransform, }: { globalSetupEntries: EntryInfo[]; assetFiles: Record; sourceMaps: Record; interopDefault: boolean; outputModule: boolean; + runtimeTsTransform: boolean; }): Promise<{ success: boolean; errors?: any[]; @@ -222,6 +224,7 @@ export async function runGlobalSetup({ interopDefault, outputModule, sourceMaps, + runtimeTsTransform, }, }); diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index cb45c8101..a86058962 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -234,6 +234,7 @@ const collectNodeTests = async ({ sourceMaps, interopDefault: true, outputModule: project.outputModule, + runtimeTsTransform: project.normalizedConfig.runtimeTsTransform, }); if (!success) { return { diff --git a/packages/core/src/core/runtimeConfigProjection.ts b/packages/core/src/core/runtimeConfigProjection.ts index 273719a14..235c425bd 100644 --- a/packages/core/src/core/runtimeConfigProjection.ts +++ b/packages/core/src/core/runtimeConfigProjection.ts @@ -74,6 +74,7 @@ export function projectRuntimeConfig( chaiConfig, includeTaskLocation, silent, + runtimeTsTransform, } = project.normalizedConfig; const shared = { @@ -128,6 +129,7 @@ export function projectRuntimeConfig( coverage: { ...coverage, reporters: [] }, logHeapUsage, detectAsyncLeaks, + runtimeTsTransform, env: { // Read env at projection time so a globalSetup-modified `process.env` // (or an explicit snapshot) is captured correctly. diff --git a/packages/core/src/runtime/worker/globalSetupWorker.ts b/packages/core/src/runtime/worker/globalSetupWorker.ts index 51e60afea..37ea0bdd7 100644 --- a/packages/core/src/runtime/worker/globalSetupWorker.ts +++ b/packages/core/src/runtime/worker/globalSetupWorker.ts @@ -2,6 +2,7 @@ import { install } from 'source-map-support'; import type { FormattedError } from '../../types'; import { color } from '../../utils/logger'; import { formatTestError } from '../util'; +import { ensureRuntimeTsHook } from './runtimeTsHook'; import { installGracefulExit } from './setup'; installGracefulExit(); @@ -46,6 +47,7 @@ const runGlobalSetup = async (data: { sourceMaps: Record; interopDefault: boolean; outputModule: boolean; + runtimeTsTransform: boolean; }): Promise<{ success: boolean; hasTeardown: boolean; @@ -57,6 +59,7 @@ const runGlobalSetup = async (data: { if (data.entries.length === 0) { return { success: true, hasTeardown: false }; } + ensureRuntimeTsHook(data.runtimeTsTransform); // provides source map support for stack traces install({ environment: 'node', diff --git a/packages/core/src/runtime/worker/runInPool.ts b/packages/core/src/runtime/worker/runInPool.ts index 3942c4e03..3aff10e09 100644 --- a/packages/core/src/runtime/worker/runInPool.ts +++ b/packages/core/src/runtime/worker/runInPool.ts @@ -17,6 +17,7 @@ import { createAsyncLeakDetector } from './asyncLeaks'; import { environmentLoaders } from './env/registry'; import { PhaseTracker } from './phaseTracker'; import { createRuntimeRpc, createWorkerRpcOptions } from './rpc'; +import { ensureRuntimeTsHook } from './runtimeTsHook'; import { createSilentConsoleController } from './silentConsole'; import { RstestSnapshotEnvironment } from './snapshot'; import { createNodeTaskContext } from './taskContext.node'; @@ -166,10 +167,12 @@ const preparePool = async ( testEnvironment, snapshotFormat, env, + runtimeTsTransform, }, } = context; setupEnv(env); + ensureRuntimeTsHook(runtimeTsTransform); const shouldInterceptConsole = !disableConsoleIntercept || silent === true || silent === 'passed-only'; diff --git a/packages/core/src/runtime/worker/runtimeTsHook.ts b/packages/core/src/runtime/worker/runtimeTsHook.ts new file mode 100644 index 000000000..3e30e310c --- /dev/null +++ b/packages/core/src/runtime/worker/runtimeTsHook.ts @@ -0,0 +1,230 @@ +import Module, { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { getNodeVersion, type NodeVersion } from '../../utils/helper'; + +/** + * Node's own type stripping erases types but never converts module systems: in + * a `type: module` scope a `.ts` file is ESM, period (and the mirror holds for + * `type: commonjs`). Modules loaded at runtime — outside the bundle graph, e.g. + * via a user-level `createRequire` — bypass rstest's per-module vm loaders, so + * only a process-global `module.registerHooks` load hook can reach them. + * + * This hook intervenes on mismatches ONLY; every other file keeps Node-native + * semantics. + */ + +type SwcModuleType = 'commonjs' | 'es6'; + +type SwcApi = { + transformSync: ( + source: string, + options: { + filename: string; + module: { type: SwcModuleType }; + jsc: { + parser: { syntax: 'typescript'; tsx: boolean }; + target: string; + }; + sourceMaps: boolean; + }, + ) => { code: string; map?: string }; +}; + +const requireFromCore = createRequire(import.meta.url); + +/** + * Comment stripping is deliberately crude — see the safety argument on + * {@link looksLikeCjs}. + */ +const stripComments = (source: string): string => + source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + +// Top-level ESM syntax. `import(` (dynamic import, legal in CJS) and +// `import.meta` must NOT match — hence the char classes after the keyword. +const ESM_SYNTAX_RE = /^[ \t]*(?:import[\s"']|export[\s{*])/m; +const CJS_MARKER_RE = /\b(?:module\.exports|exports\s*[.[]|require\s*\()/; + +/** + * Safety argument for both sniffs: a false negative means we don't transform, + * so the file fails natively exactly as it does today (no regression). The + * regexes are deliberately conservative so false positives — transforming a + * file that would have worked natively — are practically impossible: the + * module→commonjs case additionally requires CJS markers, which throw in native + * ESM anyway; the commonjs→module case requires ESM declarations, which throw + * in native CJS anyway. + */ +export const looksLikeCjs = (source: string): boolean => { + const stripped = stripComments(source); + return !ESM_SYNTAX_RE.test(stripped) && CJS_MARKER_RE.test(stripped); +}; + +export const looksLikeEsm = (source: string): boolean => { + const stripped = stripComments(source); + return ESM_SYNTAX_RE.test(stripped) && !CJS_MARKER_RE.test(stripped); +}; + +// Every comparison against `NaN` (pre-release tags such as `23.0.0-nightly`) +// is false, closing the gate — the desired failure mode. +const atLeastInMajor = ( + { minor, patch }: NodeVersion, + targetMinor: number, + targetPatch: number, +): boolean => + minor > targetMinor || (minor === targetMinor && patch >= targetPatch); + +/** + * Sync-hook CJS loading was only made reentrancy-safe by + * https://github.com/nodejs/node/pull/59929, shipped in v22.22.3, v24.11.1, + * v25.1.0 and v26.0.0. Below those versions the feature stays inactive and the + * documented manual workaround (`ts-node`/`@swc-node` registration) applies. + * + * Exported so unit tests can drive the version matrix without stubbing + * `process.versions`. + */ +export const isRuntimeTsHookSupportedVersion = ( + version: NodeVersion, +): boolean => { + const { major } = version; + if (major >= 26) return true; + if (major === 25) return atLeastInMajor(version, 1, 0); + if (major === 24) return atLeastInMajor(version, 11, 1); + if (major === 22) return atLeastInMajor(version, 22, 3); + return false; +}; + +export const supportsRuntimeTsHook = (): boolean => + typeof Module.registerHooks === 'function' && + isRuntimeTsHookSupportedVersion(getNodeVersion()); + +let swc: SwcApi | undefined; + +/** + * SWC ships inside Rspack, reached through the `rspack` object that + * `@rsbuild/core` re-exports — the same access path core already uses for + * `rspack.experiments` (see `getSetupFiles.ts` / `plugins/basic.ts`). Requiring + * it dlopens a ~39 MB native binding (~60 ms), so this must only ever run from + * inside the load hook on an actual mismatch hit — never at registration. + */ +const loadSwc = (url: string): SwcApi => { + if (swc) return swc; + try { + const { rspack }: { rspack: { experiments: { swc: SwcApi } } } = + requireFromCore('@rsbuild/core'); + swc = rspack.experiments.swc; + return swc; + } catch (error) { + throw new Error( + `Failed to load SWC from @rsbuild/core to transform ${url}. ` + + 'This file is TypeScript loaded at runtime whose module style ' + + 'mismatches its package `type` scope, so rstest needs SWC to ' + + 'transform it. Set `runtimeTsTransform: false` to opt out, or make ' + + 'the file natively compatible (e.g. rename it to `.cts` / `.mts`).', + { cause: error }, + ); + } +}; + +const textDecoder = new TextDecoder(); + +const decodeSource = ( + source: string | ArrayBuffer | NodeJS.TypedArray, +): string => (typeof source === 'string' ? source : textDecoder.decode(source)); + +const transformTs = ( + source: string, + url: string, + moduleType: SwcModuleType, +): string => { + const { code, map } = loadSwc(url).transformSync(source, { + filename: fileURLToPath(url), + module: { type: moduleType }, + jsc: { + parser: { syntax: 'typescript', tsx: false }, + target: 'es2022', + }, + sourceMaps: true, + }); + if (!map) return code; + // Verified on Node v22.22.3: `sourceMaps: 'inline'` is NOT supported by + // `rspack.experiments.swc.transformSync` (it emits no inline comment), so the + // returned map — a JSON string — is appended manually. + const inlineMap = Buffer.from(map, 'utf8').toString('base64'); + return `${code}\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`; +}; + +let hookEnabled = false; +let registered = false; +/** + * Identity of the `.ts` CJS extension handler at registration time (`undefined` + * on stock Node). `require.extensions` IS `Module._extensions` — the same + * object — so this snapshot detects a third-party TS loader (ts-node, tsx, + * @swc-node) taking over `.ts` later. + */ +let baselineTsExtension: unknown; + +const getTsExtension = (): unknown => requireFromCore.extensions['.ts']; + +/** + * Registers the runtime TypeScript load hook once per process (registerHooks is + * per-thread, which is what both the forks and threads pools want). `enabled` + * is refreshed on every call so worker reuse across projects with different + * settings behaves correctly. + */ +export const ensureRuntimeTsHook = (enabled: boolean): void => { + hookEnabled = enabled; + if (!enabled || registered) return; + if (!supportsRuntimeTsHook()) return; + + baselineTsExtension = getTsExtension(); + registered = true; + + // `shortCircuit` is accepted by the sync-hook API but has no effect + // (verified on Node v22.22.3), so it is omitted. + Module.registerHooks({ + load: (url, context, nextLoad) => { + if (!hookEnabled) return nextLoad(url, context); + + if (!url.startsWith('file://')) return nextLoad(url, context); + const fileUrl = url.replace(/[?#].*$/, ''); + if (!fileUrl.endsWith('.ts') || fileUrl.includes('/node_modules/')) { + return nextLoad(url, context); + } + + // A third-party TS loader owns `.ts` now — pass through so it keeps + // working. Verified on Node v22.22.3: when `Module._extensions['.ts']` is + // assigned, Node's CJS loader gives it precedence and this hook never + // fires on the `require()` path at all; on the `import()` path it fires + // and this check hands back the native result. + if (getTsExtension() !== baselineTsExtension) { + return nextLoad(url, context); + } + + const result = nextLoad(url, context); + const { format } = result; + // Only the two mismatch formats can be rewritten; anything already + // resolved (`commonjs`/`module`, JSON, …) passes through without paying + // for the source decode. + if (format !== 'module-typescript' && format !== 'commonjs-typescript') { + return result; + } + if (result.source === undefined) return result; + const source = decodeSource(result.source); + + if (format === 'module-typescript' && looksLikeCjs(source)) { + return { + format: 'commonjs', + source: transformTs(source, fileUrl, 'commonjs'), + }; + } + + if (format === 'commonjs-typescript' && looksLikeEsm(source)) { + return { + format: 'module', + source: transformTs(source, fileUrl, 'es6'), + }; + } + + return result; + }, + }); +}; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index aa644ed12..6968787c2 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -553,6 +553,18 @@ export interface RstestConfig { */ includeTaskLocation?: boolean; + /** + * Transform TypeScript files that are loaded at runtime outside the bundle + * graph (e.g. via `createRequire` or dynamic `import()` with a runtime path) + * when their module style mismatches the package `type` scope. + * + * Requires Node.js >= 22.22.3 / >= 24.11.1; silently inactive on older + * versions. Applies to the node executor only. + * + * @default true + */ + runtimeTsTransform?: boolean; + // Rsbuild configs plugins?: RsbuildConfig['plugins']; diff --git a/packages/core/src/types/worker.ts b/packages/core/src/types/worker.ts index 9e2c417af..9af3b8ed2 100644 --- a/packages/core/src/types/worker.ts +++ b/packages/core/src/types/worker.ts @@ -71,6 +71,7 @@ export type RuntimeConfig = Pick< | 'chaiConfig' | 'includeTaskLocation' | 'silent' + | 'runtimeTsTransform' >; /** @@ -80,13 +81,18 @@ export type RuntimeConfig = Pick< * - `testEnvironment`: the client hardcodes `environment: 'browser'`. * - `coverage`: browser coverage is host-wired, not client-read. * - `logHeapUsage` / `detectAsyncLeaks`: node process mechanisms. + * - `runtimeTsTransform`: a node module loader mechanism. * * These fields stay REQUIRED on `RuntimeConfig` (node worker consumers * destructure them unconditionally); only the browser wire narrows. */ export type BrowserRuntimeConfig = Omit< RuntimeConfig, - 'testEnvironment' | 'detectAsyncLeaks' | 'logHeapUsage' | 'coverage' + | 'testEnvironment' + | 'detectAsyncLeaks' + | 'logHeapUsage' + | 'coverage' + | 'runtimeTsTransform' >; export type CurrentTaskInfo = Pick< diff --git a/packages/core/src/utils/helper.ts b/packages/core/src/utils/helper.ts index b00a9548e..11ce528eb 100644 --- a/packages/core/src/utils/helper.ts +++ b/packages/core/src/utils/helper.ts @@ -192,11 +192,13 @@ export const serializableConfig = < }; }; -const getNodeVersion = (): { +export type NodeVersion = { major: number; minor: number; patch: number; -} => { +}; + +export const getNodeVersion = (): NodeVersion => { if (typeof process.versions?.node === 'string') { const [major = 0, minor = 0, patch = 0] = process.versions.node .split('.') diff --git a/packages/core/tests/__snapshots__/config.test.ts.snap b/packages/core/tests/__snapshots__/config.test.ts.snap index 99195263e..e71f0d628 100644 --- a/packages/core/tests/__snapshots__/config.test.ts.snap +++ b/packages/core/tests/__snapshots__/config.test.ts.snap @@ -90,6 +90,7 @@ exports[`mergeRstestConfig > should merge config correctly with default config 1 "restoreMocks": false, "retry": 0, "root": "/packages/core/tests", + "runtimeTsTransform": true, "setupFiles": [ "./setup.ts", ], diff --git a/packages/core/tests/core/__snapshots__/rstest.test.ts.snap b/packages/core/tests/core/__snapshots__/rstest.test.ts.snap index ca20be4ab..a34d71ff9 100644 --- a/packages/core/tests/core/__snapshots__/rstest.test.ts.snap +++ b/packages/core/tests/core/__snapshots__/rstest.test.ts.snap @@ -87,6 +87,7 @@ exports[`rstest context > should generate rstest context correctly 1`] = ` "restoreMocks": false, "retry": 0, "root": "/packages/core", + "runtimeTsTransform": true, "setupFiles": [], "silent": false, "slowTestThreshold": 300, @@ -190,6 +191,7 @@ exports[`rstest context > should generate rstest context correctly with multiple "restoreMocks": false, "retry": 0, "root": "/packages/core/test-project", + "runtimeTsTransform": true, "setupFiles": [ "/packages/core/test-project/scripts/rstest.setup.ts", ], @@ -296,6 +298,7 @@ exports[`rstest context > should generate rstest context correctly with multiple "restoreMocks": false, "retry": 0, "root": "/packages/core/test-project1", + "runtimeTsTransform": true, "setupFiles": [ "/packages/core/test-project1/scripts/rstest.setup.ts", ], diff --git a/packages/core/tests/core/executorCapabilities.test.ts b/packages/core/tests/core/executorCapabilities.test.ts index 7d5bf8f63..feb605faa 100644 --- a/packages/core/tests/core/executorCapabilities.test.ts +++ b/packages/core/tests/core/executorCapabilities.test.ts @@ -35,6 +35,7 @@ const makeProject = (): ProjectContext => chaiConfig: {}, includeTaskLocation: false, silent: false, + runtimeTsTransform: true, }, }) as unknown as ProjectContext; diff --git a/packages/core/tests/core/runtimeConfigProjection.test.ts b/packages/core/tests/core/runtimeConfigProjection.test.ts index 539aff463..5ba82323e 100644 --- a/packages/core/tests/core/runtimeConfigProjection.test.ts +++ b/packages/core/tests/core/runtimeConfigProjection.test.ts @@ -29,6 +29,7 @@ const baseNormalizedConfig = { chaiConfig: {}, includeTaskLocation: false, silent: false, + runtimeTsTransform: true, }; const makeProject = ( @@ -45,6 +46,7 @@ describe('projectRuntimeConfig', () => { expect('coverage' in config).toBe(false); expect('logHeapUsage' in config).toBe(false); expect('detectAsyncLeaks' in config).toBe(false); + expect('runtimeTsTransform' in config).toBe(false); }); it('static env emits only NODE_ENV + RSTEST plus config env by default', () => { @@ -100,6 +102,7 @@ describe('projectRuntimeConfig', () => { expect(config.coverage.reporters).toEqual([]); expect('logHeapUsage' in config).toBe(true); expect('testEnvironment' in config).toBe(true); + expect('runtimeTsTransform' in config).toBe(true); }); it('inherit spreads the provided env base', () => { diff --git a/packages/core/tests/runtime/runtimeTsHook.test.ts b/packages/core/tests/runtime/runtimeTsHook.test.ts new file mode 100644 index 000000000..96d4a28d5 --- /dev/null +++ b/packages/core/tests/runtime/runtimeTsHook.test.ts @@ -0,0 +1,160 @@ +import { + isRuntimeTsHookSupportedVersion, + looksLikeCjs, + looksLikeEsm, +} from '../../src/runtime/worker/runtimeTsHook'; + +describe('looksLikeCjs / looksLikeEsm', () => { + it('detects CJS-only sources', () => { + const sources = [ + "module.exports = { name: 'plugin' };", + "exports.foo = 'bar';", + "exports['foo'] = 'bar';", + "const dep = require('./dep');", + ]; + for (const source of sources) { + expect(looksLikeCjs(source)).toBe(true); + expect(looksLikeEsm(source)).toBe(false); + } + }); + + it('detects ESM-only sources', () => { + const sources = [ + "import x from './x';", + "import './side-effect';", + "import { a } from './a';", + "import * as ns from './ns';", + 'export const a = 1;', + "export { a } from './a';", + "export * from './a';", + ]; + for (const source of sources) { + expect(looksLikeEsm(source)).toBe(true); + expect(looksLikeCjs(source)).toBe(false); + } + }); + + it('does not treat dynamic import() in a CJS file as ESM', () => { + // `import(` is legal in CommonJS — it must not flip the file to ESM. + const source = [ + "const dep = require('./dep');", + "module.exports = async () => (await import('./lazy.js')).default;", + ].join('\n'); + expect(looksLikeEsm(source)).toBe(false); + expect(looksLikeCjs(source)).toBe(true); + }); + + it('does not treat import.meta as ESM syntax', () => { + // `import.meta` alone is not an import declaration; the CJS markers win. + const source = [ + 'const here = import.meta.url;', + 'module.exports = { here };', + ].join('\n'); + expect(looksLikeEsm(source)).toBe(false); + expect(looksLikeCjs(source)).toBe(true); + }); + + it('ignores import declarations inside line comments', () => { + const source = [ + "// import x from './x';", + ' // export const a = 1;', + "module.exports = { name: 'plugin' };", + ].join('\n'); + expect(looksLikeCjs(source)).toBe(true); + expect(looksLikeEsm(source)).toBe(false); + }); + + it('ignores import declarations inside block comments', () => { + const source = [ + '/*', + " import x from './x';", + ' export const a = 1;', + '*/', + "module.exports = { name: 'plugin' };", + ].join('\n'); + expect(looksLikeCjs(source)).toBe(true); + expect(looksLikeEsm(source)).toBe(false); + }); + + it('ignores CJS markers inside comments when sniffing ESM', () => { + const source = [ + "// module.exports = {}; require('./legacy');", + '/* exports.foo = 1; */', + "import x from './x';", + 'export const a = x;', + ].join('\n'); + expect(looksLikeEsm(source)).toBe(true); + expect(looksLikeCjs(source)).toBe(false); + }); + + it('reports neither for empty or ambiguous sources', () => { + const sources = [ + '', + '\n\n \n', + '// just a comment', + 'const a: number = 1;', + 'export', + 'type Foo = { a: string };', + ]; + for (const source of sources) { + expect(looksLikeCjs(source)).toBe(false); + expect(looksLikeEsm(source)).toBe(false); + } + }); + + it('reports neither for mixed sources (both marker families present)', () => { + // Ambiguous: transforming either way could change working semantics, so the + // hook must leave it to Node. + const source = ["import x from './x';", 'module.exports = x;'].join('\n'); + expect(looksLikeCjs(source)).toBe(false); + expect(looksLikeEsm(source)).toBe(false); + }); +}); + +describe('isRuntimeTsHookSupportedVersion', () => { + const version = (spec: string) => { + const [major = 0, minor = 0, patch = 0] = spec.split('.').map(Number); + return { major, minor, patch }; + }; + + // The gate mirrors nodejs/node#59929 (sync-hook CJS reentrancy fix), shipped + // in v22.22.3, v24.11.1, v25.1.0 and v26.0.0. + it.each([ + // major 22 → >= 22.22.3 + ['22.21.9', false], + ['22.22.0', false], + ['22.22.2', false], + ['22.22.3', true], + ['22.22.4', true], + ['22.23.0', true], + // major 24 → >= 24.11.1 + ['24.10.9', false], + ['24.11.0', false], + ['24.11.1', true], + ['24.12.0', true], + // major 25 → >= 25.1.0 + ['25.0.9', false], + ['25.1.0', true], + ['25.2.0', true], + // major >= 26 → always + ['26.0.0', true], + ['27.5.1', true], + // unsupported majors, however high the minor/patch + ['20.19.0', false], + ['20.99.99', false], + ['21.7.3', false], + ['23.11.1', false], + ['23.99.99', false], + ] as const)('%s → %s', (spec, expected) => { + expect(isRuntimeTsHookSupportedVersion(version(spec))).toBe(expected); + }); + + it('closes the gate on unparsable versions', () => { + // Pre-release tags (e.g. `23.0.0-nightly`) yield NaN; every comparison + // against NaN is false, which is the desired failure mode. + expect(isRuntimeTsHookSupportedVersion(version('22.22.x'))).toBe(false); + expect( + isRuntimeTsHookSupportedVersion({ major: 0, minor: 0, patch: 0 }), + ).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c54dfdd4..bc55a3a63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -518,6 +518,14 @@ importers: specifier: workspace:* version: link:../../../../../packages/core + e2e/runtimeTsTransform/fixtures: {} + + e2e/runtimeTsTransform/fixtures-cjs-scope: {} + + e2e/runtimeTsTransform/fixtures-coexist: {} + + e2e/runtimeTsTransform/fixtures-opt-out: {} + e2e/setup/fixtures/package-name/test-setup-esm-fixtures: {} e2e/setup/fixtures/package-name/test-setup-fixtures: {} diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 78a3bc351..d440e8ee2 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -42,6 +42,7 @@ desync desynced dgimsuvy distpath +dlopens DNSCHANNEL docgen dogfooding @@ -142,6 +143,7 @@ quxx RANDOMBYTESREQUEST rasterizes rebranded +reentrancy resvg rolldown rootdir diff --git a/website/docs/en/config/test/_meta.json b/website/docs/en/config/test/_meta.json index 72e97bf1d..a9eb69f57 100644 --- a/website/docs/en/config/test/_meta.json +++ b/website/docs/en/config/test/_meta.json @@ -26,6 +26,7 @@ "isolate", "test-environment", "browser", + "runtime-ts-transform", "clear-mocks", "reset-mocks", "restore-mocks", diff --git a/website/docs/en/config/test/runtime-ts-transform.mdx b/website/docs/en/config/test/runtime-ts-transform.mdx new file mode 100644 index 000000000..a5c0de924 --- /dev/null +++ b/website/docs/en/config/test/runtime-ts-transform.mdx @@ -0,0 +1,90 @@ +--- +description: Whether to transform runtime-loaded TypeScript files whose module style mismatches their package type scope. +--- + +import { ApiMeta } from '@components/ApiMeta'; + +# runtimeTsTransform + + + +- **Type:** `boolean` +- **Default:** `true` + +Whether to transform TypeScript files that are loaded at runtime — outside the bundle graph — when their module style mismatches the package `type` scope. + +Node.js can strip TypeScript syntax from `.ts` files, but it never converts module systems: in a `"type": "module"` scope a `.ts` file is ESM, and in a `"type": "commonjs"` scope it is CommonJS. Files that Rstest bundles are transformed by the build pipeline, but a file loaded later by Node.js — for example through `createRequire` or `import(dynamicPath)` — keeps native loader semantics and fails when its module style does not match its scope. + +When this option is enabled, Rstest registers a Node.js load hook in the test worker that detects the two mismatch cases and transforms the source with the SWC that already ships inside Rspack: + +- A CommonJS-style `.ts` file (`module.exports`, `exports.foo`, `require()`) in a `"type": "module"` scope is transformed to CommonJS. +- An ESM-style `.ts` file (`import` / `export` declarations) in a `"type": "commonjs"` scope is transformed to ESM. + +Every other file is left untouched and keeps Node.js native semantics, including native `require(esm)`. No extra dependency is needed, and the Rspack SWC binding is only loaded on the first file that actually needs a transform. + +## Example + +Given a package configured as ESM: + +```json title="package.json" +{ + "type": "module" +} +``` + +```ts title="plugin.ts" +module.exports = { + name: 'plugin', +}; +``` + +```ts title="plugin.test.ts" +import { createRequire } from 'node:module'; +import { expect, test } from '@rstest/core'; + +const require = createRequire(import.meta.url); + +test('loads a CommonJS-style TypeScript plugin at runtime', () => { + const plugin = require('./plugin.ts'); + + expect(plugin.name).toBe('plugin'); +}); +``` + +Without `runtimeTsTransform`, Node.js classifies `plugin.ts` as ESM and `module.exports = ...` is not exported as a CommonJS value. With the default `runtimeTsTransform: true`, Rstest transforms `plugin.ts` to CommonJS and the `require` call resolves to the expected value. + +## Node.js version requirement + +`runtimeTsTransform` relies on the synchronous `module.registerHooks` API, whose CommonJS loading path was only made reentrancy-safe by [nodejs/node#59929](https://github.com/nodejs/node/pull/59929). Rstest therefore only activates the hook on: + +- Node.js >= 22.22.3 (22.x) +- Node.js >= 24.11.1 (24.x) +- Node.js >= 25.1.0 (25.x) +- Node.js >= 26.0.0 + +On any other version the option is silently inactive and behavior is unchanged. See [Troubleshooting](/guide/debug/troubleshooting) for the manual loader registration that still applies there. + +## Limitations + +`runtimeTsTransform` is a deliberately minimal intervention. It performs type erasure and module lowering only: + +- **No tsconfig awareness.** `paths` aliases, decorator metadata, and other `compilerOptions` are not applied. Files that need them should go through the bundle graph instead. +- **Only `.ts`.** `.tsx` is not handled (it needs JSX configuration), and `.mts` / `.cts` are already unambiguous natively. +- **`node_modules` is excluded.** Dependencies keep Node.js native semantics; use [`output.bundleDependencies`](/config/build/output#outputbundledependencies) when a dependency needs transformation. +- **Named exports follow Node.js rules.** When a file is transformed to CommonJS, named imports of that file are still resolved by Node.js's `cjs-module-lexer`, so exports it cannot detect statically remain unavailable as named imports. +- **Third-party loaders win.** If a loader such as `ts-node`, `tsx`, or `@swc-node/register` takes over `.ts`, Rstest's hook passes the file through so that loader keeps working. +- **Not part of the module graph.** Files loaded through the hook are invisible to watch-mode re-runs and to `rstest.mock`. + +This option applies to the node executor only. [Browser mode](/config/test/browser) ignores it. + +## Opting out + +Set `runtimeTsTransform: false` to disable the hook entirely and restore Node.js native semantics for every runtime-loaded `.ts` file: + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + runtimeTsTransform: false, +}); +``` diff --git a/website/docs/en/guide/debug/troubleshooting.mdx b/website/docs/en/guide/debug/troubleshooting.mdx index a3d973dcb..4c7af0c3e 100644 --- a/website/docs/en/guide/debug/troubleshooting.mdx +++ b/website/docs/en/guide/debug/troubleshooting.mdx @@ -57,7 +57,7 @@ Rstest transforms files that are part of the bundle graph. However, code that is Modern Node.js can strip supported TypeScript syntax from `.ts` files, but it still uses the same module-system rules as JavaScript files. In a `type: module` package, `.ts` is treated like `.js`: it is ESM by default. If that `.ts` file contains CommonJS globals such as `module.exports`, `exports`, or `require`, Node.js does not rewrite it into CommonJS for you. -This can be different from Jest setups that use `ts-jest`, because `ts-jest` can register a runtime loader hook and compile the file before Node.js applies the same native boundary. It can also be different from Vitest, because Vitest's transform/runtime model may hide some of these Node.js loader boundaries. +This can be different from Jest setups that use `ts-jest`, because `ts-jest` can register a runtime loader hook and compile the file before Node.js applies the same native boundary. It can also be different from Vitest, because Vitest does not transform runtime requires either. ### Solution @@ -67,7 +67,15 @@ Prefer making the runtime-loaded file match Node.js native module semantics: - Or convert the file to ESM syntax when it lives under `type: module`. - Or move it into a package scope with `"type": "commonjs"`. -If the dynamic load depends on a custom transform, register that transform explicitly. For migrated Jest projects, the simplest place is [`setupFiles`](/config/test/setup-files): +If you cannot change the file, [`runtimeTsTransform`](/config/test/runtime-ts-transform) already handles this case for you. It is enabled by default, and on Node.js >= 22.22.3 / >= 24.11.1 Rstest detects the module-style mismatch and transforms the file, so the `require('./plugin.ts')` above returns the expected CommonJS value with no extra configuration. The mirror case — an ESM-style `.ts` loaded from a `type: commonjs` scope — is covered as well. + +The manual registration below is only needed when `runtimeTsTransform` cannot apply: + +- Node.js is older than the versions above, where the option is silently inactive. +- You set `runtimeTsTransform: false`. +- The file needs a transform Rstest's built-in hook does not perform, such as tsconfig `paths`, decorator metadata, or `.tsx`. See the [limitations](/config/test/runtime-ts-transform#limitations). + +In those cases, register the transform explicitly. For migrated Jest projects, the simplest place is [`setupFiles`](/config/test/setup-files): Install the runtime loader you plan to register first. Rstest does not install these loaders for you; for example, [@swc-node/register](https://github.com/swc-project/swc-node) provides a SWC-based TypeScript require hook, while [ts-node](https://github.com/TypeStrong/ts-node) provides the `ts-node/register` hook: diff --git a/website/docs/zh/config/test/_meta.json b/website/docs/zh/config/test/_meta.json index 72e97bf1d..a9eb69f57 100644 --- a/website/docs/zh/config/test/_meta.json +++ b/website/docs/zh/config/test/_meta.json @@ -26,6 +26,7 @@ "isolate", "test-environment", "browser", + "runtime-ts-transform", "clear-mocks", "reset-mocks", "restore-mocks", diff --git a/website/docs/zh/config/test/runtime-ts-transform.mdx b/website/docs/zh/config/test/runtime-ts-transform.mdx new file mode 100644 index 000000000..1ee64d898 --- /dev/null +++ b/website/docs/zh/config/test/runtime-ts-transform.mdx @@ -0,0 +1,90 @@ +--- +description: 是否转换模块风格与 package type scope 不匹配的运行时加载 TypeScript 文件。 +--- + +import { ApiMeta } from '@components/ApiMeta'; + +# runtimeTsTransform + + + +- **类型:** `boolean` +- **默认值:** `true` + +是否转换在运行时加载(即 bundle graph 之外)、且模块风格与 package `type` scope 不匹配的 TypeScript 文件。 + +Node.js 可以从 `.ts` 文件中 strip 掉 TypeScript 语法,但它不会转换模块系统:在 `"type": "module"` 作用域下 `.ts` 文件就是 ESM,在 `"type": "commonjs"` 作用域下就是 CommonJS。进入 Rstest bundle graph 的文件会由构建流水线转换,但后续交给 Node.js 加载的文件——例如通过 `createRequire` 或 `import(dynamicPath)` 加载——会保留原生 loader 语义,当它的模块风格与所在作用域不匹配时就会失败。 + +启用该选项后,Rstest 会在 test worker 中注册一个 Node.js load hook,识别这两种不匹配情况,并使用 Rspack 内置的 SWC 转换源码: + +- `"type": "module"` 作用域下的 CommonJS 风格 `.ts` 文件(`module.exports`、`exports.foo`、`require()`)会被转换为 CommonJS。 +- `"type": "commonjs"` 作用域下的 ESM 风格 `.ts` 文件(`import` / `export` 声明)会被转换为 ESM。 + +其他文件不会被改动,仍然保留 Node.js 原生语义,包括原生的 `require(esm)`。这不需要额外依赖,并且只有在第一个真正需要转换的文件出现时,才会加载 Rspack 的 SWC binding。 + +## 示例 + +假设项目被配置为 ESM: + +```json title="package.json" +{ + "type": "module" +} +``` + +```ts title="plugin.ts" +module.exports = { + name: 'plugin', +}; +``` + +```ts title="plugin.test.ts" +import { createRequire } from 'node:module'; +import { expect, test } from '@rstest/core'; + +const require = createRequire(import.meta.url); + +test('loads a CommonJS-style TypeScript plugin at runtime', () => { + const plugin = require('./plugin.ts'); + + expect(plugin.name).toBe('plugin'); +}); +``` + +如果没有 `runtimeTsTransform`,Node.js 会把 `plugin.ts` 判定为 ESM,`module.exports = ...` 不会作为 CommonJS 值导出。在默认的 `runtimeTsTransform: true` 下,Rstest 会把 `plugin.ts` 转换为 CommonJS,`require` 调用即可得到预期的值。 + +## Node.js 版本要求 + +`runtimeTsTransform` 依赖同步的 `module.registerHooks` API,而它的 CommonJS 加载路径直到 [nodejs/node#59929](https://github.com/nodejs/node/pull/59929) 才做到 reentrancy-safe。因此 Rstest 只在以下版本中激活该 hook: + +- Node.js >= 22.22.3(22.x) +- Node.js >= 24.11.1(24.x) +- Node.js >= 25.1.0(25.x) +- Node.js >= 26.0.0 + +在其他版本中,该选项会静默失效,行为保持不变。这些版本上仍然适用的手动 loader 注册方式,请参考[问题排查](/guide/debug/troubleshooting)。 + +## 限制 + +`runtimeTsTransform` 是一种刻意保持最小化的干预,只做类型擦除和模块降级: + +- **不感知 tsconfig。** `paths` 别名、decorator metadata 以及其他 `compilerOptions` 都不会生效。依赖这些能力的文件应该走 bundle graph。 +- **只处理 `.ts`。** 不处理 `.tsx`(它需要 JSX 配置),而 `.mts` / `.cts` 在原生语义下本身就是明确的。 +- **排除 `node_modules`。** 依赖保持 Node.js 原生语义;如果某个依赖需要转换,请使用 [`output.bundleDependencies`](/config/build/output#outputbundledependencies)。 +- **named exports 遵循 Node.js 规则。** 当文件被转换为 CommonJS 后,对该文件的 named imports 仍由 Node.js 的 `cjs-module-lexer` 解析,因此它无法静态识别的导出依旧不能作为 named imports 使用。 +- **第三方 loader 优先。** 如果 `ts-node`、`tsx` 或 `@swc-node/register` 这类 loader 接管了 `.ts`,Rstest 的 hook 会直接放行,让该 loader 继续工作。 +- **不属于 module graph。** 通过该 hook 加载的文件对 watch 模式的重新运行和 `rstest.mock` 都是不可见的。 + +该选项仅适用于 node executor,[browser 模式](/config/test/browser)会忽略它。 + +## 关闭该选项 + +设置 `runtimeTsTransform: false` 可以完全禁用该 hook,让所有运行时加载的 `.ts` 文件恢复 Node.js 原生语义: + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + runtimeTsTransform: false, +}); +``` diff --git a/website/docs/zh/guide/debug/troubleshooting.mdx b/website/docs/zh/guide/debug/troubleshooting.mdx index 4fcfe47c2..b7b6be0e8 100644 --- a/website/docs/zh/guide/debug/troubleshooting.mdx +++ b/website/docs/zh/guide/debug/troubleshooting.mdx @@ -57,7 +57,7 @@ Rstest 会转换进入 bundle graph 的文件。但后续交给 Node.js 加载 现代 Node.js 可以从 `.ts` 文件中 strip 掉支持的 TypeScript 语法,但它仍然使用和 JavaScript 文件相同的模块系统规则。在 `type: module` 包中,`.ts` 会像 `.js` 一样默认被视为 ESM。如果这个 `.ts` 文件包含 `module.exports`、`exports` 或 `require` 这类 CommonJS globals,Node.js 不会自动把它改写成 CommonJS。 -这和使用 `ts-jest` 的 Jest 项目可能不同,因为 `ts-jest` 可以注册 runtime loader hook,在 Node.js 应用相同原生边界之前先编译文件。它也可能和 Vitest 不同,因为 Vitest 的 transform/runtime 模型可能隐藏了一部分 Node.js loader 边界。 +这和使用 `ts-jest` 的 Jest 项目可能不同,因为 `ts-jest` 可以注册 runtime loader hook,在 Node.js 应用相同原生边界之前先编译文件。它也和 Vitest 不同,因为 Vitest 同样不会转换运行时的 require。 ### 解决方式 @@ -67,7 +67,15 @@ Rstest 会转换进入 bundle graph 的文件。但后续交给 Node.js 加载 - 或者在 `type: module` 作用域内把文件改成 ESM 语法。 - 或者把它移到 `"type": "commonjs"` 的 package scope 下。 -如果这个动态加载依赖自定义转换,需要显式注册该转换。对从 Jest 迁移的项目来说,最简单的位置是 [`setupFiles`](/config/test/setup-files): +如果无法修改这个文件,[`runtimeTsTransform`](/config/test/runtime-ts-transform) 已经默认为你处理了这种情况。它默认开启,在 Node.js >= 22.22.3 / >= 24.11.1 上,Rstest 会识别模块风格不匹配并转换该文件,因此上面的 `require('./plugin.ts')` 无需任何额外配置即可得到预期的 CommonJS 值。相反的场景——在 `type: commonjs` 作用域下加载 ESM 风格的 `.ts`——同样被覆盖。 + +只有在 `runtimeTsTransform` 无法生效时,才需要下面的手动注册方式: + +- Node.js 版本低于上述版本,此时该选项会静默失效。 +- 你设置了 `runtimeTsTransform: false`。 +- 该文件需要 Rstest 内置 hook 不提供的转换能力,例如 tsconfig `paths`、decorator metadata 或 `.tsx`。参考[限制](/config/test/runtime-ts-transform#限制)。 + +在这些情况下,需要显式注册该转换。对从 Jest 迁移的项目来说,最简单的位置是 [`setupFiles`](/config/test/setup-files): 先安装你准备注册的 runtime loader。Rstest 不会默认安装这些 loader;例如,[@swc-node/register](https://github.com/swc-project/swc-node) 提供基于 SWC 的 TypeScript require hook,而 [ts-node](https://github.com/TypeStrong/ts-node) 提供 `ts-node/register` hook: