Skip to content
Merged
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
86 changes: 86 additions & 0 deletions e2e/runtime-register/fixtures-native/native-type-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { expect, test } from '@rstest/core';

const probeNativeTypeScriptSupport = async () => {
const fixtureDir = mkdtempSync(join(tmpdir(), 'rstest-ts-probe-'));

try {
writeFileSync(
join(fixtureDir, 'probe.ts'),
'export const value = 1 as number;\n',
);

await import(pathToFileURL(join(fixtureDir, 'probe.ts')).href);
return true;
} catch {
return false;
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
};

const supportsNativeTypeScript = async () => {
if (process.env.RSTEST_OUTPUT_MODULE === 'false') {
return false;
}

return probeNativeTypeScriptSupport();
};

const createFixture = (extension: 'ts' | 'cts') => {
const fixtureDir = mkdtempSync(join(tmpdir(), 'rstest-type-module-'));

writeFileSync(join(fixtureDir, 'package.json'), '{"type":"module"}\n');
writeFileSync(
join(fixtureDir, `plugin.${extension}`),
'module.exports = { value: 1 as number };\n',
);

return fixtureDir;
};

test('keeps native node semantics for cjs-style .ts in type module scope', async ({
onTestFinished,
}) => {
if (!(await supportsNativeTypeScript())) {
return;
}

const fixtureDir = createFixture('ts');
onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true }));

const require = createRequire(
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');
}
});

test('loads cjs-style TypeScript when the runtime file uses .cts', async ({
onTestFinished,
}) => {
if (!(await supportsNativeTypeScript())) {
return;
}

const fixtureDir = createFixture('cts');
onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true }));

const require = createRequire(
pathToFileURL(join(fixtureDir, 'loader.mjs')).href,
);

expect(require('./plugin.cts')).toEqual({ value: 1 });
});
8 changes: 8 additions & 0 deletions e2e/runtime-register/fixtures-native/rstest.config.mts
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,
},
});
5 changes: 5 additions & 0 deletions e2e/runtime-register/fixtures/cjs-register.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const fs = require('node:fs');

require.extensions['.ts'] = (mod, filename) => {
mod._compile(fs.readFileSync(filename, 'utf-8'), filename);
};
9 changes: 9 additions & 0 deletions e2e/runtime-register/fixtures/exec-argv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from '@rstest/core';

test('passes pool.execArgv node flags to workers', () => {
expect(process.execArgv).toContain('--conditions=rstest-e2e');
expect(process.execArgv).toContain('--require');
expect(process.execArgv).toContain('./cjs-register.cjs');
expect(process.execArgv).toContain('--import');
expect(process.execArgv).toContain('./register.mjs');
});
17 changes: 17 additions & 0 deletions e2e/runtime-register/fixtures/register.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { rmSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { register } from 'node:module';

const registerFlagPath = join(tmpdir(), `rstest-register-${process.pid}.txt`);

await writeFile(registerFlagPath, 'loaded', 'utf-8');
Comment thread
9aoy marked this conversation as resolved.

process.once('exit', () => {
rmSync(registerFlagPath, { force: true });
});

register('./ts-register-loader.mjs', import.meta.url);

process.env.RUNTIME_REGISTER_FLAG_PATH = registerFlagPath;
19 changes: 19 additions & 0 deletions e2e/runtime-register/fixtures/register.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { expect, test } from '@rstest/core';

const require = createRequire(import.meta.url);

test('runs node register hooks inside test workers', async () => {
const registerFlagPath = process.env.RUNTIME_REGISTER_FLAG_PATH!;

expect(existsSync(registerFlagPath)).toBe(true);

const cjsModule = require(`${process.cwd()}/runtime-cjs.ts`);
expect(cjsModule.runtimeValue).toBe('loaded by cjs require hook');

const moduleUrl = pathToFileURL(`${process.cwd()}/runtime-module.ts`).href;
const imported = await import(moduleUrl);
expect(imported.runtimeValue).toBe('loaded by node register');
});
15 changes: 15 additions & 0 deletions e2e/runtime-register/fixtures/rstest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
include: ['*.test.ts'],
pool: {
maxWorkers: 1,
execArgv: [
'--require',
'./cjs-register.cjs',
'--import',
'./register.mjs',
'--conditions=rstest-e2e',
],
},
});
3 changes: 3 additions & 0 deletions e2e/runtime-register/fixtures/runtime-cjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
runtimeValue: 'loaded by cjs require hook',
};
1 change: 1 addition & 0 deletions e2e/runtime-register/fixtures/runtime-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const runtimeValue = 'loaded by node register';
26 changes: 26 additions & 0 deletions e2e/runtime-register/fixtures/ts-register-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readFile } from 'node:fs/promises';

