-
-
Notifications
You must be signed in to change notification settings - Fork 29
docs: add runtime troubleshooting guide #1436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a9e1397
docs: add runtime troubleshooting guide
9aoy e54f27f
docs: address runtime troubleshooting review
9aoy f076a59
test: fix runtime register type check
9aoy 92a5421
test: address runtime troubleshooting review
9aoy 1342e04
docs: address troubleshooting review comments
9aoy a097381
Merge remote-tracking branch 'origin/main' into 9aoy/docs-troubleshoo…
9aoy 1a06c40
docs: remove incorrect import preload warning
9aoy 3821995
docs: clarify runtime dynamic loading behavior
9aoy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
86 changes: 86 additions & 0 deletions
86
e2e/runtime-register/fixtures-native/native-type-module.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
|
|
||
| process.once('exit', () => { | ||
| rmSync(registerFlagPath, { force: true }); | ||
| }); | ||
|
|
||
| register('./ts-register-loader.mjs', import.meta.url); | ||
|
|
||
| process.env.RUNTIME_REGISTER_FLAG_PATH = registerFlagPath; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| ], | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| module.exports = { | ||
| runtimeValue: 'loaded by cjs require hook', | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const runtimeValue = 'loaded by node register'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| ["debugging", "ci", "profiling"] | ||
| ["debugging", "ci", "profiling", "troubleshooting"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
|
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'], | ||
|
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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| ["debugging", "ci", "profiling"] | ||
| ["debugging", "ci", "profiling", "troubleshooting"] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.