From a9e13970e9bc13b09a6814245d60e530c6fd37cd Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 11:35:30 +0800 Subject: [PATCH 1/7] docs: add runtime troubleshooting guide --- .../fixtures/cjs-register.cjs | 5 + .../fixtures/exec-argv.test.ts | 9 ++ .../fixtures/native-type-module.test.ts | 83 +++++++++++ e2e/runtime-register/fixtures/register.mjs | 14 ++ .../fixtures/register.test.ts | 17 +++ .../fixtures/rstest.config.mts | 15 ++ e2e/runtime-register/fixtures/runtime-cjs.ts | 3 + .../fixtures/runtime-module.ts | 1 + .../fixtures/ts-register-loader.mjs | 26 ++++ e2e/runtime-register/index.test.ts | 27 ++++ website/docs/en/guide/advanced/_meta.json | 2 +- .../en/guide/advanced/troubleshooting.mdx | 130 ++++++++++++++++++ website/docs/en/guide/migration/jest.mdx | 2 + website/docs/en/guide/migration/vitest.mdx | 2 + website/docs/zh/guide/advanced/_meta.json | 2 +- .../zh/guide/advanced/troubleshooting.mdx | 130 ++++++++++++++++++ website/docs/zh/guide/migration/jest.mdx | 2 + website/docs/zh/guide/migration/vitest.mdx | 2 + 18 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 e2e/runtime-register/fixtures/cjs-register.cjs create mode 100644 e2e/runtime-register/fixtures/exec-argv.test.ts create mode 100644 e2e/runtime-register/fixtures/native-type-module.test.ts create mode 100644 e2e/runtime-register/fixtures/register.mjs create mode 100644 e2e/runtime-register/fixtures/register.test.ts create mode 100644 e2e/runtime-register/fixtures/rstest.config.mts create mode 100644 e2e/runtime-register/fixtures/runtime-cjs.ts create mode 100644 e2e/runtime-register/fixtures/runtime-module.ts create mode 100644 e2e/runtime-register/fixtures/ts-register-loader.mjs create mode 100644 e2e/runtime-register/index.test.ts create mode 100644 website/docs/en/guide/advanced/troubleshooting.mdx create mode 100644 website/docs/zh/guide/advanced/troubleshooting.mdx diff --git a/e2e/runtime-register/fixtures/cjs-register.cjs b/e2e/runtime-register/fixtures/cjs-register.cjs new file mode 100644 index 000000000..bb96e0480 --- /dev/null +++ b/e2e/runtime-register/fixtures/cjs-register.cjs @@ -0,0 +1,5 @@ +const fs = require('node:fs'); + +require.extensions['.ts'] = (mod, filename) => { + mod._compile(fs.readFileSync(filename, 'utf-8'), filename); +}; diff --git a/e2e/runtime-register/fixtures/exec-argv.test.ts b/e2e/runtime-register/fixtures/exec-argv.test.ts new file mode 100644 index 000000000..a8088adca --- /dev/null +++ b/e2e/runtime-register/fixtures/exec-argv.test.ts @@ -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'); +}); diff --git a/e2e/runtime-register/fixtures/native-type-module.test.ts b/e2e/runtime-register/fixtures/native-type-module.test.ts new file mode 100644 index 000000000..b2c0fc2c5 --- /dev/null +++ b/e2e/runtime-register/fixtures/native-type-module.test.ts @@ -0,0 +1,83 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from '@rstest/core'; + +const probeNativeTypeScriptSupport = () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'rstest-ts-probe-')); + + try { + writeFileSync( + join(fixtureDir, 'probe.ts'), + 'module.exports = 1 as number;\n', + ); + + const result = spawnSync( + process.execPath, + ['--eval', "require('./probe.ts')"], + { + cwd: fixtureDir, + encoding: 'utf-8', + }, + ); + + return result.status === 0; + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } +}; + +const supportsNativeTypeScript = 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', + ); + writeFileSync( + join(fixtureDir, 'main.mjs'), + `import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const plugin = require('./plugin.${extension}'); +console.log(JSON.stringify(plugin)); +`, + ); + + return fixtureDir; +}; + +test.skipIf(!supportsNativeTypeScript)( + 'keeps native node semantics for cjs-style .ts in type module scope', + ({ onTestFinished }) => { + const fixtureDir = createFixture('ts'); + onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true })); + + const result = spawnSync(process.execPath, ['main.mjs'], { + cwd: fixtureDir, + encoding: 'utf-8', + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('module is not defined in ES module scope'); + }, +); + +test.skipIf(!supportsNativeTypeScript)( + 'loads cjs-style TypeScript when the runtime file uses .cts', + ({ onTestFinished }) => { + const fixtureDir = createFixture('cts'); + onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true })); + + const result = spawnSync(process.execPath, ['main.mjs'], { + cwd: fixtureDir, + encoding: 'utf-8', + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('{"value":1}'); + }, +); diff --git a/e2e/runtime-register/fixtures/register.mjs b/e2e/runtime-register/fixtures/register.mjs new file mode 100644 index 000000000..98f2663e1 --- /dev/null +++ b/e2e/runtime-register/fixtures/register.mjs @@ -0,0 +1,14 @@ +import { register } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const registerFlagPath = fileURLToPath( + new URL('./register-loaded.txt', import.meta.url), +); + +await import('node:fs/promises').then(({ writeFile }) => + writeFile(registerFlagPath, 'loaded', 'utf-8'), +); + +register('./ts-register-loader.mjs', import.meta.url); + +process.env.RUNTIME_REGISTER_FLAG_PATH = registerFlagPath; diff --git a/e2e/runtime-register/fixtures/register.test.ts b/e2e/runtime-register/fixtures/register.test.ts new file mode 100644 index 000000000..70b022fbb --- /dev/null +++ b/e2e/runtime-register/fixtures/register.test.ts @@ -0,0 +1,17 @@ +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 () => { + expect(existsSync(process.env.RUNTIME_REGISTER_FLAG_PATH!)).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'); +}); diff --git a/e2e/runtime-register/fixtures/rstest.config.mts b/e2e/runtime-register/fixtures/rstest.config.mts new file mode 100644 index 000000000..ca75e3fde --- /dev/null +++ b/e2e/runtime-register/fixtures/rstest.config.mts @@ -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', + ], + }, +}); diff --git a/e2e/runtime-register/fixtures/runtime-cjs.ts b/e2e/runtime-register/fixtures/runtime-cjs.ts new file mode 100644 index 000000000..1d165d72f --- /dev/null +++ b/e2e/runtime-register/fixtures/runtime-cjs.ts @@ -0,0 +1,3 @@ +module.exports = { + runtimeValue: 'loaded by cjs require hook', +}; diff --git a/e2e/runtime-register/fixtures/runtime-module.ts b/e2e/runtime-register/fixtures/runtime-module.ts new file mode 100644 index 000000000..d0f1fbe66 --- /dev/null +++ b/e2e/runtime-register/fixtures/runtime-module.ts @@ -0,0 +1 @@ +export const runtimeValue = 'loaded by node register'; diff --git a/e2e/runtime-register/fixtures/ts-register-loader.mjs b/e2e/runtime-register/fixtures/ts-register-loader.mjs new file mode 100644 index 000000000..2a07b0fe1 --- /dev/null +++ b/e2e/runtime-register/fixtures/ts-register-loader.mjs @@ -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); +} diff --git a/e2e/runtime-register/index.test.ts b/e2e/runtime-register/index.test.ts new file mode 100644 index 000000000..994e16597 --- /dev/null +++ b/e2e/runtime-register/index.test.ts @@ -0,0 +1,27 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from '@rstest/core'; +import { runRstestCli } from '../scripts/'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const fixtureDir = join(__dirname, 'fixtures'); + +describe('runtime node register behavior', () => { + it('should preserve node register hooks and execArgv inside workers', async ({ + onTestFinished, + }) => { + const { expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + cwd: fixtureDir, + }, + }, + }); + + await expectExecSuccess(); + }); +}); diff --git a/website/docs/en/guide/advanced/_meta.json b/website/docs/en/guide/advanced/_meta.json index c6aa2792e..e6169671b 100644 --- a/website/docs/en/guide/advanced/_meta.json +++ b/website/docs/en/guide/advanced/_meta.json @@ -1 +1 @@ -["debugging", "profiling"] +["debugging", "profiling", "troubleshooting"] diff --git a/website/docs/en/guide/advanced/troubleshooting.mdx b/website/docs/en/guide/advanced/troubleshooting.mdx new file mode 100644 index 000000000..ec9183fc0 --- /dev/null +++ b/website/docs/en/guide/advanced/troubleshooting.mdx @@ -0,0 +1,130 @@ +--- +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()` 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 deliberately 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()` / 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')` can still fail because Node.js classifies `plugin.ts` as ESM under the package `type: module` scope: + +```text +ReferenceError: module is not defined in ES module scope +``` + +### 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): + +```ts title="test/rstest.setup.ts" +import '@swc-node/register'; +// or: import 'ts-node/register'; +``` + +```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'], + }, +}); +``` + +## 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. diff --git a/website/docs/en/guide/migration/jest.mdx b/website/docs/en/guide/migration/jest.mdx index 7e1b2a8e0..a68da9984 100644 --- a/website/docs/en/guide/migration/jest.mdx +++ b/website/docs/en/guide/migration/jest.mdx @@ -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: { diff --git a/website/docs/en/guide/migration/vitest.mdx b/website/docs/en/guide/migration/vitest.mdx index 238f0a816..43529319e 100644 --- a/website/docs/en/guide/migration/vitest.mdx +++ b/website/docs/en/guide/migration/vitest.mdx @@ -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`. diff --git a/website/docs/zh/guide/advanced/_meta.json b/website/docs/zh/guide/advanced/_meta.json index c6aa2792e..e6169671b 100644 --- a/website/docs/zh/guide/advanced/_meta.json +++ b/website/docs/zh/guide/advanced/_meta.json @@ -1 +1 @@ -["debugging", "profiling"] +["debugging", "profiling", "troubleshooting"] diff --git a/website/docs/zh/guide/advanced/troubleshooting.mdx b/website/docs/zh/guide/advanced/troubleshooting.mdx new file mode 100644 index 000000000..41e4a2209 --- /dev/null +++ b/website/docs/zh/guide/advanced/troubleshooting.mdx @@ -0,0 +1,130 @@ +--- +description: Rstest 常见问题排查,包括迁移和运行时差异。 +--- + +# Troubleshooting + +本页说明一些由 Rstest 执行模型带来的常见问题。这类问题通常不是断言本身失败,而是 Rstest、Jest、Vitest 与 Node.js 原生运行时行为之间存在差异。 + +## Bundle graph 之外的代码使用 Node.js 原生行为 + +Rstest 采用基于 bundle 的执行模型来运行测试。进入 bundle graph 的文件会经过 Rstest 的构建流水线转换,因此 TypeScript、JSX、别名以及选中的依赖转换,都可以在 test worker 执行前完成。 + +有些代码仍然会在运行时交给 Node.js 加载,而不是交给 bundler 处理。常见场景包括: + +- 后续动态执行的 `require()` 调用; +- 被 external 到 bundle 之外的模块; +- 通过自定义 Node.js loader 或 register hook 加载的代码; +- test bundle 启动后,再通过 Node.js API 加载的代码。 + +对于这些部分,Rstest 会有意保留 Node.js 原生语义。这让 Node.js flags、`--require`、`--import` 和 `node:module` register hooks 可以在 test worker 中使用,但也意味着 Rstest 不会模拟 Jest、`ts-jest` 或 Vitest 的每一种 transform-time 便利行为。 + +经验规则是: + +- 静态 import 和进入 bundle graph 的模块 → 交给 Rstest 转换或打包。 +- 后续动态 `require()` / Node.js loader hook / 被 external 的模块 → 除非注册 loader 或调整打包策略,否则按 Node.js 原生行为处理。 + +## `type: module` 中运行时 `require()` CommonJS 风格的 TypeScript 文件失败 + +### 现象 + +项目通过 `"type": "module"` 配置为 ESM,但某些运行时加载的 `.ts` 文件仍然包含 CommonJS 代码: + +```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'); +``` + +在支持直接运行 `.ts` 文件的 Node.js 版本中,`require('./plugin.ts')` 仍然可能失败,因为 Node.js 会根据 `type: module` 的 package scope 将 `plugin.ts` 判定为 ESM: + +```text +ReferenceError: module is not defined in ES module scope +``` + +### 原因 + +Rstest 会转换进入 bundle graph 的文件。但后续交给 Node.js 加载的代码会保留 Node.js 原生 loader 语义。 + +现代 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 边界。 + +### 解决方式 + +优先让运行时加载的文件符合 Node.js 原生模块语义: + +- 如果文件必须保持 CommonJS,将 CommonJS TypeScript 文件改名为 `.cts`。 +- 或者在 `type: module` 作用域内把文件改成 ESM 语法。 +- 或者把它移到 `"type": "commonjs"` 的 package scope 下。 + +如果这个动态加载依赖自定义转换,需要显式注册该转换。对从 Jest 迁移的项目来说,最简单的位置是 [`setupFiles`](/config/test/setup-files): + +```ts title="test/rstest.setup.ts" +import '@swc-node/register'; +// 或:import 'ts-node/register'; +``` + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + setupFiles: ['./test/rstest.setup.ts'], +}); +``` + +如果这个 hook 必须在 worker runtime 代码启动前生效,可以通过 [`pool.execArgv`](/config/test/pool#向-worker-传递-nodejs-flags) 传递 Node.js register flags: + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + pool: { + execArgv: ['--require', 'ts-node/register'], + // 或:execArgv: ['--import', './register.mjs'], + }, +}); +``` + +## 从 CommonJS 依赖导入 named exports 失败 + +### 现象 + +测试或源码文件从 CommonJS 依赖导入 named exports: + +```ts +import { create } from 'enhanced-resolve'; +``` + +当这个依赖由 Node.js 作为 native ESM 执行时,导入可能失败,因为 Node.js 只能暴露它的 CommonJS lexer 可以静态识别出的 named exports。 + +### 原因 + +有些 CommonJS 包会动态创建导出,例如通过 getter 或 `Object.defineProperty`。TypeScript 类型里仍然可能声明了 named exports,某些工具也可能通过转换或运行时包装让这些 named imports 生效。但 Node.js 原生 ESM interop 更严格:如果 Node.js 不能静态识别这个 named export,`import { create } from 'pkg'` 就会失败,即使 `import pkg from 'pkg'` 可以正常工作。 + +这不是 Rstest 特有的问题,而是保留 Node.js 执行 bundle graph 之外代码时的结果。从 Vitest 迁移时可能更容易暴露这个差异,因为 Vitest 可能转换了更多依赖路径,并提供了更宽松的 CommonJS interop。 + +### 解决方式 + +优先使用符合 Node.js 原生 CommonJS interop 的导入方式: + +```ts +import enhancedResolve from 'enhanced-resolve'; + +const { create } = enhancedResolve; +``` + +如果你确实需要对某个依赖使用 bundler interop,可以将该依赖打进 bundle,让 Rstest 的构建流水线处理它,而不是交给 Node.js。在 `node` 环境中,第三方依赖默认会被 external,可以使用 [`output.bundleDependencies`](/config/build/output#outputbundledependencies) 打包少量需要转换的包。 diff --git a/website/docs/zh/guide/migration/jest.mdx b/website/docs/zh/guide/migration/jest.mdx index 258eab69d..b20d47c9d 100644 --- a/website/docs/zh/guide/migration/jest.mdx +++ b/website/docs/zh/guide/migration/jest.mdx @@ -149,6 +149,8 @@ export default defineConfig({ Rstest 默认使用 `swc` 进行代码转换,这与 Jest 的 `babel-jest` 不同。大多数情况下,你不需要做任何更改。你可以通过 [tools.swc](/config/build/tools#toolsswc) 配置你的 swc 选项。 +Rstest 会转换进入 bundle graph 的文件。如果你的项目之前依赖 `ts-jest` 的运行时转换,请参考 [bundle graph 之外的代码使用 Node.js 原生行为](/guide/advanced/troubleshooting#bundle-graph-之外的代码使用-nodejs-原生行为)。 + ```diff export default { - transform: { diff --git a/website/docs/zh/guide/migration/vitest.mdx b/website/docs/zh/guide/migration/vitest.mdx index 6c923c55e..253e9a83b 100644 --- a/website/docs/zh/guide/migration/vitest.mdx +++ b/website/docs/zh/guide/migration/vitest.mdx @@ -147,6 +147,8 @@ Vitest 配置文件中使用的 helper 可以对应到 `@rstest/core` 导出的 Rstest 使用 Rsbuild 作为默认测试编译工具,而不是 Vite。你可以在 [Build Configurations](/config/#build-configurations) 查看全部编译配置项。 +Rstest 采用基于 bundle 的执行模型来运行测试。没有进入 bundle graph 的代码会保留 Node.js 原生 loader 行为,这可能和 Vitest 不同。如果你在运行时动态加载文件,请参考 [bundle graph 之外的代码使用 Node.js 原生行为](/guide/advanced/troubleshooting#bundle-graph-之外的代码使用-nodejs-原生行为)。 + 大部分项目中,主要的编译侧变化如下: - 使用 `source.define` 替代 `define`。 From e54f27f7f38414ac76030c3a3b67fdfc2a5ce81c Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 12:52:57 +0800 Subject: [PATCH 2/7] docs: address runtime troubleshooting review --- .../fixtures/exec-argv.test.ts | 8 ++- e2e/runtime-register/fixtures/register.mjs | 12 ++--- .../fixtures/register.test.ts | 11 ++-- e2e/runtime-register/index.test.ts | 38 ++++++++----- .../native-type-module.test.ts | 53 +++++++------------ .../native-fixtures/rstest.config.mts | 8 +++ .../en/guide/advanced/troubleshooting.mdx | 7 +++ .../zh/guide/advanced/troubleshooting.mdx | 7 +++ 8 files changed, 87 insertions(+), 57 deletions(-) rename e2e/runtime-register/{fixtures => native-fixtures}/native-type-module.test.ts (54%) create mode 100644 e2e/runtime-register/native-fixtures/rstest.config.mts diff --git a/e2e/runtime-register/fixtures/exec-argv.test.ts b/e2e/runtime-register/fixtures/exec-argv.test.ts index a8088adca..aa538c283 100644 --- a/e2e/runtime-register/fixtures/exec-argv.test.ts +++ b/e2e/runtime-register/fixtures/exec-argv.test.ts @@ -1,6 +1,12 @@ +import { rmSync } from 'node:fs'; import { expect, test } from '@rstest/core'; -test('passes pool.execArgv node flags to workers', () => { +test('passes pool.execArgv node flags to workers', ({ onTestFinished }) => { + const registerFlagPath = process.env.RUNTIME_REGISTER_FLAG_PATH; + if (registerFlagPath) { + onTestFinished(() => rmSync(registerFlagPath, { force: true })); + } + expect(process.execArgv).toContain('--conditions=rstest-e2e'); expect(process.execArgv).toContain('--require'); expect(process.execArgv).toContain('./cjs-register.cjs'); diff --git a/e2e/runtime-register/fixtures/register.mjs b/e2e/runtime-register/fixtures/register.mjs index 98f2663e1..3b2f89cc2 100644 --- a/e2e/runtime-register/fixtures/register.mjs +++ b/e2e/runtime-register/fixtures/register.mjs @@ -1,13 +1,11 @@ +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { register } from 'node:module'; -import { fileURLToPath } from 'node:url'; -const registerFlagPath = fileURLToPath( - new URL('./register-loaded.txt', import.meta.url), -); +const registerFlagPath = join(tmpdir(), `rstest-register-${process.pid}.txt`); -await import('node:fs/promises').then(({ writeFile }) => - writeFile(registerFlagPath, 'loaded', 'utf-8'), -); +await writeFile(registerFlagPath, 'loaded', 'utf-8'); register('./ts-register-loader.mjs', import.meta.url); diff --git a/e2e/runtime-register/fixtures/register.test.ts b/e2e/runtime-register/fixtures/register.test.ts index 70b022fbb..732125cb4 100644 --- a/e2e/runtime-register/fixtures/register.test.ts +++ b/e2e/runtime-register/fixtures/register.test.ts @@ -1,12 +1,17 @@ -import { existsSync } from 'node:fs'; +import { existsSync, rmSync } 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 () => { - expect(existsSync(process.env.RUNTIME_REGISTER_FLAG_PATH!)).toBe(true); +test('runs node register hooks inside test workers', async ({ + onTestFinished, +}) => { + const registerFlagPath = process.env.RUNTIME_REGISTER_FLAG_PATH!; + onTestFinished(() => rmSync(registerFlagPath, { force: true })); + + expect(existsSync(registerFlagPath)).toBe(true); const cjsModule = require(`${process.cwd()}/runtime-cjs.ts`); expect(cjsModule.runtimeValue).toBe('loaded by cjs require hook'); diff --git a/e2e/runtime-register/index.test.ts b/e2e/runtime-register/index.test.ts index 994e16597..59c20abad 100644 --- a/e2e/runtime-register/index.test.ts +++ b/e2e/runtime-register/index.test.ts @@ -5,23 +5,37 @@ import { runRstestCli } from '../scripts/'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const fixtureDir = join(__dirname, 'fixtures'); +const registerFixtureDir = join(__dirname, 'fixtures'); +const nativeFixtureDir = join(__dirname, 'native-fixtures'); + +const runFixture = async ( + cwd: string, + onTestFinished: (cleanup: () => void | Promise) => void, +) => { + 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, }) => { - const { expectExecSuccess } = await runRstestCli({ - command: 'rstest', - args: ['run'], - onTestFinished, - options: { - nodeOptions: { - cwd: fixtureDir, - }, - }, - }); + await runFixture(registerFixtureDir, onTestFinished); + }); - await expectExecSuccess(); + it('should preserve native node semantics for late-loaded TypeScript files', async ({ + onTestFinished, + }) => { + await runFixture(nativeFixtureDir, onTestFinished); }); }); diff --git a/e2e/runtime-register/fixtures/native-type-module.test.ts b/e2e/runtime-register/native-fixtures/native-type-module.test.ts similarity index 54% rename from e2e/runtime-register/fixtures/native-type-module.test.ts rename to e2e/runtime-register/native-fixtures/native-type-module.test.ts index b2c0fc2c5..12202caf0 100644 --- a/e2e/runtime-register/fixtures/native-type-module.test.ts +++ b/e2e/runtime-register/native-fixtures/native-type-module.test.ts @@ -1,34 +1,29 @@ -import { spawnSync } from 'node:child_process'; 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 = () => { +const probeNativeTypeScriptSupport = async () => { const fixtureDir = mkdtempSync(join(tmpdir(), 'rstest-ts-probe-')); try { writeFileSync( join(fixtureDir, 'probe.ts'), - 'module.exports = 1 as number;\n', + 'export const value = 1 as number;\n', ); - const result = spawnSync( - process.execPath, - ['--eval', "require('./probe.ts')"], - { - cwd: fixtureDir, - encoding: 'utf-8', - }, - ); - - return result.status === 0; + await import(pathToFileURL(join(fixtureDir, 'probe.ts')).href); + return true; + } catch { + return false; } finally { rmSync(fixtureDir, { recursive: true, force: true }); } }; -const supportsNativeTypeScript = probeNativeTypeScriptSupport(); +const supportsNativeTypeScript = await probeNativeTypeScriptSupport(); const createFixture = (extension: 'ts' | 'cts') => { const fixtureDir = mkdtempSync(join(tmpdir(), 'rstest-type-module-')); @@ -38,14 +33,6 @@ const createFixture = (extension: 'ts' | 'cts') => { join(fixtureDir, `plugin.${extension}`), 'module.exports = { value: 1 as number };\n', ); - writeFileSync( - join(fixtureDir, 'main.mjs'), - `import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); -const plugin = require('./plugin.${extension}'); -console.log(JSON.stringify(plugin)); -`, - ); return fixtureDir; }; @@ -56,13 +43,13 @@ test.skipIf(!supportsNativeTypeScript)( const fixtureDir = createFixture('ts'); onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true })); - const result = spawnSync(process.execPath, ['main.mjs'], { - cwd: fixtureDir, - encoding: 'utf-8', - }); + const require = createRequire( + pathToFileURL(join(fixtureDir, 'loader.mjs')).href, + ); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain('module is not defined in ES module scope'); + expect(() => require('./plugin.ts')).toThrow( + /module is not defined in ES module scope/, + ); }, ); @@ -72,12 +59,10 @@ test.skipIf(!supportsNativeTypeScript)( const fixtureDir = createFixture('cts'); onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true })); - const result = spawnSync(process.execPath, ['main.mjs'], { - cwd: fixtureDir, - encoding: 'utf-8', - }); + const require = createRequire( + pathToFileURL(join(fixtureDir, 'loader.mjs')).href, + ); - expect(result.status).toBe(0); - expect(result.stdout.trim()).toBe('{"value":1}'); + expect(require('./plugin.cts')).toEqual({ value: 1 }); }, ); diff --git a/e2e/runtime-register/native-fixtures/rstest.config.mts b/e2e/runtime-register/native-fixtures/rstest.config.mts new file mode 100644 index 000000000..1244782f1 --- /dev/null +++ b/e2e/runtime-register/native-fixtures/rstest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['*.test.ts'], + pool: { + maxWorkers: 1, + }, +}); diff --git a/website/docs/en/guide/advanced/troubleshooting.mdx b/website/docs/en/guide/advanced/troubleshooting.mdx index ec9183fc0..d2ce08a1d 100644 --- a/website/docs/en/guide/advanced/troubleshooting.mdx +++ b/website/docs/en/guide/advanced/troubleshooting.mdx @@ -73,6 +73,13 @@ Prefer making the runtime-loaded file match Node.js native module semantics: 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'; diff --git a/website/docs/zh/guide/advanced/troubleshooting.mdx b/website/docs/zh/guide/advanced/troubleshooting.mdx index 41e4a2209..6a1064b90 100644 --- a/website/docs/zh/guide/advanced/troubleshooting.mdx +++ b/website/docs/zh/guide/advanced/troubleshooting.mdx @@ -73,6 +73,13 @@ Rstest 会转换进入 bundle graph 的文件。但后续交给 Node.js 加载 如果这个动态加载依赖自定义转换,需要显式注册该转换。对从 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: + +```bash +npm add -D @swc-node/register +# 或:npm add -D ts-node +``` + ```ts title="test/rstest.setup.ts" import '@swc-node/register'; // 或:import 'ts-node/register'; From f076a598991ff75e3ceb522e5d8a1c130b031e80 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 14:03:41 +0800 Subject: [PATCH 3/7] test: fix runtime register type check --- e2e/runtime-register/index.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/runtime-register/index.test.ts b/e2e/runtime-register/index.test.ts index 59c20abad..011dd4561 100644 --- a/e2e/runtime-register/index.test.ts +++ b/e2e/runtime-register/index.test.ts @@ -1,5 +1,6 @@ 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/'; @@ -10,7 +11,7 @@ const nativeFixtureDir = join(__dirname, 'native-fixtures'); const runFixture = async ( cwd: string, - onTestFinished: (cleanup: () => void | Promise) => void, + onTestFinished: typeof onRstestFinished, ) => { const { expectExecSuccess } = await runRstestCli({ command: 'rstest', From 92a542130930185439ee6d0514887e27c0db65ef Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 14:51:50 +0800 Subject: [PATCH 4/7] test: address runtime troubleshooting review --- .../native-type-module.test.ts | 86 +++++++++++++++++++ .../rstest.config.mts | 0 .../fixtures/exec-argv.test.ts | 8 +- e2e/runtime-register/index.test.ts | 2 +- .../native-type-module.test.ts | 68 --------------- .../en/guide/advanced/troubleshooting.mdx | 6 +- .../zh/guide/advanced/troubleshooting.mdx | 6 +- 7 files changed, 90 insertions(+), 86 deletions(-) create mode 100644 e2e/runtime-register/fixtures-native/native-type-module.test.ts rename e2e/runtime-register/{native-fixtures => fixtures-native}/rstest.config.mts (100%) delete mode 100644 e2e/runtime-register/native-fixtures/native-type-module.test.ts diff --git a/e2e/runtime-register/fixtures-native/native-type-module.test.ts b/e2e/runtime-register/fixtures-native/native-type-module.test.ts new file mode 100644 index 000000000..ec8e07adf --- /dev/null +++ b/e2e/runtime-register/fixtures-native/native-type-module.test.ts @@ -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 }); +}); diff --git a/e2e/runtime-register/native-fixtures/rstest.config.mts b/e2e/runtime-register/fixtures-native/rstest.config.mts similarity index 100% rename from e2e/runtime-register/native-fixtures/rstest.config.mts rename to e2e/runtime-register/fixtures-native/rstest.config.mts diff --git a/e2e/runtime-register/fixtures/exec-argv.test.ts b/e2e/runtime-register/fixtures/exec-argv.test.ts index aa538c283..a8088adca 100644 --- a/e2e/runtime-register/fixtures/exec-argv.test.ts +++ b/e2e/runtime-register/fixtures/exec-argv.test.ts @@ -1,12 +1,6 @@ -import { rmSync } from 'node:fs'; import { expect, test } from '@rstest/core'; -test('passes pool.execArgv node flags to workers', ({ onTestFinished }) => { - const registerFlagPath = process.env.RUNTIME_REGISTER_FLAG_PATH; - if (registerFlagPath) { - onTestFinished(() => rmSync(registerFlagPath, { force: true })); - } - +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'); diff --git a/e2e/runtime-register/index.test.ts b/e2e/runtime-register/index.test.ts index 011dd4561..2d2383ebd 100644 --- a/e2e/runtime-register/index.test.ts +++ b/e2e/runtime-register/index.test.ts @@ -7,7 +7,7 @@ import { runRstestCli } from '../scripts/'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const registerFixtureDir = join(__dirname, 'fixtures'); -const nativeFixtureDir = join(__dirname, 'native-fixtures'); +const nativeFixtureDir = join(__dirname, 'fixtures-native'); const runFixture = async ( cwd: string, diff --git a/e2e/runtime-register/native-fixtures/native-type-module.test.ts b/e2e/runtime-register/native-fixtures/native-type-module.test.ts deleted file mode 100644 index 12202caf0..000000000 --- a/e2e/runtime-register/native-fixtures/native-type-module.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 = await 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.skipIf(!supportsNativeTypeScript)( - 'keeps native node semantics for cjs-style .ts in type module scope', - ({ onTestFinished }) => { - const fixtureDir = createFixture('ts'); - onTestFinished(() => rmSync(fixtureDir, { recursive: true, force: true })); - - const require = createRequire( - pathToFileURL(join(fixtureDir, 'loader.mjs')).href, - ); - - expect(() => require('./plugin.ts')).toThrow( - /module is not defined in ES module scope/, - ); - }, -); - -test.skipIf(!supportsNativeTypeScript)( - 'loads cjs-style TypeScript when the runtime file uses .cts', - ({ onTestFinished }) => { - 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 }); - }, -); diff --git a/website/docs/en/guide/advanced/troubleshooting.mdx b/website/docs/en/guide/advanced/troubleshooting.mdx index d2ce08a1d..d5672a43a 100644 --- a/website/docs/en/guide/advanced/troubleshooting.mdx +++ b/website/docs/en/guide/advanced/troubleshooting.mdx @@ -49,11 +49,7 @@ const require = createRequire(import.meta.url); const plugin = require('./plugin.ts'); ``` -On Node.js versions that support running `.ts` files directly, `require('./plugin.ts')` can still fail because Node.js classifies `plugin.ts` as ESM under the package `type: module` scope: - -```text -ReferenceError: module is not defined in ES module scope -``` +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 diff --git a/website/docs/zh/guide/advanced/troubleshooting.mdx b/website/docs/zh/guide/advanced/troubleshooting.mdx index 6a1064b90..6fdf51cb0 100644 --- a/website/docs/zh/guide/advanced/troubleshooting.mdx +++ b/website/docs/zh/guide/advanced/troubleshooting.mdx @@ -49,11 +49,7 @@ const require = createRequire(import.meta.url); const plugin = require('./plugin.ts'); ``` -在支持直接运行 `.ts` 文件的 Node.js 版本中,`require('./plugin.ts')` 仍然可能失败,因为 Node.js 会根据 `type: module` 的 package scope 将 `plugin.ts` 判定为 ESM: - -```text -ReferenceError: module is not defined in ES module scope -``` +在支持直接运行 `.ts` 文件的 Node.js 版本中,`require('./plugin.ts')` 会遵循 Node.js 原生模块判定。在 `type: module` 的 package scope 下,Node.js 会把 `plugin.ts` 当作 ESM 而不是 CommonJS,因此 `module.exports = ...` 这样的 CommonJS 赋值不会作为 CommonJS 值导出。 ### 原因 From 1342e0441ac529c8475421cf23f6429981d976c0 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 15:36:52 +0800 Subject: [PATCH 5/7] docs: address troubleshooting review comments --- e2e/runtime-register/fixtures/register.mjs | 5 +++++ e2e/runtime-register/fixtures/register.test.ts | 7 ++----- website/docs/en/guide/advanced/troubleshooting.mdx | 4 ++++ website/docs/zh/guide/advanced/troubleshooting.mdx | 6 +++++- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/e2e/runtime-register/fixtures/register.mjs b/e2e/runtime-register/fixtures/register.mjs index 3b2f89cc2..6467f20cc 100644 --- a/e2e/runtime-register/fixtures/register.mjs +++ b/e2e/runtime-register/fixtures/register.mjs @@ -1,3 +1,4 @@ +import { rmSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,6 +8,10 @@ const registerFlagPath = join(tmpdir(), `rstest-register-${process.pid}.txt`); await writeFile(registerFlagPath, 'loaded', 'utf-8'); +process.once('exit', () => { + rmSync(registerFlagPath, { force: true }); +}); + register('./ts-register-loader.mjs', import.meta.url); process.env.RUNTIME_REGISTER_FLAG_PATH = registerFlagPath; diff --git a/e2e/runtime-register/fixtures/register.test.ts b/e2e/runtime-register/fixtures/register.test.ts index 732125cb4..b8a0063fe 100644 --- a/e2e/runtime-register/fixtures/register.test.ts +++ b/e2e/runtime-register/fixtures/register.test.ts @@ -1,15 +1,12 @@ -import { existsSync, rmSync } from 'node:fs'; +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 ({ - onTestFinished, -}) => { +test('runs node register hooks inside test workers', async () => { const registerFlagPath = process.env.RUNTIME_REGISTER_FLAG_PATH!; - onTestFinished(() => rmSync(registerFlagPath, { force: true })); expect(existsSync(registerFlagPath)).toBe(true); diff --git a/website/docs/en/guide/advanced/troubleshooting.mdx b/website/docs/en/guide/advanced/troubleshooting.mdx index d5672a43a..876095bf4 100644 --- a/website/docs/en/guide/advanced/troubleshooting.mdx +++ b/website/docs/en/guide/advanced/troubleshooting.mdx @@ -102,6 +102,10 @@ export default defineConfig({ }); ``` +:::warning +`--import` preloads are only reliable with the default `forks` pool. The `threads` pool creates `node:worker_threads` workers, and Node.js does not run `--import` preloads in those workers. If you use `pool.type: 'threads'`, prefer `setupFiles` for test-time setup, or use `--require` when a CommonJS preload is enough. +::: + ## Named imports from CommonJS dependencies fail ### Symptom diff --git a/website/docs/zh/guide/advanced/troubleshooting.mdx b/website/docs/zh/guide/advanced/troubleshooting.mdx index 6fdf51cb0..b7e0352a5 100644 --- a/website/docs/zh/guide/advanced/troubleshooting.mdx +++ b/website/docs/zh/guide/advanced/troubleshooting.mdx @@ -2,7 +2,7 @@ description: Rstest 常见问题排查,包括迁移和运行时差异。 --- -# Troubleshooting +# 问题排查 本页说明一些由 Rstest 执行模型带来的常见问题。这类问题通常不是断言本身失败,而是 Rstest、Jest、Vitest 与 Node.js 原生运行时行为之间存在差异。 @@ -102,6 +102,10 @@ export default defineConfig({ }); ``` +:::warning +`--import` preload 只在默认的 `forks` pool 中可靠。`threads` pool 会创建 `node:worker_threads` worker,而 Node.js 不会在这些 worker 中执行 `--import` preload。如果使用 `pool.type: 'threads'`,优先使用 `setupFiles` 做测试运行时设置;如果 CommonJS preload 已经足够,也可以使用 `--require`。 +::: + ## 从 CommonJS 依赖导入 named exports 失败 ### 现象 From 1a06c40c497ca65974a7cc81386110e2b2e4b346 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 16:01:11 +0800 Subject: [PATCH 6/7] docs: remove incorrect import preload warning --- website/docs/en/guide/advanced/troubleshooting.mdx | 4 ---- website/docs/zh/guide/advanced/troubleshooting.mdx | 4 ---- 2 files changed, 8 deletions(-) diff --git a/website/docs/en/guide/advanced/troubleshooting.mdx b/website/docs/en/guide/advanced/troubleshooting.mdx index 876095bf4..d5672a43a 100644 --- a/website/docs/en/guide/advanced/troubleshooting.mdx +++ b/website/docs/en/guide/advanced/troubleshooting.mdx @@ -102,10 +102,6 @@ export default defineConfig({ }); ``` -:::warning -`--import` preloads are only reliable with the default `forks` pool. The `threads` pool creates `node:worker_threads` workers, and Node.js does not run `--import` preloads in those workers. If you use `pool.type: 'threads'`, prefer `setupFiles` for test-time setup, or use `--require` when a CommonJS preload is enough. -::: - ## Named imports from CommonJS dependencies fail ### Symptom diff --git a/website/docs/zh/guide/advanced/troubleshooting.mdx b/website/docs/zh/guide/advanced/troubleshooting.mdx index b7e0352a5..9ae853613 100644 --- a/website/docs/zh/guide/advanced/troubleshooting.mdx +++ b/website/docs/zh/guide/advanced/troubleshooting.mdx @@ -102,10 +102,6 @@ export default defineConfig({ }); ``` -:::warning -`--import` preload 只在默认的 `forks` pool 中可靠。`threads` pool 会创建 `node:worker_threads` worker,而 Node.js 不会在这些 worker 中执行 `--import` preload。如果使用 `pool.type: 'threads'`,优先使用 `setupFiles` 做测试运行时设置;如果 CommonJS preload 已经足够,也可以使用 `--require`。 -::: - ## 从 CommonJS 依赖导入 named exports 失败 ### 现象 From 3821995a480042cd6e0de8d89055f2bdbe8fe722 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 18 Jun 2026 16:23:11 +0800 Subject: [PATCH 7/7] docs: clarify runtime dynamic loading behavior --- website/docs/en/guide/advanced/troubleshooting.mdx | 6 +++--- website/docs/zh/guide/advanced/troubleshooting.mdx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/docs/en/guide/advanced/troubleshooting.mdx b/website/docs/en/guide/advanced/troubleshooting.mdx index d5672a43a..a3d973dcb 100644 --- a/website/docs/en/guide/advanced/troubleshooting.mdx +++ b/website/docs/en/guide/advanced/troubleshooting.mdx @@ -12,17 +12,17 @@ Rstest runs tests with a bundle-based execution model. Files that are part of th Some code is still loaded by Node.js at runtime instead of by the bundler. Common examples include: -- late dynamic `require()` calls, +- 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 deliberately 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. +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()` / Node.js loader hooks / externalized modules → expect Node.js native behavior unless you register a loader or change the bundling strategy. +- 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` diff --git a/website/docs/zh/guide/advanced/troubleshooting.mdx b/website/docs/zh/guide/advanced/troubleshooting.mdx index 9ae853613..4fcfe47c2 100644 --- a/website/docs/zh/guide/advanced/troubleshooting.mdx +++ b/website/docs/zh/guide/advanced/troubleshooting.mdx @@ -12,17 +12,17 @@ Rstest 采用基于 bundle 的执行模型来运行测试。进入 bundle graph 有些代码仍然会在运行时交给 Node.js 加载,而不是交给 bundler 处理。常见场景包括: -- 后续动态执行的 `require()` 调用; +- 后续动态执行的 `require()` 或 `import(dynamicPath)` 调用; - 被 external 到 bundle 之外的模块; - 通过自定义 Node.js loader 或 register hook 加载的代码; - test bundle 启动后,再通过 Node.js API 加载的代码。 -对于这些部分,Rstest 会有意保留 Node.js 原生语义。这让 Node.js flags、`--require`、`--import` 和 `node:module` register hooks 可以在 test worker 中使用,但也意味着 Rstest 不会模拟 Jest、`ts-jest` 或 Vitest 的每一种 transform-time 便利行为。 +对于这些部分,Rstest 目前会保留 Node.js 原生语义。这让 Node.js flags、`--require`、`--import` 和 `node:module` register hooks 可以在 test worker 中使用,但也意味着 Rstest 不会模拟 Jest、`ts-jest` 或 Vitest 的每一种 transform-time 便利行为。 经验规则是: - 静态 import 和进入 bundle graph 的模块 → 交给 Rstest 转换或打包。 -- 后续动态 `require()` / Node.js loader hook / 被 external 的模块 → 除非注册 loader 或调整打包策略,否则按 Node.js 原生行为处理。 +- 后续动态 `require()` 或 `import(dynamicPath)` / Node.js loader hook / 被 external 的模块 → 除非注册 loader 或调整打包策略,否则按 Node.js 原生行为处理。 ## `type: module` 中运行时 `require()` CommonJS 风格的 TypeScript 文件失败