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
18 changes: 8 additions & 10 deletions e2e/runtime-register/fixtures-native/native-type-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())) {
Expand All @@ -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 ({
Expand Down
2 changes: 1 addition & 1 deletion e2e/runtime-register/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions e2e/runtimeTsTransform/fixtures-cjs-scope/esmPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// The mirror case: ESM-style TypeScript inside a `"type": "commonjs"` scope.
export const name: string = 'esm-plugin';
8 changes: 8 additions & 0 deletions e2e/runtimeTsTransform/fixtures-cjs-scope/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
5 changes: 5 additions & 0 deletions e2e/runtimeTsTransform/fixtures-cjs-scope/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-runtime-ts-transform-commonjs-scope",
"private": true,
"type": "commonjs"
}
8 changes: 8 additions & 0 deletions e2e/runtimeTsTransform/fixtures-cjs-scope/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
include: ['*.test.ts'],
pool: {
maxWorkers: 1,
},
});
14 changes: 14 additions & 0 deletions e2e/runtimeTsTransform/fixtures-coexist/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
5 changes: 5 additions & 0 deletions e2e/runtimeTsTransform/fixtures-coexist/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-runtime-ts-transform-coexist",
"private": true,
"type": "module"
}
4 changes: 4 additions & 0 deletions e2e/runtimeTsTransform/fixtures-coexist/plugin.ts
Original file line number Diff line number Diff line change
@@ -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' };
9 changes: 9 additions & 0 deletions e2e/runtimeTsTransform/fixtures-coexist/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
include: ['*.test.ts'],
setupFiles: ['./setupTsExtension.ts'],
pool: {
maxWorkers: 1,
},
});
19 changes: 19 additions & 0 deletions e2e/runtimeTsTransform/fixtures-coexist/setupTsExtension.ts
Original file line number Diff line number Diff line change
@@ -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,
);
};
11 changes: 11 additions & 0 deletions e2e/runtimeTsTransform/fixtures-opt-out/index.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
5 changes: 5 additions & 0 deletions e2e/runtimeTsTransform/fixtures-opt-out/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-runtime-ts-transform-opt-out",
"private": true,
"type": "module"
}
6 changes: 6 additions & 0 deletions e2e/runtimeTsTransform/fixtures-opt-out/plugin.ts
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 9 additions & 0 deletions e2e/runtimeTsTransform/fixtures-opt-out/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
include: ['*.test.ts'],
runtimeTsTransform: false,
pool: {
maxWorkers: 1,
},
});
19 changes: 19 additions & 0 deletions e2e/runtimeTsTransform/fixtures/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
5 changes: 5 additions & 0 deletions e2e/runtimeTsTransform/fixtures/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-runtime-ts-transform-module-scope",
"private": true,
"type": "module"
}
9 changes: 9 additions & 0 deletions e2e/runtimeTsTransform/fixtures/plugin.ts
Original file line number Diff line number Diff line change
@@ -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 };
8 changes: 8 additions & 0 deletions e2e/runtimeTsTransform/fixtures/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
include: ['*.test.ts'],
pool: {
maxWorkers: 1,
},
});
109 changes: 109 additions & 0 deletions e2e/runtimeTsTransform/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
},
);
7 changes: 7 additions & 0 deletions packages/browser/src/configValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ const createDefaultConfig = (): NormalizedConfig => ({
detectAsyncLeaks: false,
bail: 0,
includeTaskLocation: false,
runtimeTsTransform: true,
browser: {
enabled: false,
provider: 'playwright',
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/browserGlobalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/core/executorCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core/executors/nodeExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export function createNodeExecutor(
sourceMaps,
interopDefault: true,
outputModule: p.outputModule,
runtimeTsTransform: p.normalizedConfig.runtimeTsTransform,
}),
globalSetupTraceArgs,
);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,14 @@ export async function runGlobalSetup({
sourceMaps,
interopDefault,
outputModule,
runtimeTsTransform,
}: {
globalSetupEntries: EntryInfo[];
assetFiles: Record<string, string>;
sourceMaps: Record<string, string>;
interopDefault: boolean;
outputModule: boolean;
runtimeTsTransform: boolean;
}): Promise<{
success: boolean;
errors?: any[];
Expand All @@ -222,6 +224,7 @@ export async function runGlobalSetup({
interopDefault,
outputModule,
sourceMaps,
runtimeTsTransform,
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core/listTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ const collectNodeTests = async ({
sourceMaps,
interopDefault: true,
outputModule: project.outputModule,
runtimeTsTransform: project.normalizedConfig.runtimeTsTransform,
});
if (!success) {
return {
Expand Down
Loading
Loading