export async function resolve(specifier, context, nextResolve) {
if (specifier.endsWith('.ts')) {
return {
shortCircuit: true,
url: new URL(specifier, context.parentURL).href,
};
}

return nextResolve(specifier, context);
}

export async function load(url, context, nextLoad) {
if (url.endsWith('.ts')) {
const source = await readFile(new URL(url), 'utf-8');

return {
format: 'module',
shortCircuit: true,
source,
};
}

return nextLoad(url, context);
}
42 changes: 42 additions & 0 deletions e2e/runtime-register/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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);
const registerFixtureDir = join(__dirname, 'fixtures');
const nativeFixtureDir = join(__dirname, 'fixtures-native');

const runFixture = async (
cwd: string,
onTestFinished: typeof onRstestFinished,
) => {
const { expectExecSuccess } = await runRstestCli({
command: 'rstest',
args: ['run'],
onTestFinished,
options: {
nodeOptions: {
cwd,
},
},
});

await expectExecSuccess();
};

describe('runtime node register behavior', () => {
it('should preserve node register hooks and execArgv inside workers', async ({
onTestFinished,
}) => {
await runFixture(registerFixtureDir, onTestFinished);
});

it('should preserve native node semantics for late-loaded TypeScript files', async ({
onTestFinished,
}) => {
await runFixture(nativeFixtureDir, onTestFinished);
});
});
2 changes: 1 addition & 1 deletion website/docs/en/guide/advanced/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["debugging", "ci", "profiling"]
["debugging", "ci", "profiling", "troubleshooting"]
133 changes: 133 additions & 0 deletions website/docs/en/guide/advanced/troubleshooting.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
description: Common Rstest troubleshooting notes for migration and runtime differences.
---

# Troubleshooting

This page explains common problems that come from Rstest's execution model. They are often not test failures caused by your assertions, but differences between Rstest, Jest, Vitest, and native Node.js runtime behavior.

## Code outside the bundle graph uses native Node.js behavior

Rstest runs tests with a bundle-based execution model. Files that are part of the bundle graph are transformed by Rstest's build pipeline, so TypeScript, JSX, aliases, and selected dependency transforms can work before the test worker executes them.

Some code is still loaded by Node.js at runtime instead of by the bundler. Common examples include:

- late dynamic `require()` or `import(dynamicPath)` calls,
- modules externalized from the bundle,
- code loaded by custom Node.js loaders or register hooks,
- code loaded through Node.js APIs after the test bundle has started.

For these parts, Rstest currently preserves Node.js native semantics. This makes Node.js flags, `--require`, `--import`, and `node:module` register hooks usable in test workers, but it also means Rstest does not emulate every transform-time convenience from Jest, `ts-jest`, or Vitest.

A useful rule of thumb is:

- Static imports and modules in the bundle graph → let Rstest transform or bundle them.
- Late dynamic `require()` or `import(dynamicPath)` / Node.js loader hooks / externalized modules → expect Node.js native behavior unless you register a loader or change the bundling strategy.

## Runtime `require()` of CommonJS-style TypeScript files fails in `type: module`

### Symptom

A package is configured as ESM with `"type": "module"`, but some runtime-loaded `.ts` files still contain CommonJS code:

```json title="package.json"
{
"type": "module"
}
```

```ts title="plugin.ts"
module.exports = {
name: 'plugin',
};
```

```ts title="loader.ts"
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const plugin = require('./plugin.ts');
```

On Node.js versions that support running `.ts` files directly, `require('./plugin.ts')` follows Node.js native module classification. In a `type: module` package scope, Node.js treats `plugin.ts` as ESM rather than CommonJS, so CommonJS assignments such as `module.exports = ...` are not exported as a CommonJS value.

