From 80df9ca59bf4d9760be0a57013d3332a3be3078c Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Fri, 27 Feb 2026 19:05:15 +0800 Subject: [PATCH 1/5] feat: support `resolve.moduleNameMapper` --- .../module-name-mapper/rstest.config.ts | 21 +++ .../module-name-mapper/src/moduleA.ts | 1 + .../module-name-mapper/src/moduleB.ts | 1 + .../module-name-mapper/src/utils/helper.ts | 1 + .../module-name-mapper/tests/mapper.test.ts | 15 ++ e2e/browser-mode/fixtures/ports.ts | 1 + e2e/browser-mode/moduleNameMapper.test.ts | 12 ++ .../fixtures/moduleNameMapper/index.test.ts | 15 ++ .../moduleNameMapper/rstest.config.ts | 13 ++ .../fixtures/moduleNameMapper/src/moduleA.ts | 1 + .../fixtures/moduleNameMapper/src/moduleB.ts | 1 + .../moduleNameMapper/src/utils/helper.ts | 1 + e2e/build/index.test.ts | 19 ++- packages/browser/src/hostController.ts | 17 ++- packages/browser/src/index.ts | 5 +- packages/core/src/core/browserLoader.ts | 6 +- packages/core/src/core/listTests.ts | 9 +- packages/core/src/core/plugins/external.ts | 32 ++++- .../core/src/core/plugins/moduleNameMapper.ts | 130 ++++++++++++++++++ packages/core/src/core/rsbuild.ts | 2 + packages/core/src/core/runTests.ts | 10 +- packages/core/src/env.d.ts | 16 ++- packages/core/src/types/browser.ts | 4 + packages/core/src/types/config.ts | 30 +++- website/docs/en/config/build/resolve.mdx | 51 +++++++ website/docs/en/guide/migration/jest.mdx | 36 ++--- website/docs/zh/config/build/resolve.mdx | 51 +++++++ website/docs/zh/guide/migration/jest.mdx | 36 ++--- website/rspress.config.ts | 1 + website/theme/components/ConfigOverview.tsx | 1 + 30 files changed, 479 insertions(+), 60 deletions(-) create mode 100644 e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts create mode 100644 e2e/browser-mode/fixtures/module-name-mapper/src/moduleA.ts create mode 100644 e2e/browser-mode/fixtures/module-name-mapper/src/moduleB.ts create mode 100644 e2e/browser-mode/fixtures/module-name-mapper/src/utils/helper.ts create mode 100644 e2e/browser-mode/fixtures/module-name-mapper/tests/mapper.test.ts create mode 100644 e2e/browser-mode/moduleNameMapper.test.ts create mode 100644 e2e/build/fixtures/moduleNameMapper/index.test.ts create mode 100644 e2e/build/fixtures/moduleNameMapper/rstest.config.ts create mode 100644 e2e/build/fixtures/moduleNameMapper/src/moduleA.ts create mode 100644 e2e/build/fixtures/moduleNameMapper/src/moduleB.ts create mode 100644 e2e/build/fixtures/moduleNameMapper/src/utils/helper.ts create mode 100644 packages/core/src/core/plugins/moduleNameMapper.ts diff --git a/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts b/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts new file mode 100644 index 000000000..071450e31 --- /dev/null +++ b/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@rstest/core'; +import { BROWSER_PORTS } from '../ports'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + headless: true, + port: BROWSER_PORTS['module-name-mapper'], + }, + include: ['tests/**/*.test.ts'], + testTimeout: 30000, + resolve: { + moduleNameMapper: { + // Map module-a to module-b using exact match + '^module-a$': '/src/moduleB.ts', + // Map @utils/* to ./src/utils/* using capture groups + '^@utils/(.*)$': '/src/utils/$1', + }, + }, +}); diff --git a/e2e/browser-mode/fixtures/module-name-mapper/src/moduleA.ts b/e2e/browser-mode/fixtures/module-name-mapper/src/moduleA.ts new file mode 100644 index 000000000..699e1d2a1 --- /dev/null +++ b/e2e/browser-mode/fixtures/module-name-mapper/src/moduleA.ts @@ -0,0 +1 @@ +export const value = 'module-a'; diff --git a/e2e/browser-mode/fixtures/module-name-mapper/src/moduleB.ts b/e2e/browser-mode/fixtures/module-name-mapper/src/moduleB.ts new file mode 100644 index 000000000..4bbc55717 --- /dev/null +++ b/e2e/browser-mode/fixtures/module-name-mapper/src/moduleB.ts @@ -0,0 +1 @@ +export const value = 'module-b'; diff --git a/e2e/browser-mode/fixtures/module-name-mapper/src/utils/helper.ts b/e2e/browser-mode/fixtures/module-name-mapper/src/utils/helper.ts new file mode 100644 index 000000000..db669e7e3 --- /dev/null +++ b/e2e/browser-mode/fixtures/module-name-mapper/src/utils/helper.ts @@ -0,0 +1 @@ +export const helper = 'helper-function'; diff --git a/e2e/browser-mode/fixtures/module-name-mapper/tests/mapper.test.ts b/e2e/browser-mode/fixtures/module-name-mapper/tests/mapper.test.ts new file mode 100644 index 000000000..819a02430 --- /dev/null +++ b/e2e/browser-mode/fixtures/module-name-mapper/tests/mapper.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from '@rstest/core'; +// This should resolve to ./src/utils/helper.ts +// @ts-expect-error - moduleNameMapper redirect +import { helper } from '@utils/helper'; +// This should resolve to module-b due to moduleNameMapper +// @ts-expect-error - moduleNameMapper redirect +import { value } from 'module-a'; + +it('moduleNameMapper should redirect module-a to module-b', () => { + expect(value).toBe('module-b'); +}); + +it('moduleNameMapper should support capture groups', () => { + expect(helper).toBe('helper-function'); +}); diff --git a/e2e/browser-mode/fixtures/ports.ts b/e2e/browser-mode/fixtures/ports.ts index a08bfb217..7a65d300e 100644 --- a/e2e/browser-mode/fixtures/ports.ts +++ b/e2e/browser-mode/fixtures/ports.ts @@ -20,4 +20,5 @@ export const BROWSER_PORTS = { 'viewport-preset': 5216, reporter: 5220, 'reporter-watch': 5222, + 'module-name-mapper': 5224, } as const; diff --git a/e2e/browser-mode/moduleNameMapper.test.ts b/e2e/browser-mode/moduleNameMapper.test.ts new file mode 100644 index 000000000..6bfa99bcb --- /dev/null +++ b/e2e/browser-mode/moduleNameMapper.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { runBrowserCli } from './utils'; + +describe('browser mode - moduleNameMapper', () => { + it('should resolve modules using moduleNameMapper config', async () => { + const { expectExecSuccess, cli } = + await runBrowserCli('module-name-mapper'); + + await expectExecSuccess(); + expect(cli.stdout).toMatch(/Tests.*2 passed/); + }); +}); diff --git a/e2e/build/fixtures/moduleNameMapper/index.test.ts b/e2e/build/fixtures/moduleNameMapper/index.test.ts new file mode 100644 index 000000000..cf3856a00 --- /dev/null +++ b/e2e/build/fixtures/moduleNameMapper/index.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from '@rstest/core'; +// This should resolve to ./src/utils/helper.ts +// @ts-expect-error +import { helper } from '@utils/helper'; +// This should resolve to module-b due to moduleNameMapper +// @ts-expect-error +import { value } from 'module-a'; + +it('moduleNameMapper should redirect module-a to module-b', () => { + expect(value).toBe('module-b'); +}); + +it('moduleNameMapper should support capture groups', () => { + expect(helper).toBe('helper-function'); +}); diff --git a/e2e/build/fixtures/moduleNameMapper/rstest.config.ts b/e2e/build/fixtures/moduleNameMapper/rstest.config.ts new file mode 100644 index 000000000..66d7afeca --- /dev/null +++ b/e2e/build/fixtures/moduleNameMapper/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'node', + resolve: { + moduleNameMapper: { + // Map module-a to module-b using exact match + '^module-a$': '/src/moduleB.ts', + // Map @utils/* to ./src/utils/* using capture groups + '^@utils/(.*)$': '/src/utils/$1', + }, + }, +}); diff --git a/e2e/build/fixtures/moduleNameMapper/src/moduleA.ts b/e2e/build/fixtures/moduleNameMapper/src/moduleA.ts new file mode 100644 index 000000000..699e1d2a1 --- /dev/null +++ b/e2e/build/fixtures/moduleNameMapper/src/moduleA.ts @@ -0,0 +1 @@ +export const value = 'module-a'; diff --git a/e2e/build/fixtures/moduleNameMapper/src/moduleB.ts b/e2e/build/fixtures/moduleNameMapper/src/moduleB.ts new file mode 100644 index 000000000..4bbc55717 --- /dev/null +++ b/e2e/build/fixtures/moduleNameMapper/src/moduleB.ts @@ -0,0 +1 @@ +export const value = 'module-b'; diff --git a/e2e/build/fixtures/moduleNameMapper/src/utils/helper.ts b/e2e/build/fixtures/moduleNameMapper/src/utils/helper.ts new file mode 100644 index 000000000..db669e7e3 --- /dev/null +++ b/e2e/build/fixtures/moduleNameMapper/src/utils/helper.ts @@ -0,0 +1 @@ +export const helper = 'helper-function'; diff --git a/e2e/build/index.test.ts b/e2e/build/index.test.ts index 5c5b93903..a8a12a877 100644 --- a/e2e/build/index.test.ts +++ b/e2e/build/index.test.ts @@ -13,16 +13,25 @@ describe('test build config', () => { { name: 'plugin' }, { name: 'tools/rspack' }, { name: 'decorators' }, - ])('$name config should work correctly', async ({ name }, { - onTestFinished, - }) => { + { name: 'moduleNameMapper' }, + { + name: 'moduleNameMapperHappyDom', + fixtureDir: 'moduleNameMapper', + testEnvironment: 'happy-dom', + }, + ])('$name config should work correctly', async ({ + name, + fixtureDir = name, + testEnvironment, + }, { onTestFinished }) => { const { expectExecSuccess } = await runRstestCli({ command: 'rstest', args: [ 'run', - `fixtures/${name}`, + `fixtures/${fixtureDir}`, '-c', - `fixtures/${name}/rstest.config.ts`, + `fixtures/${fixtureDir}/rstest.config.ts`, + ...(testEnvironment ? ['--testEnvironment', testEnvironment] : []), ], onTestFinished, options: { diff --git a/packages/browser/src/hostController.ts b/packages/browser/src/hostController.ts index ea28463ad..1dd7fa963 100644 --- a/packages/browser/src/hostController.ts +++ b/packages/browser/src/hostController.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; import { fileURLToPath } from 'node:url'; +import type { RsbuildPlugin } from '@rstest/core'; import { type BrowserTestRunOptions, type BrowserTestRunResult, @@ -869,6 +870,7 @@ const createBrowserRuntime = async ({ containerDistPath, containerDevServer, forceHeadless, + builtinRsbuildPlugins, }: { context: Rstest; manifestPath: string; @@ -880,6 +882,9 @@ const createBrowserRuntime = async ({ containerDevServer?: string; /** Force headless mode regardless of user config (used for list command) */ forceHeadless?: boolean; + builtinRsbuildPlugins: { + pluginModuleNameMapper: RsbuildPlugin; + }; }): Promise => { const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({ [manifestPath]: manifestSource, @@ -951,6 +956,7 @@ const createBrowserRuntime = async ({ // Add plugin to merge user Rsbuild config with rstest required config rsbuildInstance.addPlugins([ + builtinRsbuildPlugins.pluginModuleNameMapper, { name: 'rstest:browser-user-config', setup(api) { @@ -1337,9 +1343,9 @@ async function resolveProjectEntries( export const runBrowserController = async ( context: Rstest, - options?: BrowserTestRunOptions, + options: BrowserTestRunOptions, ): Promise => { - const { skipOnTestRunEnd = false } = options ?? {}; + const { skipOnTestRunEnd = false, builtinRsbuildPlugins } = options; const buildStart = Date.now(); const browserProjects = getBrowserProjects(context); const useHeadlessDirect = browserProjects.every( @@ -1540,6 +1546,7 @@ export const runBrowserController = async ( : undefined, containerDistPath, containerDevServer, + builtinRsbuildPlugins, }); } catch (error) { return failWithError(error, async () => { @@ -2525,8 +2532,11 @@ export type ListBrowserTestsResult = { */ export const listBrowserTests = async ( context: Rstest, - options?: { + options: { shardedEntries?: Map }>; + builtinRsbuildPlugins: { + pluginModuleNameMapper: RsbuildPlugin; + }; }, ): Promise => { const projectEntries = await resolveProjectEntries( @@ -2570,6 +2580,7 @@ export const listBrowserTests = async ( containerDistPath: undefined, containerDevServer: undefined, forceHeadless: true, // Always use headless for list command + builtinRsbuildPlugins: options.builtinRsbuildPlugins, }); } catch (error) { logger.error( diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b11c3e228..c8a780e57 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -19,15 +19,16 @@ export { export async function runBrowserTests( context: Rstest, - options?: BrowserTestRunOptions, + options: BrowserTestRunOptions, ): Promise { return runBrowserController(context, options); } export async function listBrowserTests( context: Rstest, + options: BrowserTestRunOptions, ): Promise { - return listBrowserTestsImpl(context); + return listBrowserTestsImpl(context, options); } export type { diff --git a/packages/core/src/core/browserLoader.ts b/packages/core/src/core/browserLoader.ts index 18b52462e..28a5b1df6 100644 --- a/packages/core/src/core/browserLoader.ts +++ b/packages/core/src/core/browserLoader.ts @@ -16,13 +16,11 @@ export interface BrowserModule { validateBrowserConfig: (context: unknown) => void; runBrowserTests: ( context: unknown, - options?: BrowserTestRunOptions, + options: BrowserTestRunOptions, ) => Promise; listBrowserTests: ( context: unknown, - options?: { - shardedEntries?: Map }>; - }, + options: BrowserTestRunOptions, ) => Promise<{ list: ListCommandResult[]; close: () => Promise; diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index cc2c45d7e..244256c4e 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -194,7 +194,14 @@ const collectBrowserTests = async ({ projectRoots, }); validateBrowserConfig(context); - return listBrowserTests(context, { shardedEntries }); + const { pluginModuleNameMapper } = await import('./plugins/moduleNameMapper'); + + return listBrowserTests(context, { + shardedEntries, + builtinRsbuildPlugins: { + pluginModuleNameMapper: pluginModuleNameMapper(context), + }, + }); }; const collectTestFiles = async ({ diff --git a/packages/core/src/core/plugins/external.ts b/packages/core/src/core/plugins/external.ts index 7058d7168..e09f7c1b1 100644 --- a/packages/core/src/core/plugins/external.ts +++ b/packages/core/src/core/plugins/external.ts @@ -3,8 +3,27 @@ import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; import type { RstestContext } from '../../types'; import { ADDITIONAL_NODE_BUILTINS, castArray } from '../../utils'; +/** + * Check if a request matches any moduleNameMapper pattern. + * If it does, don't externalize it - let NormalModuleReplacementPlugin handle it. + */ +const matchesModuleNameMapper = ( + request: string, + moduleNameMapper: Record | undefined, +): boolean => { + if (!moduleNameMapper) return false; + + for (const pattern of Object.keys(moduleNameMapper)) { + if (new RegExp(pattern).test(request)) { + return true; + } + } + return false; +}; + const autoExternalNodeModules: ( outputModule: boolean, + moduleNameMapper: Record | undefined, ) => ( data: Rspack.ExternalItemFunctionData, callback: ( @@ -13,7 +32,7 @@ const autoExternalNodeModules: ( type?: Rspack.ExternalsType, ) => void, ) => void = - (outputModule) => + (outputModule, moduleNameMapper) => ({ context, request, dependencyType, getResolve }, callback) => { if (!request) { return callback(); @@ -24,6 +43,11 @@ const autoExternalNodeModules: ( return callback(); } + // If request matches moduleNameMapper, don't externalize - let it be transformed + if (matchesModuleNameMapper(request, moduleNameMapper)) { + return callback(); + } + const doExternal = (externalPath: string = request) => { callback( undefined, @@ -102,14 +126,16 @@ export const pluginExternal: (context: RstestContext) => RsbuildPlugin = ( setup: (api) => { api.modifyEnvironmentConfig((config, { mergeEnvironmentConfig, name }) => { const { - normalizedConfig: { testEnvironment }, + normalizedConfig: { testEnvironment, resolve }, outputModule, } = context.projects.find((p) => p.environmentName === name)!; + const moduleNameMapper = resolve?.moduleNameMapper; + return mergeEnvironmentConfig(config, { output: { externals: testEnvironment.name === 'node' - ? [autoExternalNodeModules(outputModule)] + ? [autoExternalNodeModules(outputModule, moduleNameMapper)] : undefined, }, tools: { diff --git a/packages/core/src/core/plugins/moduleNameMapper.ts b/packages/core/src/core/plugins/moduleNameMapper.ts new file mode 100644 index 000000000..9a4de7cf3 --- /dev/null +++ b/packages/core/src/core/plugins/moduleNameMapper.ts @@ -0,0 +1,130 @@ +import { dirname } from 'node:path'; +import { type RsbuildPlugin, type Rspack, rspack } from '@rsbuild/core'; +import type { RstestContext } from '../../types'; + +export type ModuleNameMapperConfig = Record; + +/** + * Replace `` token in the replacement path with the actual root directory. + * In Jest, refers to the directory containing the config file. + */ +export function replaceRootDir(value: string, rootDir: string): string { + return value.replace(//g, rootDir); +} + +export interface CreateModuleNameMapperPluginsOptions { + /** The moduleNameMapper configuration */ + moduleNameMapper: ModuleNameMapperConfig; + /** The root directory for `` token replacement */ + rootDir: string; + /** The rspack instance to use for creating plugins (defaults to imported rspack) */ + rspack?: typeof rspack; +} + +/** + * Create NormalModuleReplacementPlugin instances for moduleNameMapper configuration. + * This is a shared utility used by both node and browser modes. + * + * @returns Array of NormalModuleReplacementPlugin instances + */ +export function createModuleNameMapperPlugins( + options: CreateModuleNameMapperPluginsOptions, +): Rspack.WebpackPluginInstance[] { + const { + moduleNameMapper, + rootDir, + rspack: rspackInstance = rspack, + } = options; + const plugins: Rspack.WebpackPluginInstance[] = []; + + for (const [pattern, replacement] of Object.entries(moduleNameMapper)) { + const resourceRegExp = new RegExp(pattern); + const replacements = Array.isArray(replacement) + ? replacement + : [replacement]; + + // Process each replacement with rootDir substitution + const processedReplacements = replacements.map((r) => + replaceRootDir(r, rootDir), + ); + + // Use the first replacement path + // Note: Unlike Jest, we don't try to resolve each path in order + // because NormalModuleReplacementPlugin doesn't support async resolution + // Users should ensure the first path exists or use a single path + const newResource = processedReplacements[0]!; + + plugins.push( + new rspackInstance.NormalModuleReplacementPlugin( + resourceRegExp, + (resource: Rspack.ResolveData) => { + // Apply capture group replacements ($1, $2, etc.) + const match = resource.request?.match(resourceRegExp); + if (match) { + let resolvedPath = newResource; + // Replace capture group references with matched values + for (let i = 1; i < match.length; i++) { + resolvedPath = resolvedPath.replace( + new RegExp(`\\$${i}`, 'g'), + match[i] || '', + ); + } + resource.request = resolvedPath; + } + }, + ), + ); + } + + return plugins; +} + +/** + * Apply module name mapper using rspack.NormalModuleReplacementPlugin. + * + * This is similar to Jest's moduleNameMapper configuration. + * - Keys are regex patterns to match the module request + * - Values are replacement paths (string or array of strings) + * - Capture groups ($1, $2, etc.) are supported + * - `` token is replaced with the directory containing the config file + * + * @see https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring + */ +export const pluginModuleNameMapper: (context: RstestContext) => RsbuildPlugin = + (context) => ({ + name: 'rstest:module-name-mapper', + setup: (api) => { + api.modifyRspackConfig((config, { environment }) => { + const project = context.projects.find( + (p) => p.environmentName === environment.name, + ); + + if (!project) { + return config; + } + + const moduleNameMapper = + project.normalizedConfig.resolve?.moduleNameMapper; + + if (!moduleNameMapper || Object.keys(moduleNameMapper).length === 0) { + return config; + } + + // Use config file directory for , falling back to project root + // This aligns with Jest's behavior where is the config directory + const configDir = project.configFilePath + ? dirname(project.configFilePath) + : project.rootPath; + + config.plugins ??= []; + + const mapperPlugins = createModuleNameMapperPlugins({ + moduleNameMapper, + rootDir: configDir, + }); + config.plugins.push(...mapperPlugins); + + return config; + }); + }, + }); diff --git a/packages/core/src/core/rsbuild.ts b/packages/core/src/core/rsbuild.ts index 2970f9cce..f3c8ff179 100644 --- a/packages/core/src/core/rsbuild.ts +++ b/packages/core/src/core/rsbuild.ts @@ -23,6 +23,7 @@ import { pluginIgnoreResolveError } from './plugins/ignoreResolveError'; import { pluginInspect } from './plugins/inspect'; import { pluginMockRuntime } from './plugins/mockRuntime'; import { pluginCacheControl } from './plugins/moduleCacheControl'; +import { pluginModuleNameMapper } from './plugins/moduleNameMapper'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -122,6 +123,7 @@ export const prepareRsbuild = async ( isWatch: command === 'watch', }), pluginExternal(context), + pluginModuleNameMapper(context), !isolate ? pluginCacheControl( Object.values({ diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index 4fb983243..f3677e23e 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -25,14 +25,20 @@ import type { Rstest } from './rstest'; async function runBrowserModeTests( context: Rstest, browserProjects: typeof context.projects, - options: BrowserTestRunOptions, + options: Omit, ): Promise { const projectRoots = browserProjects.map((p) => p.rootPath); const { validateBrowserConfig, runBrowserTests } = await loadBrowserModule({ projectRoots, }); validateBrowserConfig(context); - return runBrowserTests(context, options); + const { pluginModuleNameMapper } = await import('./plugins/moduleNameMapper'); + return runBrowserTests(context, { + ...options, + builtinRsbuildPlugins: { + pluginModuleNameMapper: pluginModuleNameMapper(context), + }, + }); } export async function runTests(context: Rstest): Promise { diff --git a/packages/core/src/env.d.ts b/packages/core/src/env.d.ts index ec1bd7da3..9f43deb7d 100644 --- a/packages/core/src/env.d.ts +++ b/packages/core/src/env.d.ts @@ -9,11 +9,21 @@ declare const PLAYWRIGHT_VERSION: string; * The actual types come from the package when installed. */ declare module '@rstest/browser' { - import type { ListCommandResult, RstestContext } from './types'; + import type { + ListCommandResult, + RstestContext, + BrowserTestRunOptions, + } from './types'; export function validateBrowserConfig(context: RstestContext): void; - export function runBrowserTests(context: RstestContext): Promise; - export function listBrowserTests(context: RstestContext): Promise<{ + export function runBrowserTests( + context: RstestContext, + options: BrowserTestRunOptions, + ): Promise; + export function listBrowserTests( + context: RstestContext, + options: BrowserTestRunOptions, + ): Promise<{ list: ListCommandResult[]; close: () => Promise; }>; diff --git a/packages/core/src/types/browser.ts b/packages/core/src/types/browser.ts index 83e9d4ba1..02ea57ce5 100644 --- a/packages/core/src/types/browser.ts +++ b/packages/core/src/types/browser.ts @@ -1,3 +1,4 @@ +import type { RsbuildPlugin } from '@rsbuild/core'; import type { TestFileResult, TestResult } from './testSuite'; /** @@ -15,6 +16,9 @@ export interface BrowserTestRunOptions { * Key is project environmentName. */ shardedEntries?: Map }>; + builtinRsbuildPlugins: { + pluginModuleNameMapper: RsbuildPlugin; + }; } /** diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index ccc17d29c..27a20a2f4 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -439,7 +439,35 @@ export interface RstestConfig { 'cssModules' | 'externals' | 'cleanDistPath' | 'module' >; - resolve?: RsbuildConfig['resolve']; + resolve?: RsbuildConfig['resolve'] & { + /** + * A map from regular expressions to module names or to arrays of module names + * that allow to stub out resources with a single module. + * + * This is similar to Jest's `moduleNameMapper` configuration. + * Keys are regex patterns and values are replacement paths. + * Use `$1`, `$2`, etc. to reference capture groups. + * Use `` as a string token to reference the root directory. + * + * @example + * ```ts + * { + * resolve: { + * moduleNameMapper: { + * // Map lodash to lodash-es + * '^lodash$': 'lodash-es', + * // Map @components/* to src/components/* + * '^@components/(.*)$': '/src/components/$1', + * // Map image imports to a stub + * '\\.(jpg|jpeg|png|gif)$': '/__mocks__/fileMock.js', + * } + * } + * } + * ``` + * @see https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring + */ + moduleNameMapper?: Record; + }; tools?: Pick< NonNullable, diff --git a/website/docs/en/config/build/resolve.mdx b/website/docs/en/config/build/resolve.mdx index 515621382..3da0016ac 100644 --- a/website/docs/en/config/build/resolve.mdx +++ b/website/docs/en/config/build/resolve.mdx @@ -21,3 +21,54 @@ Force Rstest to resolve the specified packages from project root, which is usefu ## resolve.extensions Automatically resolve file extensions when importing modules. This means you can import files without explicitly writing their extensions. + +## resolve.moduleNameMapper + +- **Type:** `Record` +- **Default:** `undefined` + +A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module. This is similar to Jest's [moduleNameMapper](https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring) configuration. + +This is useful for: + +- Mocking modules in tests +- Redirecting module paths +- Stubbing out static assets like images or styles + +### Basic usage + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + resolve: { + moduleNameMapper: { + // Map exact module name + '^module-a$': '/mocks/module-a.ts', + // Map with capture groups + '^@utils/(.*)$': '/src/utils/$1', + // Stub CSS imports + '\\.(css|less|scss)$': 'identity-obj-proxy', + }, + }, +}); +``` + +### Example: Path aliasing with capture groups + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + resolve: { + moduleNameMapper: { + // @components/Button -> ./src/components/Button + '^@components/(.*)$': '/src/components/$1', + }, + }, +}); +``` + +:::tip +For simple path aliasing, consider using TypeScript's `paths` or [resolve.alias](#resolvealias) in `tsconfig.json` instead, which provides better performance and IDE support. +::: diff --git a/website/docs/en/guide/migration/jest.mdx b/website/docs/en/guide/migration/jest.mdx index 573ebb266..d44a55fa7 100644 --- a/website/docs/en/guide/migration/jest.mdx +++ b/website/docs/en/guide/migration/jest.mdx @@ -45,24 +45,24 @@ export default defineConfig({ Here are some common Jest configurations and their Rstest equivalents: -| Jest Configuration | Rstest Equivalent | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `testRegex` | [`include`](/config/test/include) | -| `testMatch` | [`include`](/config/test/include) | -| `testPathIgnorePatterns` | [`exclude`](/config/test/exclude) | -| `transformIgnorePatterns` | [`output.externals`](/config/build/output#outputexternals)、[`source.exclude`](/config/build/source#sourceexclude) | -| `displayName` | [`name`](/config/test/name) | -| `rootDir` | [`root`](/config/test/root) | -| `setupFilesAfterEnv` | [`setupFiles`](/config/test/setup-files) | -| `verbose` | [`verbose-reporter`](/config/test/reporters#verbose-reporter) | -| `injectGlobals` | [`globals`](/config/test/globals) | -| `moduleNameMapper` | [`resolve.alias`](/config/build/resolve#resolvealias) | -| `maxWorkers` | [`pool.maxWorkers`](/config/test/pool) | -| `collectCoverage` | [`coverage.enabled`](/config/test/coverage#enabled) | -| `coverageDirectory` | [`coverage.reportsDirectory`](/config/test/coverage#reportsdirectory) | -| `coverageProvider` | [`coverage.provider`](/config/test/coverage#provider) | -| `coveragePathIgnorePatterns` | [`coverage.exclude`](/config/test/coverage#exclude) | -| `coverageThreshold` | [`coverage.thresholds`](/config/test/coverage#thresholds) | +| Jest Configuration | Rstest Equivalent | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `testRegex` | [`include`](/config/test/include) | +| `testMatch` | [`include`](/config/test/include) | +| `testPathIgnorePatterns` | [`exclude`](/config/test/exclude) | +| `transformIgnorePatterns` | [`output.externals`](/config/build/output#outputexternals)、[`source.exclude`](/config/build/source#sourceexclude) | +| `displayName` | [`name`](/config/test/name) | +| `rootDir` | [`root`](/config/test/root) | +| `setupFilesAfterEnv` | [`setupFiles`](/config/test/setup-files) | +| `verbose` | [`verbose-reporter`](/config/test/reporters#verbose-reporter) | +| `injectGlobals` | [`globals`](/config/test/globals) | +| `moduleNameMapper` | [`resolve.alias`](/config/build/resolve#resolvealias)、[`resolve.moduleNameMapper`](/config/build/resolve#resolvemodulenamemapper) | +| `maxWorkers` | [`pool.maxWorkers`](/config/test/pool) | +| `collectCoverage` | [`coverage.enabled`](/config/test/coverage#enabled) | +| `coverageDirectory` | [`coverage.reportsDirectory`](/config/test/coverage#reportsdirectory) | +| `coverageProvider` | [`coverage.provider`](/config/test/coverage#provider) | +| `coveragePathIgnorePatterns` | [`coverage.exclude`](/config/test/coverage#exclude) | +| `coverageThreshold` | [`coverage.thresholds`](/config/test/coverage#thresholds) | For more details, please refer to the [Configuration](/config) section. diff --git a/website/docs/zh/config/build/resolve.mdx b/website/docs/zh/config/build/resolve.mdx index dfa85a2ae..13d3e73c2 100644 --- a/website/docs/zh/config/build/resolve.mdx +++ b/website/docs/zh/config/build/resolve.mdx @@ -21,3 +21,54 @@ import { RsbuildDocBadge } from '@components/RsbuildDocBadge'; ## resolve.extensions 自动添加导入文件的扩展名。这意味着你可以导入文件,而不需要显式地写它们的扩展名。 + +## resolve.moduleNameMapper + +- **类型:** `Record` +- **默认值:** `undefined` + +从正则表达式到模块名称或模块名称数组的映射,允许用单个模块替换多个模块。这类似于 Jest 的 [moduleNameMapper](https://jestjs.io/zh-Hans/docs/configuration#modulenamemapper-objectstring-string--arraystring) 配置。 + +这在以下场景中非常有用: + +- 在测试中 mock 模块 +- 重定向模块路径 +- 替换图片或样式等静态资源 + +### 基本用法 + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + resolve: { + moduleNameMapper: { + // 精确匹配模块名 + '^module-a$': '/mocks/module-a.ts', + // 使用捕获组进行匹配 + '^@utils/(.*)$': '/src/utils/$1', + // 存根 CSS 导入 + '\\.(css|less|scss)$': 'identity-obj-proxy', + }, + }, +}); +``` + +### 示例:使用捕获组进行路径别名 + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + resolve: { + moduleNameMapper: { + // @components/Button -> ./src/components/Button + '^@components/(.*)$': '/src/components/$1', + }, + }, +}); +``` + +:::tip +对于简单的路径别名,建议使用 TypeScript 的 `tsconfig.json` 中的 `paths` 或 [resolve.alias](#resolvealias) 配置,它们提供更好的性能和 IDE 支持。 +::: diff --git a/website/docs/zh/guide/migration/jest.mdx b/website/docs/zh/guide/migration/jest.mdx index 0b8ddb23b..53eb1f50a 100644 --- a/website/docs/zh/guide/migration/jest.mdx +++ b/website/docs/zh/guide/migration/jest.mdx @@ -45,24 +45,24 @@ export default defineConfig({ 以下是一些常见的 Jest 配置及其对应的 Rstest 配置: -| Jest 配置 | Rstest 对等配置 | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `testRegex` | [`include`](/config/test/include) | -| `testMatch` | [`include`](/config/test/include) | -| `testPathIgnorePatterns` | [`exclude`](/config/test/exclude) | -| `transformIgnorePatterns` | [`output.externals`](/config/build/output#outputexternals)、[`source.exclude`](/config/build/source#sourceexclude) | -| `displayName` | [`name`](/config/test/name) | -| `rootDir` | [`root`](/config/test/root) | -| `setupFilesAfterEnv` | [`setupFiles`](/config/test/setup-files) | -| `verbose` | [`verbose-reporter`](/config/test/reporters#verbose-reporter) | -| `injectGlobals` | [`globals`](/config/test/globals) | -| `moduleNameMapper` | [`resolve.alias`](/config/build/resolve#resolvealias) | -| `maxWorkers` | [`pool.maxWorkers`](/config/test/pool) | -| `collectCoverage` | [`coverage.enabled`](/config/test/coverage#enabled) | -| `coverageDirectory` | [`coverage.reportsDirectory`](/config/test/coverage#reportsdirectory) | -| `coverageProvider` | [`coverage.provider`](/config/test/coverage#provider) | -| `coveragePathIgnorePatterns` | [`coverage.exclude`](/config/test/coverage#exclude) | -| `coverageThreshold` | [`coverage.thresholds`](/config/test/coverage#thresholds) | +| Jest 配置 | Rstest 对等配置 | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `testRegex` | [`include`](/config/test/include) | +| `testMatch` | [`include`](/config/test/include) | +| `testPathIgnorePatterns` | [`exclude`](/config/test/exclude) | +| `transformIgnorePatterns` | [`output.externals`](/config/build/output#outputexternals)、[`source.exclude`](/config/build/source#sourceexclude) | +| `displayName` | [`name`](/config/test/name) | +| `rootDir` | [`root`](/config/test/root) | +| `setupFilesAfterEnv` | [`setupFiles`](/config/test/setup-files) | +| `verbose` | [`verbose-reporter`](/config/test/reporters#verbose-reporter) | +| `injectGlobals` | [`globals`](/config/test/globals) | +| `moduleNameMapper` | [`resolve.alias`](/config/build/resolve#resolvealias)、[`resolve.moduleNameMapper`](/config/build/resolve#resolvemodulenamemapper) | +| `maxWorkers` | [`pool.maxWorkers`](/config/test/pool) | +| `collectCoverage` | [`coverage.enabled`](/config/test/coverage#enabled) | +| `coverageDirectory` | [`coverage.reportsDirectory`](/config/test/coverage#reportsdirectory) | +| `coverageProvider` | [`coverage.provider`](/config/test/coverage#provider) | +| `coveragePathIgnorePatterns` | [`coverage.exclude`](/config/test/coverage#exclude) | +| `coverageThreshold` | [`coverage.thresholds`](/config/test/coverage#thresholds) | 更多详情,请参考 [配置文档](/config)。 diff --git a/website/rspress.config.ts b/website/rspress.config.ts index f71695b73..f49e29665 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -97,6 +97,7 @@ export default defineConfig({ }), ], performance: { + buildCache: false, printFileSize: { total: true, detail: false, diff --git a/website/theme/components/ConfigOverview.tsx b/website/theme/components/ConfigOverview.tsx index 09bc0543f..36f544680 100644 --- a/website/theme/components/ConfigOverview.tsx +++ b/website/theme/components/ConfigOverview.tsx @@ -143,6 +143,7 @@ const BUILD_OVERVIEW_GROUPS: BasicGroup[] = [ items: [ 'resolve.aliasStrategy', 'resolve.alias', + 'resolve.moduleNameMapper', 'resolve.dedupe', 'resolve.extensions', ], From 001b4a01b934e41850692d219a0587cbe2d14eb0 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Fri, 27 Feb 2026 19:14:11 +0800 Subject: [PATCH 2/5] docs: update --- website/docs/en/config/build/resolve.mdx | 23 ---------------------- website/docs/zh/config/build/resolve.mdx | 25 +----------------------- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/website/docs/en/config/build/resolve.mdx b/website/docs/en/config/build/resolve.mdx index 3da0016ac..0aa77ec76 100644 --- a/website/docs/en/config/build/resolve.mdx +++ b/website/docs/en/config/build/resolve.mdx @@ -29,14 +29,6 @@ Automatically resolve file extensions when importing modules. This means you can A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module. This is similar to Jest's [moduleNameMapper](https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring) configuration. -This is useful for: - -- Mocking modules in tests -- Redirecting module paths -- Stubbing out static assets like images or styles - -### Basic usage - ```ts title="rstest.config.ts" import { defineConfig } from '@rstest/core'; @@ -54,21 +46,6 @@ export default defineConfig({ }); ``` -### Example: Path aliasing with capture groups - -```ts title="rstest.config.ts" -import { defineConfig } from '@rstest/core'; - -export default defineConfig({ - resolve: { - moduleNameMapper: { - // @components/Button -> ./src/components/Button - '^@components/(.*)$': '/src/components/$1', - }, - }, -}); -``` - :::tip For simple path aliasing, consider using TypeScript's `paths` or [resolve.alias](#resolvealias) in `tsconfig.json` instead, which provides better performance and IDE support. ::: diff --git a/website/docs/zh/config/build/resolve.mdx b/website/docs/zh/config/build/resolve.mdx index 13d3e73c2..b82f32317 100644 --- a/website/docs/zh/config/build/resolve.mdx +++ b/website/docs/zh/config/build/resolve.mdx @@ -29,14 +29,6 @@ import { RsbuildDocBadge } from '@components/RsbuildDocBadge'; 从正则表达式到模块名称或模块名称数组的映射,允许用单个模块替换多个模块。这类似于 Jest 的 [moduleNameMapper](https://jestjs.io/zh-Hans/docs/configuration#modulenamemapper-objectstring-string--arraystring) 配置。 -这在以下场景中非常有用: - -- 在测试中 mock 模块 -- 重定向模块路径 -- 替换图片或样式等静态资源 - -### 基本用法 - ```ts title="rstest.config.ts" import { defineConfig } from '@rstest/core'; @@ -47,28 +39,13 @@ export default defineConfig({ '^module-a$': '/mocks/module-a.ts', // 使用捕获组进行匹配 '^@utils/(.*)$': '/src/utils/$1', - // 存根 CSS 导入 + // 替换 CSS 导入 '\\.(css|less|scss)$': 'identity-obj-proxy', }, }, }); ``` -### 示例:使用捕获组进行路径别名 - -```ts title="rstest.config.ts" -import { defineConfig } from '@rstest/core'; - -export default defineConfig({ - resolve: { - moduleNameMapper: { - // @components/Button -> ./src/components/Button - '^@components/(.*)$': '/src/components/$1', - }, - }, -}); -``` - :::tip 对于简单的路径别名,建议使用 TypeScript 的 `tsconfig.json` 中的 `paths` 或 [resolve.alias](#resolvealias) 配置,它们提供更好的性能和 IDE 支持。 ::: From ce8723b44b4c1b1d07fc74e7ac614b79bd7fef74 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Fri, 27 Feb 2026 19:27:28 +0800 Subject: [PATCH 3/5] fix: update --- .../module-name-mapper/rstest.config.ts | 1 + .../moduleNameMapper/rstest.config.ts | 1 + .../core/src/core/plugins/moduleNameMapper.ts | 40 ++++++------------- packages/core/src/types/config.ts | 17 -------- 4 files changed, 15 insertions(+), 44 deletions(-) diff --git a/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts b/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts index 071450e31..dea52ceb9 100644 --- a/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts +++ b/e2e/browser-mode/fixtures/module-name-mapper/rstest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ }, include: ['tests/**/*.test.ts'], testTimeout: 30000, + root: __dirname, resolve: { moduleNameMapper: { // Map module-a to module-b using exact match diff --git a/e2e/build/fixtures/moduleNameMapper/rstest.config.ts b/e2e/build/fixtures/moduleNameMapper/rstest.config.ts index 66d7afeca..4980417ae 100644 --- a/e2e/build/fixtures/moduleNameMapper/rstest.config.ts +++ b/e2e/build/fixtures/moduleNameMapper/rstest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ name: 'node', + root: __dirname, resolve: { moduleNameMapper: { // Map module-a to module-b using exact match diff --git a/packages/core/src/core/plugins/moduleNameMapper.ts b/packages/core/src/core/plugins/moduleNameMapper.ts index 9a4de7cf3..3074db303 100644 --- a/packages/core/src/core/plugins/moduleNameMapper.ts +++ b/packages/core/src/core/plugins/moduleNameMapper.ts @@ -1,35 +1,29 @@ -import { dirname } from 'node:path'; import { type RsbuildPlugin, type Rspack, rspack } from '@rsbuild/core'; import type { RstestContext } from '../../types'; -export type ModuleNameMapperConfig = Record; +type ModuleNameMapperConfig = Record; /** * Replace `` token in the replacement path with the actual root directory. - * In Jest, refers to the directory containing the config file. */ -export function replaceRootDir(value: string, rootDir: string): string { +function replaceRootDir(value: string, rootDir: string): string { return value.replace(//g, rootDir); } -export interface CreateModuleNameMapperPluginsOptions { - /** The moduleNameMapper configuration */ - moduleNameMapper: ModuleNameMapperConfig; - /** The root directory for `` token replacement */ - rootDir: string; - /** The rspack instance to use for creating plugins (defaults to imported rspack) */ - rspack?: typeof rspack; -} - /** * Create NormalModuleReplacementPlugin instances for moduleNameMapper configuration. * This is a shared utility used by both node and browser modes. * * @returns Array of NormalModuleReplacementPlugin instances */ -export function createModuleNameMapperPlugins( - options: CreateModuleNameMapperPluginsOptions, -): Rspack.WebpackPluginInstance[] { +export function createModuleNameMapperPlugins(options: { + /** The moduleNameMapper configuration */ + moduleNameMapper: ModuleNameMapperConfig; + /** The root directory for `` token replacement */ + rootDir: string; + /** The rspack instance to use for creating plugins (defaults to imported rspack) */ + rspack?: typeof rspack; +}): Rspack.WebpackPluginInstance[] { const { moduleNameMapper, rootDir, @@ -87,8 +81,6 @@ export function createModuleNameMapperPlugins( * - Values are replacement paths (string or array of strings) * - Capture groups ($1, $2, etc.) are supported * - `` token is replaced with the directory containing the config file - * - * @see https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring */ export const pluginModuleNameMapper: (context: RstestContext) => RsbuildPlugin = (context) => ({ @@ -100,27 +92,21 @@ export const pluginModuleNameMapper: (context: RstestContext) => RsbuildPlugin = ); if (!project) { - return config; + return; } const moduleNameMapper = project.normalizedConfig.resolve?.moduleNameMapper; if (!moduleNameMapper || Object.keys(moduleNameMapper).length === 0) { - return config; + return; } - // Use config file directory for , falling back to project root - // This aligns with Jest's behavior where is the config directory - const configDir = project.configFilePath - ? dirname(project.configFilePath) - : project.rootPath; - config.plugins ??= []; const mapperPlugins = createModuleNameMapperPlugins({ moduleNameMapper, - rootDir: configDir, + rootDir: project.rootPath, }); config.plugins.push(...mapperPlugins); diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 27a20a2f4..ba4b5c4e1 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -448,23 +448,6 @@ export interface RstestConfig { * Keys are regex patterns and values are replacement paths. * Use `$1`, `$2`, etc. to reference capture groups. * Use `` as a string token to reference the root directory. - * - * @example - * ```ts - * { - * resolve: { - * moduleNameMapper: { - * // Map lodash to lodash-es - * '^lodash$': 'lodash-es', - * // Map @components/* to src/components/* - * '^@components/(.*)$': '/src/components/$1', - * // Map image imports to a stub - * '\\.(jpg|jpeg|png|gif)$': '/__mocks__/fileMock.js', - * } - * } - * } - * ``` - * @see https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring */ moduleNameMapper?: Record; }; From 68746bce301c99d925dfed1f085049d9311f9ec4 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Fri, 27 Feb 2026 19:30:40 +0800 Subject: [PATCH 4/5] fix: lint --- packages/core/src/core/plugins/moduleNameMapper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/plugins/moduleNameMapper.ts b/packages/core/src/core/plugins/moduleNameMapper.ts index 3074db303..454e1c827 100644 --- a/packages/core/src/core/plugins/moduleNameMapper.ts +++ b/packages/core/src/core/plugins/moduleNameMapper.ts @@ -92,14 +92,14 @@ export const pluginModuleNameMapper: (context: RstestContext) => RsbuildPlugin = ); if (!project) { - return; + return config; } const moduleNameMapper = project.normalizedConfig.resolve?.moduleNameMapper; if (!moduleNameMapper || Object.keys(moduleNameMapper).length === 0) { - return; + return config; } config.plugins ??= []; From a3c1cf26b8f9a6703c6cf48805ac6e8a2868aae3 Mon Sep 17 00:00:00 2001 From: 9aoy <9aoyuao@gmail.com> Date: Thu, 5 Mar 2026 17:08:26 +0800 Subject: [PATCH 5/5] chore: excludeFromExternalize --- packages/core/src/core/plugins/external.ts | 31 ++---------- .../core/src/core/plugins/moduleNameMapper.ts | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/core/src/core/plugins/external.ts b/packages/core/src/core/plugins/external.ts index e09f7c1b1..a99765c07 100644 --- a/packages/core/src/core/plugins/external.ts +++ b/packages/core/src/core/plugins/external.ts @@ -3,27 +3,8 @@ import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; import type { RstestContext } from '../../types'; import { ADDITIONAL_NODE_BUILTINS, castArray } from '../../utils'; -/** - * Check if a request matches any moduleNameMapper pattern. - * If it does, don't externalize it - let NormalModuleReplacementPlugin handle it. - */ -const matchesModuleNameMapper = ( - request: string, - moduleNameMapper: Record | undefined, -): boolean => { - if (!moduleNameMapper) return false; - - for (const pattern of Object.keys(moduleNameMapper)) { - if (new RegExp(pattern).test(request)) { - return true; - } - } - return false; -}; - const autoExternalNodeModules: ( outputModule: boolean, - moduleNameMapper: Record | undefined, ) => ( data: Rspack.ExternalItemFunctionData, callback: ( @@ -32,7 +13,7 @@ const autoExternalNodeModules: ( type?: Rspack.ExternalsType, ) => void, ) => void = - (outputModule, moduleNameMapper) => + (outputModule) => ({ context, request, dependencyType, getResolve }, callback) => { if (!request) { return callback(); @@ -43,11 +24,6 @@ const autoExternalNodeModules: ( return callback(); } - // If request matches moduleNameMapper, don't externalize - let it be transformed - if (matchesModuleNameMapper(request, moduleNameMapper)) { - return callback(); - } - const doExternal = (externalPath: string = request) => { callback( undefined, @@ -126,16 +102,15 @@ export const pluginExternal: (context: RstestContext) => RsbuildPlugin = ( setup: (api) => { api.modifyEnvironmentConfig((config, { mergeEnvironmentConfig, name }) => { const { - normalizedConfig: { testEnvironment, resolve }, + normalizedConfig: { testEnvironment }, outputModule, } = context.projects.find((p) => p.environmentName === name)!; - const moduleNameMapper = resolve?.moduleNameMapper; return mergeEnvironmentConfig(config, { output: { externals: testEnvironment.name === 'node' - ? [autoExternalNodeModules(outputModule, moduleNameMapper)] + ? [autoExternalNodeModules(outputModule)] : undefined, }, tools: { diff --git a/packages/core/src/core/plugins/moduleNameMapper.ts b/packages/core/src/core/plugins/moduleNameMapper.ts index 454e1c827..95630a443 100644 --- a/packages/core/src/core/plugins/moduleNameMapper.ts +++ b/packages/core/src/core/plugins/moduleNameMapper.ts @@ -73,6 +73,47 @@ export function createModuleNameMapperPlugins(options: { return plugins; } +/** + * Check if a request matches any moduleNameMapper pattern. + * If it does, don't externalize it - let NormalModuleReplacementPlugin handle it. + */ +const matchesModuleNameMapper = ( + request: string, + moduleNameMapper: Record | undefined, +): boolean => { + if (!moduleNameMapper) return false; + + for (const pattern of Object.keys(moduleNameMapper)) { + if (new RegExp(pattern).test(request)) { + return true; + } + } + return false; +}; + +const excludeExternalize: ( + moduleNameMapper: Record | undefined, +) => ( + data: Rspack.ExternalItemFunctionData, + callback: ( + err?: Error, + result?: Rspack.ExternalItemValue, + type?: Rspack.ExternalsType, + ) => void, +) => void = + (moduleNameMapper) => + ({ request }, callback) => { + if (!request) { + return callback(); + } + + // If request matches moduleNameMapper, don't externalize - let it be transformed + if (matchesModuleNameMapper(request, moduleNameMapper)) { + return callback(undefined, false); + } + return callback(); + }; + /** * Apply module name mapper using rspack.NormalModuleReplacementPlugin. * @@ -110,6 +151,13 @@ export const pluginModuleNameMapper: (context: RstestContext) => RsbuildPlugin = }); config.plugins.push(...mapperPlugins); + // Make sure that externals configuration is not modified by users + config.externals = Array.isArray(config.externals) + ? config.externals + : []; + + config.externals.unshift(excludeExternalize(moduleNameMapper)); + return config; }); },