diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 834fabb2..295d0446 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -112,4 +112,4 @@ jobs: - name: E2E Test if: steps.changes.outputs.changed == 'true' - run: pnpm run e2e && pnpm run test:example + run: pnpm run e2e diff --git a/package.json b/package.json index 84d9c87c..02bafd0a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "build": "cross-env NX_DAEMON=false nx run-many -t build --exclude @examples/* @rstest/tests-* --parallel=10", "e2e": "cd tests && pnpm test", "test": "rstest", - "test:example": "pnpm --filter @examples/* test", "change": "changeset", "changeset": "changeset", "check-dependency-version": "pnpx check-dependency-version-consistency . && echo", diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index c8685f0b..13d7f85d 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -1,44 +1,15 @@ -import type { LoadConfigOptions } from '@rsbuild/core'; import cac, { type CAC } from 'cac'; import { normalize } from 'pathe'; -import { loadConfig } from '../config'; import type { ListCommandOptions, RstestCommand, - RstestConfig, RstestInstance, } from '../types'; -import { castArray, formatError, getAbsolutePath } from '../utils/helper'; +import { formatError } from '../utils/helper'; import { logger } from '../utils/logger'; +import type { CommonOptions } from './init'; import { showRstest } from './prepare'; -type CommonOptions = { - root?: string; - config?: string; - configLoader?: LoadConfigOptions['loader']; - globals?: boolean; - isolate?: boolean; - include?: string[]; - exclude?: string[]; - reporter?: string[]; - passWithNoTests?: boolean; - printConsoleTrace?: boolean; - disableConsoleIntercept?: boolean; - update?: boolean; - testNamePattern?: RegExp | string; - testTimeout?: number; - hookTimeout?: number; - testEnvironment?: string; - clearMocks?: boolean; - resetMocks?: boolean; - restoreMocks?: boolean; - unstubGlobals?: boolean; - unstubEnvs?: boolean; - retry?: number; - maxConcurrency?: number; - slowTestThreshold?: number; -}; - const applyCommonOptions = (cli: CAC) => { cli .option( @@ -106,64 +77,6 @@ const applyCommonOptions = (cli: CAC) => { ); }; -export async function initCli(options: CommonOptions): Promise<{ - config: RstestConfig; - configFilePath: string | null; -}> { - const cwd = process.cwd(); - const root = options.root ? getAbsolutePath(cwd, options.root) : cwd; - - const { content: config, filePath: configFilePath } = await loadConfig({ - cwd: root, - path: options.config, - configLoader: options.configLoader, - }); - - const keys: (keyof CommonOptions & keyof RstestConfig)[] = [ - 'root', - 'globals', - 'isolate', - 'passWithNoTests', - 'update', - 'testNamePattern', - 'testTimeout', - 'hookTimeout', - 'clearMocks', - 'resetMocks', - 'restoreMocks', - 'unstubEnvs', - 'unstubGlobals', - 'retry', - 'slowTestThreshold', - 'maxConcurrency', - 'printConsoleTrace', - 'disableConsoleIntercept', - 'testEnvironment', - ]; - for (const key of keys) { - if (options[key] !== undefined) { - (config[key] as any) = options[key]; - } - } - - if (options.exclude) { - config.exclude = castArray(options.exclude); - } - - if (options.reporter) { - config.reporters = castArray(options.reporter) as typeof config.reporters; - } - - if (options.include) { - config.include = castArray(options.include); - } - - return { - config, - configFilePath, - }; -} - export function setupCommands(): void { const cli = cac('rstest'); @@ -199,9 +112,17 @@ export function setupCommands(): void { ) => { let rstest: RstestInstance | undefined; try { - const { config } = await initCli(options); + const { initCli } = await import('./init'); + const { config, projects } = await initCli(options); const { createRstest } = await import('../core'); - rstest = createRstest(config, command, filters.map(normalize)); + rstest = createRstest( + { + config, + projects, + }, + command, + filters.map(normalize), + ); await rstest.runTests(); } catch (err) { for (const reporter of rstest?.context.reporters || []) { @@ -237,9 +158,14 @@ export function setupCommands(): void { options: CommonOptions & ListCommandOptions, ) => { try { - const { config } = await initCli(options); + const { initCli } = await import('./init'); + const { config, projects } = await initCli(options); const { createRstest } = await import('../core'); - const rstest = createRstest(config, 'list', filters.map(normalize)); + const rstest = createRstest( + { config, projects }, + 'list', + filters.map(normalize), + ); await rstest.listTests({ filesOnly: options.filesOnly, json: options.json, diff --git a/packages/core/src/cli/init.ts b/packages/core/src/cli/init.ts new file mode 100644 index 00000000..a67eee0c --- /dev/null +++ b/packages/core/src/cli/init.ts @@ -0,0 +1,207 @@ +import { existsSync, readFileSync } from 'node:fs'; +import type { LoadConfigOptions } from '@rsbuild/core'; +import { basename, resolve } from 'pathe'; +import { type GlobOptions, glob, isDynamicPattern } from 'tinyglobby'; +import { loadConfig } from '../config'; +import type { Project, RstestConfig } from '../types'; +import { castArray, getAbsolutePath } from '../utils/helper'; + +export type CommonOptions = { + root?: string; + config?: string; + configLoader?: LoadConfigOptions['loader']; + globals?: boolean; + isolate?: boolean; + include?: string[]; + exclude?: string[]; + reporter?: string[]; + passWithNoTests?: boolean; + printConsoleTrace?: boolean; + disableConsoleIntercept?: boolean; + update?: boolean; + testNamePattern?: RegExp | string; + testTimeout?: number; + hookTimeout?: number; + testEnvironment?: string; + clearMocks?: boolean; + resetMocks?: boolean; + restoreMocks?: boolean; + unstubGlobals?: boolean; + unstubEnvs?: boolean; + retry?: number; + maxConcurrency?: number; + slowTestThreshold?: number; +}; + +async function resolveConfig( + options: CommonOptions & Required>, +): Promise<{ + config: RstestConfig; + configFilePath: string | null; +}> { + const { content: config, filePath: configFilePath } = await loadConfig({ + cwd: options.root, + path: options.config, + configLoader: options.configLoader, + }); + + const keys: (keyof CommonOptions & keyof RstestConfig)[] = [ + 'root', + 'globals', + 'isolate', + 'passWithNoTests', + 'update', + 'testNamePattern', + 'testTimeout', + 'hookTimeout', + 'clearMocks', + 'resetMocks', + 'restoreMocks', + 'unstubEnvs', + 'unstubGlobals', + 'retry', + 'slowTestThreshold', + 'maxConcurrency', + 'printConsoleTrace', + 'disableConsoleIntercept', + 'testEnvironment', + ]; + for (const key of keys) { + if (options[key] !== undefined) { + (config[key] as any) = options[key]; + } + } + + if (options.reporter) { + config.reporters = castArray(options.reporter) as typeof config.reporters; + } + + if (options.exclude) { + config.exclude = castArray(options.exclude); + } + + if (options.include) { + config.include = castArray(options.include); + } + + return { + config, + configFilePath, + }; +} + +export async function resolveProjects({ + config, + root, + options, +}: { + config: RstestConfig; + root: string; + options: CommonOptions; +}): Promise { + const projects: Project[] = []; + + if (!config.projects || !config.projects.length) { + return projects; + } + + const getDefaultProjectName = (dir: string) => { + const pkgJsonPath = resolve(dir, 'package.json'); + const name = existsSync(pkgJsonPath) + ? JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name + : ''; + + if (typeof name !== 'string' || !name) { + return basename(dir); + } + return name; + }; + + const globProjects = async (patterns: string[]) => { + const globOptions: GlobOptions = { + absolute: true, + dot: true, + onlyFiles: false, + cwd: root, + expandDirectories: false, + ignore: ['**/node_modules/**', '**/.DS_Store'], + }; + + return glob(patterns, globOptions); + }; + + const { projectPaths, projectPatterns } = (config.projects || []).reduce( + (total, p) => { + const projectStr = p.replace('', root); + + if (isDynamicPattern(projectStr)) { + total.projectPatterns.push(projectStr); + } else { + const absolutePath = getAbsolutePath(root, projectStr); + + if (!existsSync(absolutePath)) { + throw `Can't resolve project "${p}", please make sure "${p}" is a existing file or a directory.`; + } + total.projectPaths.push(absolutePath); + } + return total; + }, + { + projectPaths: [] as string[], + projectPatterns: [] as string[], + }, + ); + + projectPaths.push(...(await globProjects(projectPatterns))); + + const names = new Set(); + + for (const project of projectPaths || []) { + const { config, configFilePath } = await resolveConfig({ + ...options, + root: project, + }); + + config.name ??= getDefaultProjectName(project); + + projects.push({ + config, + configFilePath, + }); + + if (names.has(config.name)) { + const conflictProjects = projects.filter( + (p) => p.config.name === config.name, + ); + throw `Project name "${config.name}" is already used. Please ensure all projects have unique names. +Conflicting projects: +${conflictProjects.map((p) => `- ${p.configFilePath || p.config.root}`).join('\n')} + `; + } + + names.add(config.name); + } + return projects; +} + +export async function initCli(options: CommonOptions): Promise<{ + config: RstestConfig; + configFilePath: string | null; + projects: Project[]; +}> { + const cwd = process.cwd(); + const root = options.root ? getAbsolutePath(cwd, options.root) : cwd; + + const { config, configFilePath } = await resolveConfig({ + ...options, + root, + }); + + const projects = await resolveProjects({ config, root, options }); + + return { + config, + configFilePath, + projects, + }; +} diff --git a/packages/core/src/core/context.ts b/packages/core/src/core/context.ts index d41dfa6d..e922a21f 100644 --- a/packages/core/src/core/context.ts +++ b/packages/core/src/core/context.ts @@ -1,10 +1,16 @@ import { SnapshotManager } from '@vitest/snapshot/manager'; import { isCI } from 'std-env'; -import { withDefaultConfig } from '../config'; +import { mergeRstestConfig, withDefaultConfig } from '../config'; import { DefaultReporter } from '../reporter'; import { GithubActionsReporter } from '../reporter/githubActions'; import { VerboseReporter } from '../reporter/verbose'; -import type { RstestCommand, RstestConfig, RstestContext } from '../types'; +import type { + NormalizedConfig, + Project, + RstestCommand, + RstestConfig, + RstestContext, +} from '../types'; import { castArray, getAbsolutePath } from '../utils/helper'; const reportersMap: { @@ -48,9 +54,17 @@ function createReporters( return result; } +/** + * Only letters, numbers, "-", "_", and "$" are allowed. + */ +function formatEnvironmentName(name: string): string { + return name.replace(/[^a-zA-Z0-9\-_$]/g, '_'); +} + export function createContext( options: { cwd: string; command: RstestCommand }, userConfig: RstestConfig, + projects: Project[], ): RstestContext { const { cwd, command } = options; const rootPath = userConfig.root @@ -65,6 +79,8 @@ export function createContext( config: rstestConfig, }) : []; + + // TODO: project.snapshotManager ? const snapshotManager = new SnapshotManager({ updateSnapshot: rstestConfig.update ? 'all' : isCI ? 'none' : 'new', }); @@ -77,5 +93,32 @@ export function createContext( snapshotManager, originalConfig: userConfig, normalizedConfig: rstestConfig, + projects: projects.length + ? projects.map((project) => { + const config = mergeRstestConfig( + { + ...rstestConfig, + setupFiles: undefined, + }, + project.config, + ) as NormalizedConfig; + return { + rootPath: config.root, + name: config.name, + environmentName: formatEnvironmentName(config.name), + normalizedConfig: config, + }; + }) + : [ + { + rootPath, + name: rstestConfig.name, + environmentName: formatEnvironmentName(rstestConfig.name), + normalizedConfig: { + ...rstestConfig, + setupFiles: undefined, + }, + }, + ], }; } diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index f5dd3ca6..f08eca5f 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -1,5 +1,6 @@ import type { ListCommandOptions, + Project, RstestCommand, RstestConfig, RstestInstance, @@ -7,11 +8,21 @@ import type { import { createContext } from './context'; export function createRstest( - config: RstestConfig, + { + config, + projects, + }: { + config: RstestConfig; + projects: Project[]; + }, command: RstestCommand, fileFilters: string[], ): RstestInstance { - const context = createContext({ cwd: process.cwd(), command }, config); + const context = createContext( + { cwd: process.cwd(), command }, + config, + projects, + ); const runTests = async (): Promise => { const { runTests } = await import('./runTests'); diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index 78f2e22e..5b748766 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -17,31 +17,55 @@ export async function listTests( fileFilters: string[], { filesOnly, json }: ListCommandOptions, ): Promise { - const { - normalizedConfig: { + const { rootPath } = context; + + const testEntries: Record> = {}; + + const globTestSourceEntries = async ( + name: string, + ): Promise> => { + if (testEntries[name]) { + return testEntries[name]; + } + const { include, exclude, includeSource, root } = context.projects.find( + (p) => p.environmentName === name, + )!.normalizedConfig; + + const entries = await getTestEntries({ include, exclude, root, - name, - setupFiles: setups, + fileFilters, includeSource, - }, - rootPath, - } = context; - - const testEntries = await getTestEntries({ - include, - exclude, - root, - fileFilters, - includeSource, - }); + }); - const globTestSourceEntries = async (): Promise> => { - return testEntries; + testEntries[name] = entries; + + return entries; }; - const setupFiles = getSetupFiles(setups, rootPath); + const globalSetupFiles = getSetupFiles( + context.normalizedConfig.setupFiles, + rootPath, + ); + + const setupFiles = Object.fromEntries( + context.projects.map((project) => { + const { + environmentName, + rootPath, + normalizedConfig: { setupFiles }, + } = project; + + return [ + environmentName, + { + ...globalSetupFiles, + ...getSetupFiles(setupFiles, rootPath), + }, + ]; + }), + ); const rsbuildInstance = await prepareRsbuild( context, @@ -50,7 +74,6 @@ export async function listTests( ); const { getRsbuildStats, closeServer } = await createRsbuildServer({ - name, globTestSourceEntries, normalizedConfig: context.normalizedConfig, setupFiles, @@ -58,23 +81,40 @@ export async function listTests( rootPath, }); - const { entries, setupEntries, assetFiles, sourceMaps, getSourcemap } = - await getRsbuildStats(); - const pool = await createPool({ context, }); - const list = await pool.collectTests({ - entries, - sourceMaps, - setupEntries, - assetFiles, - updateSnapshot: context.snapshotManager.options.updateSnapshot, - }); + const updateSnapshot = context.snapshotManager.options.updateSnapshot; + + const returns = await Promise.all( + context.projects.map(async (project) => { + const { entries, setupEntries, assetFiles, sourceMaps } = + await getRsbuildStats({ environmentName: project.environmentName }); + + const list = await pool.collectTests({ + entries, + sourceMaps, + setupEntries, + assetFiles, + project, + updateSnapshot, + }); + + return { + list, + sourceMaps, + }; + }), + ); + + const list = returns.flatMap((r) => r.list); + const sourceMaps = Object.assign({}, ...returns.map((r) => r.sourceMaps)); + const tests: { file: string; name?: string; + project?: string; }[] = []; const traverseTests = (test: Test) => { @@ -83,10 +123,18 @@ export async function listTests( } if (test.type === 'case') { - tests.push({ - file: test.testPath, - name: getTaskNameWithPrefix(test), - }); + if (showProject) { + tests.push({ + file: test.testPath, + name: getTaskNameWithPrefix(test), + project: test.project, + }); + } else { + tests.push({ + file: test.testPath, + name: getTaskNameWithPrefix(test), + }); + } } else { for (const child of test.tests) { traverseTests(child); @@ -95,6 +143,7 @@ export async function listTests( }; const hasError = list.some((file) => file.errors?.length); + const showProject = context.projects.length > 1; if (hasError) { const { printError } = await import('../utils/error'); @@ -107,7 +156,7 @@ export async function listTests( logger.log(`${color.bgRed(' FAIL ')} ${relativePath}`); for (const error of file.errors) { - await printError(error, getSourcemap, rootPath); + await printError(error, (name) => sourceMaps[name] || null, rootPath); } } } @@ -120,9 +169,16 @@ export async function listTests( for (const file of list) { if (filesOnly) { - tests.push({ - file: file.testPath, - }); + if (showProject) { + tests.push({ + file: file.testPath, + project: file.project, + }); + } else { + tests.push({ + file: file.testPath, + }); + } continue; } for (const test of file.tests) { diff --git a/packages/core/src/core/plugins/basic.ts b/packages/core/src/core/plugins/basic.ts index 1937244a..f5af2710 100644 --- a/packages/core/src/core/plugins/basic.ts +++ b/packages/core/src/core/plugins/basic.ts @@ -8,101 +8,118 @@ export const pluginBasic: (context: RstestContext) => RsbuildPlugin = ( ) => ({ name: 'rstest:basic', setup: (api) => { - api.modifyRsbuildConfig(async (config) => { - config.environments = { - [context.normalizedConfig.name]: { - output: { - target: 'node', + api.modifyEnvironmentConfig( + async (config, { mergeEnvironmentConfig, name }) => { + const { + normalizedConfig: { + resolve, + source, + output, + tools, + performance, + dev, }, - }, - }; - }); - api.modifyEnvironmentConfig(async (config, { mergeEnvironmentConfig }) => { - return mergeEnvironmentConfig(config, { - source: { - define: { - 'import.meta.rstest': "global['@rstest/core']", + } = context.projects.find((p) => p.environmentName === name)!; + return mergeEnvironmentConfig( + config, + { + performance, + tools, + resolve, + source, + output, + dev, }, - }, - output: { - // Pass resources to the worker on demand according to entry - manifest: true, - sourceMap: { - js: 'source-map', - }, - distPath: { - root: TEMP_RSTEST_OUTPUT_DIR, - }, - }, - tools: { - rspack: (config, { isProd, rspack }) => { - // treat `test` as development mode - config.mode = isProd ? 'production' : 'development'; - config.output ??= {}; - config.output.iife = false; - // polyfill interop - config.output.importFunctionName = '__rstest_dynamic_import__'; - config.output.devtoolModuleFilenameTemplate = - '[absolute-resource-path]'; - config.plugins.push( - new rspack.experiments.RstestPlugin({ - injectModulePathName: true, - importMetaPathName: true, - hoistMockModule: true, - manualMockRoot: path.resolve(context.rootPath, '__mocks__'), - }), - ); + { + source: { + define: { + 'import.meta.rstest': "global['@rstest/core']", + }, + }, + output: { + // Pass resources to the worker on demand according to entry + manifest: true, + sourceMap: { + js: 'source-map', + }, + distPath: { + root: TEMP_RSTEST_OUTPUT_DIR, + }, + }, + tools: { + rspack: (config, { isProd, rspack }) => { + // treat `test` as development mode + config.mode = isProd ? 'production' : 'development'; + config.output ??= {}; + config.output.iife = false; + // polyfill interop + config.output.importFunctionName = '__rstest_dynamic_import__'; + config.output.devtoolModuleFilenameTemplate = + '[absolute-resource-path]'; + config.plugins.push( + new rspack.experiments.RstestPlugin({ + injectModulePathName: true, + importMetaPathName: true, + hoistMockModule: true, + manualMockRoot: path.resolve(context.rootPath, '__mocks__'), + }), + ); - config.module.parser ??= {}; - config.module.parser.javascript = { - // Keep dynamic import expressions. - // eg. (modulePath) => import(modulePath) - importDynamic: false, - // Keep dynamic require expressions. - // eg. (modulePath) => require(modulePath) - requireDynamic: false, - requireAsExpression: false, - // Keep require.resolve expressions. - requireResolve: false, - ...(config.module.parser.javascript || {}), - }; + config.module.parser ??= {}; + config.module.parser.javascript = { + // Keep dynamic import expressions. + // eg. (modulePath) => import(modulePath) + importDynamic: false, + // Keep dynamic require expressions. + // eg. (modulePath) => require(modulePath) + requireDynamic: false, + requireAsExpression: false, + // Keep require.resolve expressions. + requireResolve: false, + ...(config.module.parser.javascript || {}), + }; - config.resolve ??= {}; - config.resolve.extensions ??= []; - config.resolve.extensions.push('.cjs'); + config.resolve ??= {}; + config.resolve.extensions ??= []; + config.resolve.extensions.push('.cjs'); - // TypeScript allows importing TS files with `.js` extension - config.resolve.extensionAlias ??= {}; - config.resolve.extensionAlias['.js'] = ['.js', '.ts', '.tsx']; - config.resolve.extensionAlias['.jsx'] = ['.jsx', '.tsx']; + // TypeScript allows importing TS files with `.js` extension + config.resolve.extensionAlias ??= {}; + config.resolve.extensionAlias['.js'] = ['.js', '.ts', '.tsx']; + config.resolve.extensionAlias['.jsx'] = ['.jsx', '.tsx']; - if (context.normalizedConfig.testEnvironment === 'node') { - // skip `module` field in Node.js environment. - // ESM module resolved by module field is not always a native ESM module - config.resolve.mainFields = config.resolve.mainFields?.filter( - (filed) => filed !== 'module', - ) || ['main']; - } + if (context.normalizedConfig.testEnvironment === 'node') { + // skip `module` field in Node.js environment. + // ESM module resolved by module field is not always a native ESM module + config.resolve.mainFields = config.resolve.mainFields?.filter( + (filed) => filed !== 'module', + ) || ['main']; + } - config.resolve.byDependency ??= {}; - config.resolve.byDependency.commonjs ??= {}; - // skip `module` field when commonjs require - // By default, rspack resolves the "module" field for commonjs first, but this is not always returned synchronously in esm - config.resolve.byDependency.commonjs.mainFields = ['main', '...']; + config.resolve.byDependency ??= {}; + config.resolve.byDependency.commonjs ??= {}; + // skip `module` field when commonjs require + // By default, rspack resolves the "module" field for commonjs first, but this is not always returned synchronously in esm + config.resolve.byDependency.commonjs.mainFields = [ + 'main', + '...', + ]; - config.optimization = { - moduleIds: 'named', - chunkIds: 'named', - nodeEnv: false, - ...(config.optimization || {}), - // make sure setup file and test file share the runtime - runtimeChunk: { - name: 'runtime', + config.optimization = { + moduleIds: 'named', + chunkIds: 'named', + nodeEnv: false, + ...(config.optimization || {}), + // make sure setup file and test file share the runtime + runtimeChunk: { + name: 'runtime', + }, + }; }, - }; + }, }, - }, - }); - }); + ); + }, + ); }, }); diff --git a/packages/core/src/core/plugins/entry.ts b/packages/core/src/core/plugins/entry.ts index f8683da5..3d62632b 100644 --- a/packages/core/src/core/plugins/entry.ts +++ b/packages/core/src/core/plugins/entry.ts @@ -26,20 +26,20 @@ class TestFileWatchPlugin { } export const pluginEntryWatch: (params: { - globTestSourceEntries: () => Promise>; - setupFiles: Record; + globTestSourceEntries: (name: string) => Promise>; + setupFiles: Record>; isWatch: boolean; }) => RsbuildPlugin = ({ isWatch, globTestSourceEntries, setupFiles }) => ({ name: 'rstest:entry-watch', setup: (api) => { - api.modifyRspackConfig(async (config) => { + api.modifyRspackConfig(async (config, { environment }) => { if (isWatch) { config.plugins.push(new TestFileWatchPlugin(api.context.rootPath)); config.entry = async () => { - const sourceEntries = await globTestSourceEntries(); + const sourceEntries = await globTestSourceEntries(environment.name); return { ...sourceEntries, - ...setupFiles, + ...setupFiles[environment.name], }; }; @@ -64,9 +64,9 @@ export const pluginEntryWatch: (params: { config.watchOptions ??= {}; config.watchOptions.ignored = '**/**'; - const sourceEntries = await globTestSourceEntries(); + const sourceEntries = await globTestSourceEntries(environment.name); config.entry = { - ...setupFiles, + ...setupFiles[environment.name], ...sourceEntries, }; } diff --git a/packages/core/src/core/plugins/external.ts b/packages/core/src/core/plugins/external.ts index 6153a160..883058ea 100644 --- a/packages/core/src/core/plugins/external.ts +++ b/packages/core/src/core/plugins/external.ts @@ -1,5 +1,5 @@ import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; -import type { NormalizedConfig } from '../../types'; +import type { RstestContext } from '../../types'; import { castArray, NODE_BUILTINS } from '../../utils'; const autoExternalNodeModules: ( @@ -78,32 +78,39 @@ function autoExternalNodeBuiltin( } } -export const pluginExternal: ( - testEnvironment: NormalizedConfig['testEnvironment'], -) => RsbuildPlugin = (testEnvironment) => ({ +export const pluginExternal: (context: RstestContext) => RsbuildPlugin = ( + context, +) => ({ name: 'rstest:external', setup: (api) => { - api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => { - return mergeRsbuildConfig(config, { - output: { - externals: - testEnvironment === 'node' ? [autoExternalNodeModules] : undefined, - }, - tools: { - rspack: (config) => { - // Make sure that externals configuration is not modified by users - config.externals = castArray(config.externals) || []; + api.modifyEnvironmentConfig( + async (config, { mergeEnvironmentConfig, name }) => { + const { + normalizedConfig: { testEnvironment }, + } = context.projects.find((p) => p.environmentName === name)!; + return mergeEnvironmentConfig(config, { + output: { + externals: + testEnvironment === 'node' + ? [autoExternalNodeModules] + : undefined, + }, + tools: { + rspack: (config) => { + // Make sure that externals configuration is not modified by users + config.externals = castArray(config.externals) || []; - config.externals.unshift({ - '@rstest/core': 'global @rstest/core', - }); + config.externals.unshift({ + '@rstest/core': 'global @rstest/core', + }); - config.externalsPresets ??= {}; - config.externalsPresets.node = false; - config.externals.push(autoExternalNodeBuiltin); + config.externalsPresets ??= {}; + config.externalsPresets.node = false; + config.externals.push(autoExternalNodeBuiltin); + }, }, - }, - }); - }); + }); + }, + ); }, }); diff --git a/packages/core/src/core/rsbuild.ts b/packages/core/src/core/rsbuild.ts index 6ffc993e..37650e34 100644 --- a/packages/core/src/core/rsbuild.ts +++ b/packages/core/src/core/rsbuild.ts @@ -49,22 +49,12 @@ const isMultiCompiler = < export const prepareRsbuild = async ( context: RstestContext, - globTestSourceEntries: () => Promise>, - setupFiles: Record, + globTestSourceEntries: (name: string) => Promise>, + setupFiles: Record>, ): Promise => { const { command, - normalizedConfig: { - isolate, - plugins, - resolve, - source, - output, - tools, - testEnvironment, - performance, - dev = {}, - }, + normalizedConfig: { isolate, dev = {} }, } = context; const debugMode = isDebug(); @@ -74,10 +64,6 @@ export const prepareRsbuild = async ( const rsbuildInstance = await createRsbuild({ callerName: 'rstest', rsbuildConfig: { - tools, - resolve, - source, - output, server: { printUrls: false, strictPort: false, @@ -90,9 +76,18 @@ export const prepareRsbuild = async ( hmr: false, writeToDisk, }, - performance, + environments: Object.fromEntries( + context.projects.map((project) => [ + project.environmentName, + { + plugins: project.normalizedConfig.plugins, + output: { + target: 'node', + }, + }, + ]), + ), plugins: [ - ...(plugins || []), pluginBasic(context), pluginIgnoreResolveError, pluginMockRuntime, @@ -102,8 +97,14 @@ export const prepareRsbuild = async ( setupFiles, isWatch: command === 'watch', }), - pluginExternal(testEnvironment), - !isolate ? pluginCacheControl(Object.values(setupFiles)) : null, + pluginExternal(context), + !isolate + ? pluginCacheControl( + Object.values(setupFiles).flatMap((files) => + Object.values(files), + ), + ) + : null, pluginInspect(), ].filter(Boolean) as RsbuildPlugin[], }, @@ -113,20 +114,21 @@ export const prepareRsbuild = async ( }; export const createRsbuildServer = async ({ - name, globTestSourceEntries, setupFiles, rsbuildInstance, normalizedConfig, }: { rsbuildInstance: RsbuildInstance; - name: string; normalizedConfig: RstestContext['normalizedConfig']; - globTestSourceEntries: () => Promise>; - setupFiles: Record; + globTestSourceEntries: (name: string) => Promise>; + setupFiles: Record>; rootPath: string; }): Promise<{ - getRsbuildStats: (options?: { fileFilters?: string[] }) => Promise<{ + getRsbuildStats: (options: { + environmentName: string; + fileFilters?: string[]; + }) => Promise<{ buildTime: number; hash?: string; entries: EntryInfo[]; @@ -181,12 +183,41 @@ export const createRsbuildServer = async ({ ); } + const readFile = async (fileName: string) => { + return new Promise((resolve, reject) => { + outputFileSystem.readFile(fileName, (err, data) => { + if (err) { + reject(err); + } + resolve(typeof data === 'string' ? data : data!.toString()); + }); + }); + }; + + const getEntryFiles = async (manifest: ManifestData, outputPath: string) => { + const entryFiles: Record = {}; + + const entries = Object.keys(manifest.entries); + + for (const entry of entries) { + const data = manifest.entries[entry]; + entryFiles[entry] = ( + (data?.initial?.js || []).concat(data?.async?.js || []) || [] + ).map((file: string) => path.join(outputPath, file)); + } + return entryFiles; + }; + const getRsbuildStats = async ({ + environmentName, fileFilters, - }: { fileFilters?: string[] } | undefined = {}) => { - const stats = await devServer.environments[name]!.getStats(); + }: { + environmentName: string; + fileFilters?: string[]; + }) => { + const stats = await devServer.environments[environmentName]!.getStats(); - const manifest = devServer.environments[name]!.context + const manifest = devServer.environments[environmentName]!.context .manifest as ManifestData; const { @@ -207,35 +238,10 @@ export const createRsbuildServer = async ({ timings: true, }); - const readFile = async (fileName: string) => { - return new Promise((resolve, reject) => { - outputFileSystem.readFile(fileName, (err, data) => { - if (err) { - reject(err); - } - resolve(typeof data === 'string' ? data : data!.toString()); - }); - }); - }; - - const getEntryFiles = async () => { - const entryFiles: Record = {}; - - const entries = Object.keys(manifest.entries); - - for (const entry of entries) { - const data = manifest.entries[entry]; - entryFiles[entry] = ( - (data?.initial?.js || []).concat(data?.async?.js || []) || [] - ).map((file: string) => path.join(outputPath!, file)); - } - return entryFiles; - }; - - const entryFiles = await getEntryFiles(); + const entryFiles = await getEntryFiles(manifest, outputPath!); const entries: EntryInfo[] = []; const setupEntries: EntryInfo[] = []; - const sourceEntries = await globTestSourceEntries(); + const sourceEntries = await globTestSourceEntries(environmentName); for (const entry of Object.keys(entrypoints!)) { const e = entrypoints![entry]!; @@ -245,10 +251,10 @@ export const createRsbuildServer = async ({ e.assets![e.assets!.length - 1]!.name, ); - if (setupFiles[entry]) { + if (setupFiles[environmentName]![entry]) { setupEntries.push({ distPath, - testPath: setupFiles[entry], + testPath: setupFiles[environmentName]![entry], files: entryFiles[entry], }); } else if (sourceEntries[entry]) { diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index 26bf6606..b92a184e 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -8,24 +8,16 @@ export async function runTests( context: RstestContext, fileFilters: string[], ): Promise { - const { - normalizedConfig: { - include, - exclude, - root, - name, - setupFiles: setups, - includeSource, - }, - rootPath, - reporters, - snapshotManager, - command, - } = context; + const { rootPath, reporters, projects, snapshotManager, command } = context; const entriesCache = new Map>(); - const globTestSourceEntries = async (): Promise> => { + const globTestSourceEntries = async ( + name: string, + ): Promise> => { + const { include, exclude, includeSource, root } = projects.find( + (p) => p.environmentName === name, + )!.normalizedConfig; const entries = await getTestEntries({ include, exclude, @@ -36,21 +28,31 @@ export async function runTests( entriesCache.set(name, entries); - if (!Object.keys(entries).length) { - logger.log(color.red('No test files found.')); - logger.log(''); - if (fileFilters.length) { - logger.log(color.gray('filter: '), fileFilters.join(color.gray(', '))); - } - logger.log(color.gray('include:'), include.join(color.gray(', '))); - logger.log(color.gray('exclude:'), exclude.join(color.gray(', '))); - logger.log(''); - } - return entries; }; - const setupFiles = getSetupFiles(setups, rootPath); + const globalSetupFiles = getSetupFiles( + context.normalizedConfig.setupFiles, + rootPath, + ); + + const setupFiles = Object.fromEntries( + context.projects.map((project) => { + const { + environmentName, + rootPath, + normalizedConfig: { setupFiles }, + } = project; + + return [ + environmentName, + { + ...globalSetupFiles, + ...getSetupFiles(setupFiles, rootPath), + }, + ]; + }), + ); const rsbuildInstance = await prepareRsbuild( context, @@ -59,16 +61,15 @@ export async function runTests( ); const { getRsbuildStats, closeServer } = await createRsbuildServer({ - name, normalizedConfig: context.normalizedConfig, globTestSourceEntries: command === 'watch' ? globTestSourceEntries - : async () => { + : async (name) => { if (entriesCache.has(name)) { return entriesCache.get(name)!; } - return globTestSourceEntries(); + return globTestSourceEntries(name); }, setupFiles, rsbuildInstance, @@ -89,39 +90,68 @@ export async function runTests( }); let testFileResult: TestFileResult[] = []; - let buildHash: string | undefined; - - const run = async ({ fileFilters }: { fileFilters?: string[] } = {}) => { - const { - entries, - setupEntries, - assetFiles, - sourceMaps, - getSourcemap, - buildTime, - hash, - } = await getRsbuildStats({ fileFilters }); - const testStart = Date.now(); - - const { results, testResults } = await pool.runTests({ - entries, - sourceMaps, - setupEntries, - assetFiles, - updateSnapshot: snapshotManager.options.updateSnapshot, - }); - const actualBuildTime = buildHash === hash ? 0 : buildTime; + const run = async ({ failedTests }: { failedTests?: string[] } = {}) => { + let testStart: number; + const buildStart = Date.now(); + + const returns = await Promise.all( + context.projects.map(async (p) => { + const { entries, setupEntries, assetFiles, sourceMaps } = + await getRsbuildStats({ + environmentName: p.environmentName, + fileFilters: failedTests, + }); + + testStart ??= Date.now(); + + const { results, testResults } = await pool.runTests({ + entries, + sourceMaps, + setupEntries, + assetFiles, + project: p, + updateSnapshot: context.snapshotManager.options.updateSnapshot, + }); - const testTime = Date.now() - testStart; + return { + results, + testResults, + sourceMaps, + }; + }), + ); + + const buildTime = testStart! - buildStart; + + const testTime = Date.now() - testStart!; const duration = { - totalTime: testTime + actualBuildTime, - buildTime: actualBuildTime, + totalTime: testTime + buildTime, + buildTime, testTime, }; - buildHash = hash; + const results = returns.flatMap((r) => r.results); + const testResults = returns.flatMap((r) => r.testResults); + const sourceMaps = Object.assign({}, ...returns.map((r) => r.sourceMaps)); + + if (results.length === 0) { + if (command === 'watch') { + logger.log(color.yellow('No test files found\n')); + } else { + const code = context.normalizedConfig.passWithNoTests ? 0 : 1; + logger.log( + color[code ? 'red' : 'yellow']( + `No test files found, exiting with code ${code}\n`, + ), + ); + process.exitCode = code; + } + if (fileFilters.length) { + logger.log(color.gray('filter: '), fileFilters.join(color.gray(', '))); + } + } testFileResult = results; @@ -135,7 +165,7 @@ export async function runTests( testResults, snapshotSummary: snapshotManager.summary, duration, - getSourcemap, + getSourcemap: (name: string) => sourceMaps[name] || null, }); } }; @@ -201,7 +231,7 @@ export async function runTests( snapshotManager.clear(); - await run({ fileFilters: failedTests }); + await run({ failedTests }); afterTestsWatchRun(); }, updateSnapshot: async () => { diff --git a/packages/core/src/pool/forks.ts b/packages/core/src/pool/forks.ts index 74b3d5c5..77fa0c02 100644 --- a/packages/core/src/pool/forks.ts +++ b/packages/core/src/pool/forks.ts @@ -56,6 +56,7 @@ export const createForksPool = (poolOptions: { collectTests: (options: RunWorkerOptions) => Promise<{ tests: Test[]; testPath: string; + project: string; errors?: FormattedError[]; }>; close: () => Promise; diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts index f1c800ec..1db5c4c6 100644 --- a/packages/core/src/pool/index.ts +++ b/packages/core/src/pool/index.ts @@ -3,6 +3,7 @@ import type { SnapshotUpdateState } from '@vitest/snapshot'; import type { EntryInfo, FormattedError, + ProjectContext, RstestContext, RuntimeConfig, SourceMapInput, @@ -31,7 +32,7 @@ const parseWorkers = (maxWorkers: string | number): number => { return parsed > 0 ? parsed : 1; }; -const getRuntimeConfig = (context: RstestContext): RuntimeConfig => { +const getRuntimeConfig = (context: ProjectContext): RuntimeConfig => { const { testNamePattern, testTimeout, @@ -111,6 +112,7 @@ export const createPool = async ({ setupEntries: EntryInfo[]; sourceMaps: Record; updateSnapshot: SnapshotUpdateState; + project: ProjectContext; }) => Promise<{ results: TestFileResult[]; testResults: TestResult[]; @@ -121,11 +123,13 @@ export const createPool = async ({ setupEntries: EntryInfo[]; sourceMaps: Record; updateSnapshot: SnapshotUpdateState; + project: ProjectContext; }) => Promise< { tests: Test[]; testPath: string; errors?: FormattedError[]; + project: string; }[] >; close: () => Promise; @@ -195,8 +199,6 @@ export const createPool = async ({ }, }); - const runtimeConfig = getRuntimeConfig(context); - const rpcMethods = { onTestCaseResult: async (result: TestResult) => { await Promise.all( @@ -226,8 +228,11 @@ export const createPool = async ({ assetFiles, setupEntries, sourceMaps, + project, updateSnapshot, }) => { + const projectName = context.normalizedConfig.name; + const runtimeConfig = getRuntimeConfig(project); const setupAssets = setupEntries.flatMap((entry) => entry.files || []); const entryLength = Object.keys(entries).length; @@ -248,6 +253,7 @@ export const createPool = async ({ entryInfo, assetFiles: neededFiles, context: { + project: projectName, rootPath: context.rootPath, runtimeConfig: serializableConfig(runtimeConfig), }, @@ -261,6 +267,7 @@ export const createPool = async ({ .catch((err: unknown) => { (err as any).fullStack = true; return { + project: projectName, testPath: entryInfo.testPath, status: 'fail', name: '', @@ -279,15 +286,19 @@ export const createPool = async ({ const testResults = results.flatMap((r) => r.results); - return { results, testResults }; + return { results, testResults, project }; }, collectTests: async ({ entries, assetFiles, setupEntries, sourceMaps, + project, updateSnapshot, }) => { + const runtimeConfig = getRuntimeConfig(project); + const projectName = project.normalizedConfig.name; + const setupAssets = setupEntries.flatMap((entry) => entry.files || []); const entryLength = Object.keys(entries).length; @@ -308,6 +319,7 @@ export const createPool = async ({ entryInfo, assetFiles: neededFiles, context: { + project: projectName, rootPath: context.rootPath, runtimeConfig: serializableConfig(runtimeConfig), }, @@ -321,6 +333,7 @@ export const createPool = async ({ .catch((err: FormattedError) => { err.fullStack = true; return { + project: projectName, testPath: entryInfo.testPath, tests: [], errors: [err], diff --git a/packages/core/src/runtime/runner/index.ts b/packages/core/src/runtime/runner/index.ts index 7c8a587d..d1bbed62 100644 --- a/packages/core/src/runtime/runner/index.ts +++ b/packages/core/src/runtime/runner/index.ts @@ -25,9 +25,11 @@ export function createRunner({ workerState }: { workerState: WorkerState }): { } { const { testPath, + project, runtimeConfig: { testNamePattern }, } = workerState; const runtime = createRuntimeAPI({ + project, testPath, runtimeConfig: workerState.runtimeConfig, }); diff --git a/packages/core/src/runtime/runner/runner.ts b/packages/core/src/runtime/runner/runner.ts index 22bba452..e854c672 100644 --- a/packages/core/src/runtime/runner/runner.ts +++ b/packages/core/src/runtime/runner/runner.ts @@ -47,6 +47,7 @@ export class TestRunner { this.workerState = state; const { runtimeConfig: { passWithNoTests, retry, maxConcurrency }, + project, snapshotOptions, } = state; const results: TestResult[] = []; @@ -72,6 +73,7 @@ export class TestRunner { parentNames: test.parentNames, name: test.name, testPath, + project, }; return result; } @@ -81,6 +83,7 @@ export class TestRunner { parentNames: test.parentNames, name: test.name, testPath, + project, }; return result; } @@ -103,6 +106,7 @@ export class TestRunner { name: test.name, errors: formatTestError(error, test), testPath, + project, }; } @@ -122,6 +126,7 @@ export class TestRunner { parentNames: test.parentNames, name: test.name, testPath, + project, errors: [ { message: 'Expect test to fail', @@ -130,6 +135,7 @@ export class TestRunner { }; } catch (_err) { result = { + project, status: 'pass' as const, parentNames: test.parentNames, name: test.name, @@ -146,6 +152,7 @@ export class TestRunner { await test.fn?.(test.context); this.afterRunTest(test); result = { + project, parentNames: test.parentNames, name: test.name, status: 'pass' as const, @@ -153,6 +160,7 @@ export class TestRunner { }; } catch (error) { result = { + project, status: 'fail' as const, parentNames: test.parentNames, name: test.name, @@ -245,6 +253,7 @@ export class TestRunner { name: test.name, testPath, errors: [noTestError], + project, }; hooks.onTestCaseResult?.(result); } @@ -329,6 +338,7 @@ export class TestRunner { if (tests.length === 0) { if (passWithNoTests) { return { + project, testPath, name: '', status: 'pass', @@ -337,6 +347,7 @@ export class TestRunner { } return { + project, testPath, name: '', status: 'fail', @@ -359,6 +370,7 @@ export class TestRunner { const snapshotResult = await snapshotClient.finish(testPath); return { + project, testPath, name: '', status: errors.length ? 'fail' : getTestStatus(results, defaultStatus), diff --git a/packages/core/src/runtime/runner/runtime.ts b/packages/core/src/runtime/runner/runtime.ts index 7c34b57d..c4ccaff2 100644 --- a/packages/core/src/runtime/runner/runtime.ts +++ b/packages/core/src/runtime/runner/runtime.ts @@ -44,14 +44,18 @@ export class RunnerRuntime { private collectStatus: CollectStatus = 'lazy'; private currentCollectList: (() => MaybePromise)[] = []; private runtimeConfig; + private project: string; constructor({ testPath, runtimeConfig, + project, }: { testPath: string; runtimeConfig: RuntimeConfig; + project: string; }) { + this.project = project; this.testPath = testPath; this.runtimeConfig = runtimeConfig; } @@ -140,6 +144,7 @@ export class RunnerRuntime { private getDefaultRootSuite(): TestSuite { return { + project: this.project, runMode: 'run', testPath: this.testPath, name: ROOT_SUITE_NAME, @@ -165,6 +170,7 @@ export class RunnerRuntime { }): void { this.checkStatus(name, 'suite'); const currentSuite: TestSuite = { + project: this.project, name, runMode, tests: [], @@ -302,6 +308,7 @@ export class RunnerRuntime { }): void { this.checkStatus(name, 'case'); this.addTestCase({ + project: this.project, name, originalFn, fn: fn @@ -444,14 +451,17 @@ export class RunnerRuntime { export const createRuntimeAPI = ({ testPath, runtimeConfig, + project, }: { testPath: string; runtimeConfig: RuntimeConfig; + project: string; }): { api: RunnerAPI; instance: RunnerRuntime; } => { const runtimeInstance: RunnerRuntime = new RunnerRuntime({ + project, testPath, runtimeConfig, }); diff --git a/packages/core/src/runtime/worker/index.ts b/packages/core/src/runtime/worker/index.ts index 31a3f780..244dad13 100644 --- a/packages/core/src/runtime/worker/index.ts +++ b/packages/core/src/runtime/worker/index.ts @@ -237,6 +237,7 @@ const runInPool = async ( assetFiles, type, context: { + project, runtimeConfig: { isolate }, }, } = options; @@ -286,12 +287,14 @@ const runInPool = async ( }); const tests = await runner.collectTests(); return { + project, testPath, tests, errors: formatTestError(unhandledErrors), }; } catch (err) { return { + project, testPath, tests: [], errors: formatTestError(err), @@ -349,6 +352,7 @@ const runInPool = async ( return results; } catch (err) { return { + project, testPath, status: 'fail', name: '', diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 3405ddfb..69e4d4d5 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -18,6 +18,8 @@ export type RstestPoolOptions = { execArgv?: string[]; }; +type TestProject = string; + export interface RstestConfig { /** * Project root @@ -25,6 +27,10 @@ export interface RstestConfig { * @default process.cwd() */ root?: string; + /** + * Run tests from one or more projects. + */ + projects?: TestProject[]; /** * Project name * @@ -225,7 +231,7 @@ type OptionalKeys = | 'onConsoleLog'; export type NormalizedConfig = Required< - Omit + Omit > & { [key in OptionalKeys]?: RstestConfig[key]; } & { diff --git a/packages/core/src/types/core.ts b/packages/core/src/types/core.ts index 9a9fd0ba..0557b9c6 100644 --- a/packages/core/src/types/core.ts +++ b/packages/core/src/types/core.ts @@ -4,6 +4,15 @@ import type { Reporter } from './reporter'; export type RstestCommand = 'watch' | 'run' | 'list'; +export type Project = { config: RstestConfig; configFilePath: string | null }; + +export type ProjectContext = { + name: string; + environmentName: string; + rootPath: string; + normalizedConfig: NormalizedConfig; +}; + export type RstestContext = { /** The Rstest core version. */ version: string; @@ -13,6 +22,10 @@ export type RstestContext = { originalConfig: Readonly; /** The normalized Rstest config. */ normalizedConfig: NormalizedConfig; + /** + * Run tests from one or more projects. + */ + projects: ProjectContext[]; /** * The command type. * diff --git a/packages/core/src/types/testSuite.ts b/packages/core/src/types/testSuite.ts index 22c33c3b..6c242e75 100644 --- a/packages/core/src/types/testSuite.ts +++ b/packages/core/src/types/testSuite.ts @@ -48,6 +48,7 @@ export type TestCase = { * Result of the task. if `expect.soft()` failed multiple times or `retry` was triggered. */ result?: TaskResult; + project: string; }; export type SuiteContext = { @@ -70,6 +71,7 @@ export type TestSuite = { concurrent?: boolean; sequential?: boolean; testPath: TestPath; + project: string; /** nested cases and suite could in a suite */ tests: (TestSuite | TestCase)[]; type: 'suite'; @@ -111,6 +113,7 @@ export type TestResult = { duration?: number; errors?: FormattedError[]; retryCount?: number; + project: string; }; export type TestFileResult = TestResult & { diff --git a/packages/core/src/types/worker.ts b/packages/core/src/types/worker.ts index 0441e21c..ed4f0a1b 100644 --- a/packages/core/src/types/worker.ts +++ b/packages/core/src/types/worker.ts @@ -50,6 +50,7 @@ export type RuntimeConfig = Pick< export type WorkerContext = { rootPath: RstestContext['rootPath']; + project: string; runtimeConfig: RuntimeConfig; }; diff --git a/packages/core/tests/core/rsbuild.test.ts b/packages/core/tests/core/rsbuild.test.ts index aef7b63e..f20299f2 100644 --- a/packages/core/tests/core/rsbuild.test.ts +++ b/packages/core/tests/core/rsbuild.test.ts @@ -14,6 +14,20 @@ describe('prepareRsbuild', () => { tools: {}, testEnvironment: 'jsdom', }, + projects: [ + { + name: 'test', + environmentName: 'test', + normalizedConfig: { + plugins: [], + resolve: {}, + source: {}, + output: {}, + tools: {}, + testEnvironment: 'jsdom', + }, + }, + ], } as unknown as RstestContext, async () => ({}), {}, @@ -39,6 +53,21 @@ describe('prepareRsbuild', () => { testEnvironment: 'node', isolate: true, }, + projects: [ + { + name: 'test', + environmentName: 'test', + normalizedConfig: { + plugins: [], + resolve: {}, + source: {}, + output: {}, + tools: {}, + testEnvironment: 'node', + isolate: true, + }, + }, + ], } as unknown as RstestContext, async () => ({}), {}, @@ -67,6 +96,24 @@ describe('prepareRsbuild', () => { output: {}, tools: {}, }, + projects: [ + { + name: 'test', + environmentName: 'test', + normalizedConfig: { + plugins: [], + resolve: {}, + source: { + decorators: { + version: 'legacy', + }, + include: [/node_modules[\\/]query-string[\\/]/], + }, + output: {}, + tools: {}, + }, + }, + ], } as unknown as RstestContext, async () => ({}), {}, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b65c24f..280b2bf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,43 @@ importers: specifier: ^7.1.0 version: 7.1.0 + tests/projects/fixtures/packages/client: + dependencies: + react: + specifier: ^19.1.1 + version: 19.1.1 + react-dom: + specifier: ^19.1.1 + version: 19.1.1(react@19.1.1) + devDependencies: + '@rsbuild/core': + specifier: 1.4.15 + version: 1.4.15 + '@rsbuild/plugin-react': + specifier: ^1.3.5 + version: 1.3.5(@rsbuild/core@1.4.15) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.6.4 + version: 6.6.4 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@types/react': + specifier: ^19.1.9 + version: 19.1.9 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.1.7(@types/react@19.1.9) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + tests/reporter: devDependencies: '@rstest/core': diff --git a/rstest.config.ts b/rstest.config.ts index fb510c75..29a8546a 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ - include: ['packages/**/tests/**/*.test.ts'], + projects: ['packages/*', 'examples/*'], globals: true, setupFiles: ['./scripts/rstest.setup.ts'], }); diff --git a/tests/build/index.test.ts b/tests/build/index.test.ts index a1638603..cde614e9 100644 --- a/tests/build/index.test.ts +++ b/tests/build/index.test.ts @@ -18,7 +18,7 @@ describe('test build config', () => { command: 'rstest', args: [ 'run', - `fixtures/${name}/index.test.ts`, + `fixtures/${name}`, '-c', `fixtures/${name}/rstest.config.ts`, ], diff --git a/tests/dom/fixtures/package.json b/tests/dom/fixtures/package.json index 2d561492..2a002431 100644 --- a/tests/dom/fixtures/package.json +++ b/tests/dom/fixtures/package.json @@ -4,7 +4,8 @@ "scripts": { "dev": "rsbuild dev --open", "build": "rsbuild build", - "preview": "rsbuild preview" + "preview": "rsbuild preview", + "test": "rstest run" }, "dependencies": { "react": "^19.1.1", diff --git a/tests/dom/fixtures/rstest.config.ts b/tests/dom/fixtures/rstest.config.ts index bb742cc6..736dccb1 100644 --- a/tests/dom/fixtures/rstest.config.ts +++ b/tests/dom/fixtures/rstest.config.ts @@ -4,4 +4,5 @@ import rsbuildConfig from './rsbuild.config'; export default defineConfig({ ...(rsbuildConfig as RstestConfig), setupFiles: ['./test/setup.ts'], + testEnvironment: 'jsdom', }); diff --git a/tests/dom/fixtures/rstest.externals.config.ts b/tests/dom/fixtures/rstest.externals.config.ts index adbfb5e1..a1bf1005 100644 --- a/tests/dom/fixtures/rstest.externals.config.ts +++ b/tests/dom/fixtures/rstest.externals.config.ts @@ -4,6 +4,7 @@ import rsbuildConfig from './rsbuild.config'; export default defineConfig({ ...(rsbuildConfig as RstestConfig), setupFiles: ['./test/setup.ts'], + testEnvironment: 'jsdom', output: { externals: [/react/], }, diff --git a/tests/projects/fixtures/packages/client/package.json b/tests/projects/fixtures/packages/client/package.json new file mode 100644 index 00000000..e056c918 --- /dev/null +++ b/tests/projects/fixtures/packages/client/package.json @@ -0,0 +1,25 @@ +{ + "name": "@rstest/tests-project-client", + "private": true, + "scripts": { + "dev": "rsbuild dev --open", + "build": "rsbuild build", + "test": "rstest run", + "preview": "rsbuild preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@rsbuild/core": "1.4.15", + "@rsbuild/plugin-react": "^1.3.5", + "@testing-library/jest-dom": "^6.6.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "jsdom": "^26.1.0", + "typescript": "^5.9.2" + } +} diff --git a/tests/projects/fixtures/packages/client/rsbuild.config.ts b/tests/projects/fixtures/packages/client/rsbuild.config.ts new file mode 100644 index 00000000..c9962d33 --- /dev/null +++ b/tests/projects/fixtures/packages/client/rsbuild.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; + +export default defineConfig({ + plugins: [pluginReact()], +}); diff --git a/tests/projects/fixtures/packages/client/rstest.config.ts b/tests/projects/fixtures/packages/client/rstest.config.ts new file mode 100644 index 00000000..0237e2ae --- /dev/null +++ b/tests/projects/fixtures/packages/client/rstest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, type RstestConfig } from '@rstest/core'; +import rsbuildConfig from './rsbuild.config'; + +export default defineConfig({ + ...(rsbuildConfig as RstestConfig), + testEnvironment: 'jsdom', + setupFiles: ['./test/setup.ts'], +}); diff --git a/tests/projects/fixtures/packages/client/src/App.tsx b/tests/projects/fixtures/packages/client/src/App.tsx new file mode 100644 index 00000000..fe89f12f --- /dev/null +++ b/tests/projects/fixtures/packages/client/src/App.tsx @@ -0,0 +1,10 @@ +const App = () => { + return ( +
+

Rsbuild with React

+

Start building amazing things with Rsbuild.

+
+ ); +}; + +export default App; diff --git a/tests/projects/fixtures/packages/client/src/env.d.ts b/tests/projects/fixtures/packages/client/src/env.d.ts new file mode 100644 index 00000000..b0ac762b --- /dev/null +++ b/tests/projects/fixtures/packages/client/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/projects/fixtures/packages/client/src/index.tsx b/tests/projects/fixtures/packages/client/src/index.tsx new file mode 100644 index 00000000..55f29bff --- /dev/null +++ b/tests/projects/fixtures/packages/client/src/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootEl = document.getElementById('root'); +if (rootEl) { + const root = ReactDOM.createRoot(rootEl); + root.render( + + + , + ); +} diff --git a/tests/projects/fixtures/packages/client/test/App.test.tsx b/tests/projects/fixtures/packages/client/test/App.test.tsx new file mode 100644 index 00000000..71e48a42 --- /dev/null +++ b/tests/projects/fixtures/packages/client/test/App.test.tsx @@ -0,0 +1,21 @@ +import { expect, test } from '@rstest/core'; +import { render, screen } from '@testing-library/react'; +import App from '../src/App'; + +test('should render App correctly', async () => { + render(); + + const element = screen.getByText('Rsbuild with React'); + + expect(element.tagName).toBe('H1'); + + expect(element.constructor).toBe(document.defaultView?.HTMLHeadingElement); +}); + +test('should get document correctly', () => { + expect(global.document).toBeDefined(); +}); + +it('should load root setup file correctly', () => { + expect(process.env.TEST_ROOT).toBe('1'); +}); diff --git a/tests/projects/fixtures/packages/client/test/setup.ts b/tests/projects/fixtures/packages/client/test/setup.ts new file mode 100644 index 00000000..32b38ae2 --- /dev/null +++ b/tests/projects/fixtures/packages/client/test/setup.ts @@ -0,0 +1,4 @@ +import { expect } from '@rstest/core'; +import * as jestDomMatchers from '@testing-library/jest-dom/matchers'; + +expect.extend(jestDomMatchers); diff --git a/tests/projects/fixtures/packages/client/test/test.d.ts b/tests/projects/fixtures/packages/client/test/test.d.ts new file mode 100755 index 00000000..e48dee96 --- /dev/null +++ b/tests/projects/fixtures/packages/client/test/test.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/projects/fixtures/packages/node/src/index.ts b/tests/projects/fixtures/packages/node/src/index.ts new file mode 100644 index 00000000..eae921c8 --- /dev/null +++ b/tests/projects/fixtures/packages/node/src/index.ts @@ -0,0 +1 @@ +export const sayHi = () => 'hi'; diff --git a/tests/projects/fixtures/packages/node/test/__snapshots__/index.test.ts.snap b/tests/projects/fixtures/packages/node/test/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000..f4a7721d --- /dev/null +++ b/tests/projects/fixtures/packages/node/test/__snapshots__/index.test.ts.snap @@ -0,0 +1,3 @@ +// Rstest Snapshot v1 + +exports[`should generate snapshot correctly 1`] = `"hello world"`; diff --git a/tests/projects/fixtures/packages/node/test/index.test.ts b/tests/projects/fixtures/packages/node/test/index.test.ts new file mode 100644 index 00000000..ba7ffcad --- /dev/null +++ b/tests/projects/fixtures/packages/node/test/index.test.ts @@ -0,0 +1,17 @@ +import { sayHi } from '../src/index'; + +it('should test source code correctly', () => { + expect(sayHi()).toBe('hi'); +}); + +it('should can not get document', () => { + expect(global.document).toBeUndefined(); +}); + +it('should load root setup file correctly', () => { + expect(process.env.TEST_ROOT).toBe('1'); +}); + +it('should generate snapshot correctly', () => { + expect('hello world').toMatchSnapshot(); +}); diff --git a/tests/projects/fixtures/rstest.404.config.ts b/tests/projects/fixtures/rstest.404.config.ts new file mode 100644 index 00000000..dbac804c --- /dev/null +++ b/tests/projects/fixtures/rstest.404.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + projects: ['packages/*', '404'], + globals: true, + setupFiles: ['./setup.ts'], +}); diff --git a/tests/projects/fixtures/rstest.config.ts b/tests/projects/fixtures/rstest.config.ts new file mode 100644 index 00000000..6a29b6d6 --- /dev/null +++ b/tests/projects/fixtures/rstest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + projects: ['packages/*'], + globals: true, + setupFiles: ['./setup.ts'], +}); diff --git a/tests/projects/fixtures/setup.ts b/tests/projects/fixtures/setup.ts new file mode 100644 index 00000000..ed6c2e9c --- /dev/null +++ b/tests/projects/fixtures/setup.ts @@ -0,0 +1 @@ +process.env.TEST_ROOT = '1'; diff --git a/tests/projects/index.test.ts b/tests/projects/index.test.ts new file mode 100644 index 00000000..359bb16d --- /dev/null +++ b/tests/projects/index.test.ts @@ -0,0 +1,94 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from '@rstest/core'; +import { runRstestCli } from '../scripts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('test projects', () => { + it('should run projects correctly', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await expectExecSuccess(); + const logs = cli.stdout.split('\n').filter(Boolean); + + // test log print + expect( + logs.find((log) => log.includes('packages/node/test/index.test.ts')), + ).toBeTruthy(); + expect( + logs.find((log) => log.includes('packages/client/test/App.test.tsx')), + ).toBeTruthy(); + }); + + it('should run projects fail when project not found', async () => { + const { cli } = await runRstestCli({ + command: 'rstest', + args: ['run', '-c', 'rstest.404.config.ts'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await cli.exec; + expect(cli.exec.process?.exitCode).toBe(1); + const logs = cli.stdout.split('\n').filter(Boolean); + + // test log print + expect( + logs.find((log) => log.includes(`Can't resolve project "404"`)), + ).toBeTruthy(); + }); + + it('should run test failed when test file not found', async () => { + const { cli } = await runRstestCli({ + command: 'rstest', + args: ['run', '404-file'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await cli.exec; + expect(cli.exec.process?.exitCode).toBe(1); + const logs = cli.stdout.split('\n').filter(Boolean); + + // test log print + expect( + logs.find((log) => log.includes('No test files found')), + ).toBeTruthy(); + }); + + it('should run test success when test file not found with passWithNoTests flag', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run', '404-file', '--passWithNoTests'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures'), + }, + }, + }); + + await expectExecSuccess(); + const logs = cli.stdout.split('\n').filter(Boolean); + + // test log print + expect( + logs.find((log) => log.includes('No test files found')), + ).toBeTruthy(); + }); +});