### Cause

Rstest transforms files that are part of the bundle graph. However, code that is loaded later by Node.js keeps Node.js native loader semantics.

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.

### Solution

Prefer making the runtime-loaded file match Node.js native module semantics:

- Rename the CommonJS TypeScript file to `.cts` when it must stay CommonJS.
- 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):

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:

```bash
npm add -D @swc-node/register
# or: npm add -D ts-node
```

```ts title="test/rstest.setup.ts"
import '@swc-node/register';
// or: import 'ts-node/register';
Comment thread
9aoy marked this conversation as resolved.
```

```ts title="rstest.config.ts"
import { defineConfig } from '@rstest/core';

export default defineConfig({
setupFiles: ['./test/rstest.setup.ts'],
});
```

If the hook must be active before worker runtime code starts, pass Node.js register flags through [`pool.execArgv`](/config/test/pool#pass-nodejs-flags-to-workers):

```ts title="rstest.config.ts"
import { defineConfig } from '@rstest/core';

export default defineConfig({
pool: {
execArgv: ['--require', 'ts-node/register'],
// or: execArgv: ['--import', './register.mjs'],
Comment thread
9aoy marked this conversation as resolved.
},
});
```

## Named imports from CommonJS dependencies fail

### Symptom

A test or source file imports named exports from a CommonJS dependency:

```ts
import { create } from 'enhanced-resolve';
```

When the dependency is executed by Node.js as native ESM, the import may fail because Node.js can only expose named exports that its CommonJS lexer can detect statically.

### Cause

Some CommonJS packages create exports dynamically, for example through getters or `Object.defineProperty`. TypeScript types may still declare named exports, and some tools may make those named imports work by transforming or wrapping the dependency at runtime. Native Node.js ESM interop is stricter: if Node.js cannot statically detect the named export, `import { create } from 'pkg'` fails even though `import pkg from 'pkg'` can work.

This is not specific to Rstest. It is a consequence of letting Node.js execute code that stayed outside the bundle graph. It may become visible during migration from Vitest because Vitest may transform more of the dependency path and provide more permissive CommonJS interop.

### Solution

Prefer the import shape that matches Node.js native CommonJS interop:

```ts
import enhancedResolve from 'enhanced-resolve';

const { create } = enhancedResolve;
```

If you need bundler interop for a specific dependency, bundle that dependency so Rstest's build pipeline can process it instead of leaving it to Node.js. In the `node` environment, third-party dependencies are externalized by default; use [`output.bundleDependencies`](/config/build/output#outputbundledependencies) to bundle selected packages that need transformation.
2 changes: 2 additions & 0 deletions website/docs/en/guide/migration/jest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ You can keep these comments as-is or rename them to `@rstest-environment` / `@rs

Rstest uses `swc` for code transformation by default, which is different from Jest's `babel-jest`. Most of the time, you don't need to change anything. And you can configure your swc options through [tools.swc](/config/build/tools#toolsswc).

Rstest transforms files that are part of its bundle graph. If your project previously relied on `ts-jest` runtime transforms, see [Code outside the bundle graph uses native Node.js behavior](/guide/advanced/troubleshooting#code-outside-the-bundle-graph-uses-native-nodejs-behavior).

```diff
export default {
- transform: {
Expand Down
2 changes: 2 additions & 0 deletions website/docs/en/guide/migration/vitest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ You can keep these comments as-is or rename them to `@rstest-environment` / `@rs

Rstest uses Rsbuild as the default test build tool instead of Vite. You can view all available build configuration options in [Build Configurations](/config/#build-configurations).

Rstest runs tests with a bundle-based execution model. Code outside the bundle graph keeps Node.js native loader behavior, which may differ from Vitest. If you dynamically load files at runtime, see [Code outside the bundle graph uses native Node.js behavior](/guide/advanced/troubleshooting#code-outside-the-bundle-graph-uses-native-nodejs-behavior).

In most projects, these are the key build-side changes:

- Use `source.define` instead of `define`.
Expand Down
2 changes: 1 addition & 1 deletion website/docs/zh/guide/advanced/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["debugging", "ci", "profiling"]
["debugging", "ci", "profiling", "troubleshooting"]
Loading
Loading