diff --git a/e2e/browser-mode/fixtures/ports.ts b/e2e/browser-mode/fixtures/ports.ts index 80d514fe3..af250a8a3 100644 --- a/e2e/browser-mode/fixtures/ports.ts +++ b/e2e/browser-mode/fixtures/ports.ts @@ -29,6 +29,7 @@ export const BROWSER_PORTS = { 'reporter-watch': 5222, 'github-actions': 5224, 'browser-coverage-multiproject': 5228, + related: 5230, } as const; const browserPortValues = Object.values(BROWSER_PORTS); diff --git a/e2e/browser-mode/fixtures/related/rstest.config.mts b/e2e/browser-mode/fixtures/related/rstest.config.mts new file mode 100644 index 000000000..b8dc12176 --- /dev/null +++ b/e2e/browser-mode/fixtures/related/rstest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; +import { BROWSER_PORTS } from '../ports'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + headless: true, + port: BROWSER_PORTS.related, + }, + include: ['tests/**/*.test.ts'], + testTimeout: 30000, +}); diff --git a/e2e/browser-mode/fixtures/related/tests/index.test.ts b/e2e/browser-mode/fixtures/related/tests/index.test.ts new file mode 100644 index 000000000..0942f9865 --- /dev/null +++ b/e2e/browser-mode/fixtures/related/tests/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayHi } from './src/index'; + +describe('browser related index', () => { + it('should greet index', () => { + expect(sayHi()).toBe('Hello, index!'); + }); +}); diff --git a/e2e/browser-mode/fixtures/related/tests/other.test.ts b/e2e/browser-mode/fixtures/related/tests/other.test.ts new file mode 100644 index 000000000..718a4af17 --- /dev/null +++ b/e2e/browser-mode/fixtures/related/tests/other.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayBye } from './src/other'; + +describe('browser related other', () => { + it('should greet other', () => { + expect(sayBye()).toBe('Hello, other!'); + }); +}); diff --git a/e2e/browser-mode/fixtures/related/tests/src/index.ts b/e2e/browser-mode/fixtures/related/tests/src/index.ts new file mode 100644 index 000000000..18acf4c64 --- /dev/null +++ b/e2e/browser-mode/fixtures/related/tests/src/index.ts @@ -0,0 +1,3 @@ +import { greet } from './shared'; + +export const sayHi = () => greet('index'); diff --git a/e2e/browser-mode/fixtures/related/tests/src/other.ts b/e2e/browser-mode/fixtures/related/tests/src/other.ts new file mode 100644 index 000000000..1d04f6b4d --- /dev/null +++ b/e2e/browser-mode/fixtures/related/tests/src/other.ts @@ -0,0 +1,3 @@ +import { greet } from './shared'; + +export const sayBye = () => greet('other'); diff --git a/e2e/browser-mode/fixtures/related/tests/src/shared.ts b/e2e/browser-mode/fixtures/related/tests/src/shared.ts new file mode 100644 index 000000000..629f2e058 --- /dev/null +++ b/e2e/browser-mode/fixtures/related/tests/src/shared.ts @@ -0,0 +1 @@ +export const greet = (name: string) => `Hello, ${name}!`; diff --git a/e2e/browser-mode/related.test.ts b/e2e/browser-mode/related.test.ts new file mode 100644 index 000000000..0278399a6 --- /dev/null +++ b/e2e/browser-mode/related.test.ts @@ -0,0 +1,55 @@ +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('browser mode - related', () => { + it('should filter browser tests by related source files', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--related', 'tests/src/index.ts', '--filesOnly'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures', 'related'), + env: { + CI: '', + GITHUB_ACTIONS: '', + }, + }, + }, + }); + + await expectExecSuccess(); + + expect( + cli.stdout.split('\n').filter((line) => line.includes('.test.ts')), + ).toEqual(['tests/index.test.ts']); + }); + + it('should not run the full browser suite when related finds no tests', async () => { + const { cli, expectExecFailed } = await runRstestCli({ + command: 'rstest', + args: ['run', '--related', 'tests/src/missing.ts'], + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures', 'related'), + env: { + CI: '', + GITHUB_ACTIONS: '', + }, + }, + }, + }); + + await expectExecFailed(); + + expect(cli.stderr).toContain('No test files found, exiting with code 1.'); + expect(cli.log).toContain('related:'); + expect(cli.log).toContain('tests/src/missing.ts'); + expect(cli.log).not.toContain('index.test.ts'); + expect(cli.log).not.toContain('other.test.ts'); + }); +}); diff --git a/e2e/filter/fixtures-related-dynamic/index.test.ts b/e2e/filter/fixtures-related-dynamic/index.test.ts new file mode 100644 index 000000000..9e5ff45dd --- /dev/null +++ b/e2e/filter/fixtures-related-dynamic/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; + +describe('index', () => { + it('should transform late', async () => { + const { getLate } = await import('./src/late'); + expect(getLate()).toBe('LATE'); + }); +}); diff --git a/e2e/filter/fixtures-related-dynamic/other.test.ts b/e2e/filter/fixtures-related-dynamic/other.test.ts new file mode 100644 index 000000000..85d5002bf --- /dev/null +++ b/e2e/filter/fixtures-related-dynamic/other.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; + +describe('other', () => { + it('should transform other', async () => { + const { getOther } = await import('./src/other'); + expect(getOther()).toBe('OTHER'); + }); +}); diff --git a/e2e/filter/fixtures-related-dynamic/rstest.config.mts b/e2e/filter/fixtures-related-dynamic/rstest.config.mts new file mode 100644 index 000000000..3114ac1eb --- /dev/null +++ b/e2e/filter/fixtures-related-dynamic/rstest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + tools: { + rspack: { + watchOptions: { + aggregateTimeout: 10, + }, + }, + }, +}); diff --git a/e2e/filter/fixtures-related-dynamic/src/late.ts b/e2e/filter/fixtures-related-dynamic/src/late.ts new file mode 100644 index 000000000..bd4c299c9 --- /dev/null +++ b/e2e/filter/fixtures-related-dynamic/src/late.ts @@ -0,0 +1,3 @@ +import { transform } from './shared'; + +export const getLate = () => transform('late'); diff --git a/e2e/filter/fixtures-related-dynamic/src/other.ts b/e2e/filter/fixtures-related-dynamic/src/other.ts new file mode 100644 index 000000000..9f12a5445 --- /dev/null +++ b/e2e/filter/fixtures-related-dynamic/src/other.ts @@ -0,0 +1,3 @@ +import { transform } from './shared'; + +export const getOther = () => transform('other'); diff --git a/e2e/filter/fixtures-related-dynamic/src/shared.ts b/e2e/filter/fixtures-related-dynamic/src/shared.ts new file mode 100644 index 000000000..632182bcc --- /dev/null +++ b/e2e/filter/fixtures-related-dynamic/src/shared.ts @@ -0,0 +1 @@ +export const transform = (value: string) => value.toUpperCase(); diff --git a/e2e/filter/fixtures-related-mixed/browser/index.test.ts b/e2e/filter/fixtures-related-mixed/browser/index.test.ts new file mode 100644 index 000000000..cf82ebc66 --- /dev/null +++ b/e2e/filter/fixtures-related-mixed/browser/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayHi } from './src/index'; + +describe('browser project', () => { + it('should not be touched for node-only related sources', () => { + expect(sayHi()).toBe('Hello, browser!'); + }); +}); diff --git a/e2e/filter/fixtures-related-mixed/browser/src/index.ts b/e2e/filter/fixtures-related-mixed/browser/src/index.ts new file mode 100644 index 000000000..705223080 --- /dev/null +++ b/e2e/filter/fixtures-related-mixed/browser/src/index.ts @@ -0,0 +1 @@ +export const sayHi = () => 'Hello, browser!'; diff --git a/e2e/filter/fixtures-related-mixed/node/index.test.ts b/e2e/filter/fixtures-related-mixed/node/index.test.ts new file mode 100644 index 000000000..884befb96 --- /dev/null +++ b/e2e/filter/fixtures-related-mixed/node/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayHi } from './src/index'; + +describe('node project', () => { + it('should run node related tests without loading browser mode', () => { + expect(sayHi()).toBe('Hello, node!'); + }); +}); diff --git a/e2e/filter/fixtures-related-mixed/node/src/index.ts b/e2e/filter/fixtures-related-mixed/node/src/index.ts new file mode 100644 index 000000000..718f596f4 --- /dev/null +++ b/e2e/filter/fixtures-related-mixed/node/src/index.ts @@ -0,0 +1 @@ +export const sayHi = () => 'Hello, node!'; diff --git a/e2e/filter/fixtures-related-mixed/rstest.config.mts b/e2e/filter/fixtures-related-mixed/rstest.config.mts new file mode 100644 index 000000000..971994069 --- /dev/null +++ b/e2e/filter/fixtures-related-mixed/rstest.config.mts @@ -0,0 +1,21 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + projects: [ + { + name: 'node-project', + root: 'node', + include: ['**/*.test.ts'], + }, + { + name: 'browser-project', + root: 'browser', + include: ['**/*.test.ts'], + browser: { + enabled: true, + provider: 'invalid' as unknown as 'playwright', + headless: true, + }, + }, + ], +}); diff --git a/e2e/filter/fixtures-related/index.test.ts b/e2e/filter/fixtures-related/index.test.ts new file mode 100644 index 000000000..3d8280a3d --- /dev/null +++ b/e2e/filter/fixtures-related/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayHi } from './src/index'; + +describe('index', () => { + it('should greet index', () => { + expect(sayHi()).toBe('Hello, index!'); + }); +}); diff --git a/e2e/filter/fixtures-related/other.test.ts b/e2e/filter/fixtures-related/other.test.ts new file mode 100644 index 000000000..42120790c --- /dev/null +++ b/e2e/filter/fixtures-related/other.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayBye } from './src/other'; + +describe('other', () => { + it('should greet other', () => { + expect(sayBye()).toBe('Hello, other!'); + }); +}); diff --git a/e2e/filter/fixtures-related/src/fallback.ts b/e2e/filter/fixtures-related/src/fallback.ts new file mode 100644 index 000000000..436c70c85 --- /dev/null +++ b/e2e/filter/fixtures-related/src/fallback.ts @@ -0,0 +1 @@ +export const fallback = () => 'fallback'; diff --git a/e2e/filter/fixtures-related/src/fallback.ts.test.ts b/e2e/filter/fixtures-related/src/fallback.ts.test.ts new file mode 100644 index 000000000..042d43627 --- /dev/null +++ b/e2e/filter/fixtures-related/src/fallback.ts.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from '@rstest/core'; +import { unrelated } from './unrelated'; + +it('should not be selected by substring-only fallback matching', () => { + expect(unrelated()).toBe('unrelated'); +}); diff --git a/e2e/filter/fixtures-related/src/index.ts b/e2e/filter/fixtures-related/src/index.ts new file mode 100644 index 000000000..18acf4c64 --- /dev/null +++ b/e2e/filter/fixtures-related/src/index.ts @@ -0,0 +1,3 @@ +import { greet } from './shared'; + +export const sayHi = () => greet('index'); diff --git a/e2e/filter/fixtures-related/src/other.ts b/e2e/filter/fixtures-related/src/other.ts new file mode 100644 index 000000000..1d04f6b4d --- /dev/null +++ b/e2e/filter/fixtures-related/src/other.ts @@ -0,0 +1,3 @@ +import { greet } from './shared'; + +export const sayBye = () => greet('other'); diff --git a/e2e/filter/fixtures-related/src/shared.ts b/e2e/filter/fixtures-related/src/shared.ts new file mode 100644 index 000000000..629f2e058 --- /dev/null +++ b/e2e/filter/fixtures-related/src/shared.ts @@ -0,0 +1 @@ +export const greet = (name: string) => `Hello, ${name}!`; diff --git a/e2e/filter/fixtures-related/src/unrelated.ts b/e2e/filter/fixtures-related/src/unrelated.ts new file mode 100644 index 000000000..f44e8d266 --- /dev/null +++ b/e2e/filter/fixtures-related/src/unrelated.ts @@ -0,0 +1 @@ +export const unrelated = () => 'unrelated'; diff --git a/e2e/filter/related.test.ts b/e2e/filter/related.test.ts new file mode 100644 index 000000000..e761f97d2 --- /dev/null +++ b/e2e/filter/related.test.ts @@ -0,0 +1,186 @@ +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); + +const relatedFixturePath = join(__dirname, 'fixtures-related'); +const dynamicFixturePath = join(__dirname, 'fixtures-related-dynamic'); +const mixedFixturePath = join(__dirname, 'fixtures-related-mixed'); + +const collectRunTestFileLogs = (stdout: string) => + stdout + .split('\n') + .filter((log) => log.includes('.test.ts')) + .sort(); + +describe('related test filtering', () => { + it('should run only tests related to a leaf source file', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run', '--related', 'src/index.ts'], + options: { + nodeOptions: { + cwd: relatedFixturePath, + }, + }, + }); + + await expectExecSuccess(); + + const logs = collectRunTestFileLogs(cli.stdout); + + expect(logs).toMatchInlineSnapshot(` + [ + " ✓ index.test.ts (1)", + ] + `); + }); + + it('should include both test files for a shared dependency', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run', '--related', 'src/shared.ts'], + options: { + nodeOptions: { + cwd: relatedFixturePath, + }, + }, + }); + + await expectExecSuccess(); + + const logs = collectRunTestFileLogs(cli.stdout); + + expect(logs).toMatchInlineSnapshot(` + [ + " ✓ index.test.ts (1)", + " ✓ other.test.ts (1)", + ] + `); + }); + + it('should resolve async dependencies and the Jest alias', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run', '--findRelatedTests', 'src/late.ts'], + options: { + nodeOptions: { + cwd: dynamicFixturePath, + }, + }, + }); + + await expectExecSuccess(); + + const logs = collectRunTestFileLogs(cli.stdout); + + expect(logs).toMatchInlineSnapshot(` + [ + " ✓ index.test.ts (1)", + ] + `); + }); + + it('should support list mode with related source filters', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--related', 'src/shared.ts', '--filesOnly'], + options: { + nodeOptions: { + cwd: relatedFixturePath, + }, + }, + }); + + await expectExecSuccess(); + + const logs = cli.stdout.split('\n').filter(Boolean); + + expect(logs).toMatchInlineSnapshot(` + [ + "index.test.ts", + "other.test.ts", + ] + `); + }); + + it('should print the original related source filter when nothing matches', async () => { + const { cli, expectExecFailed } = await runRstestCli({ + command: 'rstest', + args: ['run', '--related', '404.ts'], + options: { + nodeOptions: { + cwd: relatedFixturePath, + }, + }, + }); + + await expectExecFailed(); + + expect(cli.stderr).toContain('No test files found, exiting with code 1.'); + expect(cli.log).toContain('related:'); + expect(cli.log).toContain('404.ts'); + expect(cli.log).not.toContain('__rstest_related_no_match__'); + }); + + it('should not fall back to substring file matching when related finds no tests', async () => { + const { cli, expectExecFailed } = await runRstestCli({ + command: 'rstest', + args: ['run', '--related', 'src/fallback.ts'], + options: { + nodeOptions: { + cwd: relatedFixturePath, + }, + }, + }); + + await expectExecFailed(); + + expect(cli.stderr).toContain('No test files found, exiting with code 1.'); + expect(cli.log).toContain('related:'); + expect(cli.log).toContain('src/fallback.ts'); + expect(cli.log).not.toContain('src/fallback.ts.test.ts'); + }); + + it('should not initialize browser related resolution for node-only sources', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run', '--related', 'node/src/index.ts'], + options: { + nodeOptions: { + cwd: mixedFixturePath, + }, + }, + }); + + await expectExecSuccess(); + + const logs = collectRunTestFileLogs(cli.stdout); + + expect(logs).toMatchInlineSnapshot(` + [ + " ✓ [node-project] node/index.test.ts (1)", + ] + `); + expect(cli.log).not.toContain('invalid'); + }); + + it('should keep exact related test paths without prefix matching extra files', async () => { + const { cli, expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['list', '--related', 'index.test.ts', '--filesOnly'], + options: { + nodeOptions: { + cwd: relatedFixturePath, + }, + }, + }); + + await expectExecSuccess(); + + expect(cli.stdout.split('\n').filter(Boolean)).toEqual(['index.test.ts']); + }); +}); diff --git a/packages/browser/src/hostController.ts b/packages/browser/src/hostController.ts index c58d6cc09..b0c5d13fe 100644 --- a/packages/browser/src/hostController.ts +++ b/packages/browser/src/hostController.ts @@ -922,6 +922,7 @@ const collectProjectEntries = async ( rootPath: context.rootPath, projectRoot: project.rootPath, fileFilters: context.fileFilters || [], + fileFilterMode: context.fileFilterMode, }); const setup = getSetupFiles(setupFiles, project.rootPath); @@ -1661,8 +1662,10 @@ export const runBrowserController = async ( context: Rstest, options?: BrowserTestRunOptions, ): Promise => { - const { skipOnTestRunEnd = false } = options ?? {}; + const { skipOnTestRunEnd = false, allowEmptyWatchRun = false } = + options ?? {}; const buildStart = Date.now(); + const isWatchMode = context.command === 'watch'; const browserProjects = getBrowserProjects(context); const useHeadlessDirect = browserProjects.every( (project) => project.normalizedConfig.browser.headless, @@ -1879,27 +1882,43 @@ export const runBrowserController = async ( (total, item) => total + item.testFiles.length, 0, ); + const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun; if (totalTests === 0) { const code = context.normalizedConfig.passWithNoTests ? 0 : 1; if (!skipOnTestRunEnd) { - const message = `No test files found, exiting with code ${code}.`; + const message = shouldKeepWatchingWithEmptySet + ? 'No test files found.' + : `No test files found, exiting with code ${code}.`; if (code === 0) { logger.log(color.yellow(message)); } else { logger.error(color.red(message)); } + + if (context.relatedFilters?.length) { + logger.log( + color.gray('related: '), + context.relatedFilters.join(color.gray(', ')), + ); + } else if (context.fileFilters?.length) { + logger.log( + color.gray('filter: '), + context.fileFilters.join(color.gray(', ')), + ); + } } - if (code !== 0) { + if (code !== 0 && !shouldKeepWatchingWithEmptySet) { ensureProcessExitCode(code); } - return; + if (!shouldKeepWatchingWithEmptySet) { + return; + } } await notifyTestRunStart(); - const isWatchMode = context.command === 'watch'; const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled(); const browserTempOutputRoot = context.normalizedConfig.output.distPath.root; const tempDir = @@ -2619,6 +2638,69 @@ export const runBrowserController = async ( }, }); + if (allTestFiles.length === 0) { + const duration = { + totalTime: buildTime, + buildTime, + testTime: 0, + }; + const result = { + results: reporterResults, + testResults: caseResults, + duration, + hasFailure: false, + getSourcemap: getBrowserSourcemap, + resolveSourcemap: resolveBrowserSourcemap, + close: skipOnTestRunEnd + ? async () => { + sessionRegistry.clear(); + await destroyBrowserRuntime(runtime); + } + : undefined, + }; + + if (!skipOnTestRunEnd) { + await notifyTestRunEnd({ duration }); + } + + if (isWatchMode) { + triggerRerun = async () => { + const newProjectEntries = await collectProjectEntries(context); + const rerunPlan = planWatchRerun({ + projectEntries: newProjectEntries, + previousTestFiles: watchContext.lastTestFiles, + affectedTestFiles: watchContext.affectedTestFiles, + }); + watchContext.affectedTestFiles = []; + + if (rerunPlan.filesChanged) { + watchContext.lastTestFiles = rerunPlan.currentTestFiles; + if (rerunPlan.currentTestFiles.length === 0) { + logger.log( + color.cyan('No browser test files remain after update.\n'), + ); + logBrowserWatchReadyMessage(enableCliShortcuts); + return; + } + + logger.log( + color.cyan( + `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`, + ), + ); + void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles); + return; + } + + logBrowserWatchReadyMessage(enableCliShortcuts); + }; + watchContext.hooksEnabled = true; + logBrowserWatchReadyMessage(enableCliShortcuts); + } + + return result; + } + const testStart = Date.now(); await runFilesWithPool(allTestFiles); const testTime = Date.now() - testStart; @@ -3095,24 +3177,27 @@ export const runBrowserController = async ( }); }; - const testStart = Date.now(); - try { - await waitForRunnerFramesReady( - currentTestFiles.map((file) => file.testPath), - ); + let testTime = 0; + if (currentTestFiles.length > 0) { + const testStart = Date.now(); + try { + await waitForRunnerFramesReady( + currentTestFiles.map((file) => file.testPath), + ); - for (const file of currentTestFiles) { - await enqueueHeadedReload(file); - if (fatalError) { - break; + for (const file of currentTestFiles) { + await enqueueHeadedReload(file); + if (fatalError) { + break; + } } + } catch (error) { + fatalError = fatalError ?? toError(error); + ensureProcessExitCode(1); } - } catch (error) { - fatalError = fatalError ?? toError(error); - ensureProcessExitCode(1); - } - const testTime = Date.now() - testStart; + testTime = Date.now() - testStart; + } // Define rerun logic for watch mode if (isWatchMode) { @@ -3136,6 +3221,13 @@ export const runBrowserController = async ( watchContext.lastTestFiles = rerunPlan.currentTestFiles; currentTestFiles = rerunPlan.currentTestFiles; await rpcManager.notifyTestFileUpdate(currentTestFiles); + if (currentTestFiles.length === 0) { + logger.log( + color.cyan('No browser test files remain after update.\n'), + ); + logBrowserWatchReadyMessage(enableCliShortcuts); + return; + } await waitForRunnerFramesReady( currentTestFiles.map((file) => file.testPath), ); diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index f2ad05071..936ae34fc 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -1,8 +1,11 @@ import cac, { type CAC, type Command } from 'cac'; import { normalize } from 'pathe'; import type { + FileFilterMode, ListCommandOptions, + Project, RstestCommand, + RstestConfig, RstestInstance, } from '../types'; import { color, determineAgent, formatError, logger } from '../utils'; @@ -35,6 +38,11 @@ const runtimeOptionDefinitions: OptionDefinition[] = [ '-r, --root ', 'Specify the project root directory, can be an absolute path or a path relative to cwd', ], + [ + '--related', + 'Treat positional arguments as source file paths and run only related tests', + ], + ['--findRelatedTests', 'Alias for --related for Jest compatibility'], ['--globals', 'Provide global APIs'], ['--isolate', 'Run tests in an isolated environment'], ['--include ', 'Match test files'], @@ -224,6 +232,59 @@ export const normalizeCliFilters = ( filters: ReadonlyArray, ): string[] => filters.map((filter) => normalize(String(filter))); +const isRelatedRun = (options: CommonOptions): boolean => + options.related === true || options.findRelatedTests === true; + +const resolveEffectiveCliFilters = async ({ + options, + filters, + createRstest, + config, + configFilePath, + projects, +}: { + options: CommonOptions; + filters: Array; + createRstest: ( + input: { + config: RstestConfig; + configFilePath?: string; + projects: Project[]; + }, + command: RstestCommand, + fileFilters: string[], + ) => RstestInstance; + config: RstestConfig; + configFilePath?: string; + projects: Project[]; +}): Promise<{ + effectiveFilters: string[]; + fileFilterMode: FileFilterMode; + relatedFilters?: string[]; + relatedResolutionEmpty?: boolean; +}> => { + const normalizedFilters = normalizeCliFilters(filters); + + if (!isRelatedRun(options)) { + return { effectiveFilters: normalizedFilters, fileFilterMode: 'fuzzy' }; + } + + const { resolveRelatedTestFiles } = await import('../core/related'); + const rstest = createRstest({ config, configFilePath, projects }, 'list', []); + + const relatedFiles = await resolveRelatedTestFiles( + rstest.context, + normalizedFilters, + ); + + return { + effectiveFilters: relatedFiles, + fileFilterMode: 'exact', + relatedFilters: normalizedFilters, + relatedResolutionEmpty: relatedFiles.length === 0, + }; +}; + export const runRest = async ({ options, filters, @@ -241,11 +302,28 @@ export const runRest = async ({ try { const { config, configFilePath, projects, createRstest } = await resolveCliRuntime(options); + const { + effectiveFilters, + fileFilterMode, + relatedFilters, + relatedResolutionEmpty, + } = await resolveEffectiveCliFilters({ + options, + filters, + createRstest, + config, + configFilePath, + projects, + }); + rstest = createRstest( { config, configFilePath, projects }, command, - normalizeCliFilters(filters), + effectiveFilters, + fileFilterMode, ); + rstest.context.relatedFilters = relatedFilters; + rstest.context.relatedResolutionEmpty = relatedResolutionEmpty; process.on('uncaughtException', unexpectedlyExitHandler); @@ -350,11 +428,28 @@ export function createCli(): CAC { config.includeTaskLocation = true; } + const { + effectiveFilters, + fileFilterMode, + relatedFilters, + relatedResolutionEmpty, + } = await resolveEffectiveCliFilters({ + options, + filters, + createRstest, + config, + configFilePath, + projects, + }); + const rstest = createRstest( { config, configFilePath, projects }, 'list', - normalizeCliFilters(filters), + effectiveFilters, + fileFilterMode, ); + rstest.context.relatedFilters = relatedFilters; + rstest.context.relatedResolutionEmpty = relatedResolutionEmpty; await rstest.listTests({ filesOnly: options.filesOnly, diff --git a/packages/core/src/cli/init.ts b/packages/core/src/cli/init.ts index a40d4c306..a30767444 100644 --- a/packages/core/src/cli/init.ts +++ b/packages/core/src/cli/init.ts @@ -17,6 +17,8 @@ export type CommonOptions = { root?: string; config?: string; configLoader?: LoadConfigOptions['loader']; + related?: boolean; + findRelatedTests?: boolean; globals?: boolean; /** * Pool options. diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 903195364..127c62d68 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -1,4 +1,5 @@ import type { + FileFilterMode, ListCommandOptions, Project, RstestCommand, @@ -19,12 +20,14 @@ export function createRstest( }, command: RstestCommand, fileFilters: string[], + fileFilterMode?: FileFilterMode, ): RstestInstance { const context = new Rstest( { cwd: process.cwd(), command, fileFilters, + fileFilterMode, configFilePath, projects, }, diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index e53da85e3..64a3e7fe4 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -402,6 +402,46 @@ export async function listTests( ): Promise { const { rootPath } = context; const { shard } = context.normalizedConfig; + const showProject = context.projects.length > 1; + + if (context.relatedResolutionEmpty) { + const tests: ListedTest[] = []; + + if (json && json !== 'false') { + const content = JSON.stringify( + summary + ? { + items: tests, + summary: createListSummaryPayload({ + tests, + filesOnly, + includeSuites, + showProject, + }), + } + : tests, + null, + 2, + ); + if (json !== true && json !== 'true') { + const jsonPath = isAbsolute(json) ? json : join(rootPath, json); + mkdirSync(dirname(jsonPath), { recursive: true }); + writeFileSync(jsonPath, content); + } else { + logger.log(content); + } + } else if (summary) { + printListSummary({ + tests, + filesOnly, + includeSuites, + showProject, + write: logger.log, + }); + } + + return []; + } const shardedEntries = await resolveShardedEntries(context); const testEntries: Record> = {}; @@ -440,6 +480,7 @@ export async function listTests( rootPath, projectRoot: root, fileFilters: context.fileFilters || [], + fileFilterMode: context.fileFilterMode, includeSource, }); @@ -491,8 +532,6 @@ export async function listTests( }; const hasError = list.some((file) => file.errors?.length) || errors.length; - const showProject = context.projects.length > 1; - if (hasError) { const { printError } = await import('../utils/error'); process.exitCode = 1; diff --git a/packages/core/src/core/related.ts b/packages/core/src/core/related.ts new file mode 100644 index 000000000..38b2d4254 --- /dev/null +++ b/packages/core/src/core/related.ts @@ -0,0 +1,405 @@ +import { existsSync } from 'node:fs'; +import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; +import { isAbsolute, normalize, relative, resolve } from 'pathe'; +import type { ProjectContext, RstestContext } from '../types'; +import { getTestEntries } from '../utils'; +import { getSetupFiles } from '../utils/getSetupFiles'; +import { prepareRsbuild } from './rsbuild'; + +type StatsModuleReason = NonNullable[number]; + +type ModuleGraph = { + allSources: Set; + dependentsBySource: Map>; +}; + +const stripSourceProtocol = (source: string): string => { + if (source.startsWith('file://')) { + const path = source.slice('file://'.length); + // Windows file URLs look like file:///C:/path — strip only the leading slash + // before the drive letter. On POSIX, keep the leading slash: /home/... + return /^\/[a-zA-Z]:/.test(path) ? path.slice(1) : path; + } + return source.replace(/^[a-zA-Z]+:\/\//, ''); +}; + +export const resolveStatsPathCandidate = ({ + candidate, + projectRoot, +}: { + candidate: string; + projectRoot: string; +}): string | null => { + let normalizedCandidate = stripSourceProtocol(candidate.trim()); + + if (!normalizedCandidate) { + return null; + } + + const bangIndex = normalizedCandidate.lastIndexOf('!'); + if (bangIndex !== -1) { + normalizedCandidate = normalizedCandidate.slice(bangIndex + 1); + } + + if ( + normalizedCandidate.startsWith('builtin:') || + normalizedCandidate.startsWith('data:') || + normalizedCandidate.startsWith('webpack/') || + normalizedCandidate.startsWith('rspack/') + ) { + return null; + } + + const queryIndex = normalizedCandidate.search(/[?#]/); + if (queryIndex !== -1) { + normalizedCandidate = normalizedCandidate.slice(0, queryIndex); + } + + if (!normalizedCandidate) { + return null; + } + + const absolutePath = isAbsolute(normalizedCandidate) + ? normalize(normalizedCandidate) + : normalize(resolve(projectRoot, normalizedCandidate)); + + return absolutePath; +}; + +const normalizeStatsPathCandidate = ({ + candidate, + projectRoot, +}: { + candidate: string; + projectRoot: string; +}): string | null => { + const absolutePath = resolveStatsPathCandidate({ + candidate, + projectRoot, + }); + + return absolutePath && existsSync(absolutePath) ? absolutePath : null; +}; + +const normalizeStatsModulePath = ({ + module, + projectRoot, +}: { + module: Rspack.StatsModule; + projectRoot: string; +}): string | null => { + const candidate = + typeof module.nameForCondition === 'string' && module.nameForCondition + ? module.nameForCondition + : module.identifier || ''; + + return normalizeStatsPathCandidate({ + candidate, + projectRoot, + }); +}; + +const normalizeStatsReasonPath = ({ + reason, + projectRoot, +}: { + reason: StatsModuleReason; + projectRoot: string; +}): string | null => { + const candidate = + reason.moduleIdentifier || reason.moduleName || reason.module || ''; + + return normalizeStatsPathCandidate({ + candidate, + projectRoot, + }); +}; + +const collectModuleGraph = ({ + modules, + projectRoot, +}: { + modules: Rspack.StatsModule[] | undefined; + projectRoot: string; +}): ModuleGraph => { + const allSources = new Set(); + const dependentsBySource = new Map>(); + + const visitModules = (statsModules: Rspack.StatsModule[] | undefined) => { + for (const module of statsModules || []) { + const sourcePath = normalizeStatsModulePath({ + module, + projectRoot, + }); + + if (sourcePath) { + allSources.add(sourcePath); + + for (const reason of module.reasons || []) { + const dependentPath = normalizeStatsReasonPath({ + reason, + projectRoot, + }); + + if (!dependentPath) { + continue; + } + + allSources.add(dependentPath); + + const dependents = dependentsBySource.get(sourcePath) || new Set(); + dependents.add(dependentPath); + dependentsBySource.set(sourcePath, dependents); + } + } + + if (module.modules?.length) { + visitModules(module.modules); + } + } + }; + + visitModules(modules); + + return { + allSources, + dependentsBySource, + }; +}; + +const collectReachableDependents = ({ + dependentsBySource, + initialSources, +}: { + dependentsBySource: Map>; + initialSources: Iterable; +}): Set => { + const visited = new Set(); + const queue = Array.from(initialSources); + + for (const source of queue) { + visited.add(source); + } + + while (queue.length > 0) { + const currentSource = queue.shift()!; + + for (const dependent of dependentsBySource.get(currentSource) || []) { + if (visited.has(dependent)) { + continue; + } + + visited.add(dependent); + queue.push(dependent); + } + } + + return visited; +}; + +const collectProjectEntries = async ( + context: RstestContext, +): Promise>> => { + const entries = new Map>(); + + await Promise.all( + context.projects.map(async (project) => { + const { include, exclude, includeSource, root } = + project.normalizedConfig; + + entries.set( + project.environmentName, + await getTestEntries({ + include, + exclude: exclude.patterns, + includeSource, + rootPath: context.rootPath, + projectRoot: root, + fileFilters: [], + }), + ); + }), + ); + + return entries; +}; + +const buildSetupFiles = ( + projects: ProjectContext[], + key: 'setupFiles' | 'globalSetup', +): Record> => { + return Object.fromEntries( + projects.map((project) => [ + project.environmentName, + getSetupFiles(project.normalizedConfig[key], project.rootPath), + ]), + ); +}; + +const createRelatedBuildSafeguardsPlugin = (): RsbuildPlugin => ({ + name: 'rstest:related-build-safeguards', + setup(api) { + api.modifyRsbuildConfig((config) => ({ + ...config, + dev: { + ...(config.dev || {}), + lazyCompilation: false, + }, + performance: { + ...(config.performance || {}), + buildCache: false, + }, + })); + api.modifyRspackConfig((rspackConfig) => { + // Related-file graph collection needs a complete, fresh graph. + // Keep it independent from user-enabled lazy compilation and + // persistent cache settings. + rspackConfig.lazyCompilation = false; + rspackConfig.cache = false; + }); + }, +}); + +const normalizeExactPathMatch = (filePath: string): string => { + const normalizedPath = normalize(filePath); + + return process.platform === 'win32' + ? normalizedPath.toLocaleLowerCase() + : normalizedPath; +}; + +const collectDirectlyMatchedFiles = ({ + files, + sourceFilters, + rootPath, +}: { + files: string[]; + sourceFilters: string[]; + rootPath: string; +}): string[] => { + const exactSourcePaths = new Set(); + + for (const sourceFilter of sourceFilters) { + exactSourcePaths.add(normalizeExactPathMatch(sourceFilter)); + exactSourcePaths.add( + normalizeExactPathMatch(resolve(rootPath, sourceFilter)), + ); + } + + return files.filter((filePath) => { + const normalizedFilePath = normalizeExactPathMatch(filePath); + const normalizedRelativeFilePath = normalizeExactPathMatch( + relative(rootPath, filePath), + ); + + return ( + exactSourcePaths.has(normalizedFilePath) || + exactSourcePaths.has(normalizedRelativeFilePath) + ); + }); +}; + +export async function resolveRelatedTestFiles( + context: RstestContext, + sourceFilters: string[], +): Promise { + if (sourceFilters.length === 0) { + throw new Error( + 'The `--related` option requires at least one source file path.', + ); + } + + const projectEntries = await collectProjectEntries(context); + const matchedTestFiles = new Set( + collectDirectlyMatchedFiles({ + files: Array.from(projectEntries.values()).flatMap((entries) => + Object.values(entries), + ), + sourceFilters, + rootPath: context.rootPath, + }), + ); + + const globTestSourceEntries = async (environmentName: string) => + projectEntries.get(environmentName) ?? {}; + + const setupFiles = buildSetupFiles(context.projects, 'setupFiles'); + const globalSetupFiles = buildSetupFiles(context.projects, 'globalSetup'); + + const rsbuildInstance = await prepareRsbuild( + context, + globTestSourceEntries, + setupFiles, + globalSetupFiles, + context.projects, + [createRelatedBuildSafeguardsPlugin()], + ); + + const devServer = await rsbuildInstance.createDevServer({ + getPortSilently: true, + }); + + try { + for (const project of context.projects) { + const environment = devServer.environments[project.environmentName]!; + const stats = await environment.getStats(); + const { modules } = stats.toJson({ + all: false, + modules: true, + nestedModules: true, + reasons: true, + }); + + const moduleGraph = collectModuleGraph({ + modules, + projectRoot: project.rootPath, + }); + const testPaths = Object.values( + projectEntries.get(project.environmentName) || {}, + ); + const setupPaths = Object.values( + setupFiles[project.environmentName] || {}, + ); + const globalSetupPaths = Object.values( + globalSetupFiles[project.environmentName] || {}, + ); + const matchedSources = collectDirectlyMatchedFiles({ + files: Array.from(moduleGraph.allSources), + sourceFilters, + rootPath: context.rootPath, + }); + + if (matchedSources.length === 0) { + continue; + } + + const reachableDependents = collectReachableDependents({ + dependentsBySource: moduleGraph.dependentsBySource, + initialSources: matchedSources, + }); + + const shouldRerunWholeProject = + setupPaths.some((setupPath) => reachableDependents.has(setupPath)) || + globalSetupPaths.some((setupPath) => + reachableDependents.has(setupPath), + ); + + if (shouldRerunWholeProject) { + for (const testPath of testPaths) { + matchedTestFiles.add(testPath); + } + continue; + } + + for (const testPath of testPaths) { + if (reachableDependents.has(testPath)) { + matchedTestFiles.add(testPath); + } + } + } + } finally { + await devServer.close(); + } + + return Array.from(matchedTestFiles).sort(); +} diff --git a/packages/core/src/core/rsbuild.ts b/packages/core/src/core/rsbuild.ts index 805349356..7913fb629 100644 --- a/packages/core/src/core/rsbuild.ts +++ b/packages/core/src/core/rsbuild.ts @@ -94,19 +94,25 @@ export const prepareRsbuild = async ( setupFiles: Record>, globalSetupFiles: Record>, /** - * Explicit list of node-mode projects to include in the Rsbuild instance. - * When provided, only these projects will be compiled. + * Explicit list of projects to include in the Rsbuild instance. + * + * Most callers still pass node-mode projects for execution, but related-test + * resolution also reuses this node-targeted build to collect a uniform module + * graph for browser projects. If browser graph collection ever needs a + * materially different build pipeline, split that behavior at the caller. */ - targetNodeProjects?: ProjectContext[], + targetProjects?: ProjectContext[], + extraPlugins: RsbuildPlugin[] = [], ): Promise => { const { command, normalizedConfig: { isolate, dev = {}, coverage, pool }, } = context; - // Filter out browser mode projects - this rsbuild is for node mode only - const projects = targetNodeProjects?.length - ? targetNodeProjects + // Default execution still excludes browser projects. Callers can opt in to a + // broader project set when they only need graph information. + const projects = targetProjects?.length + ? targetProjects : context.projects.filter( (project) => !project.normalizedConfig.browser.enabled, ); @@ -166,6 +172,7 @@ export const prepareRsbuild = async ( ) : null, pluginInspect({ poolExecArgv: pool.execArgv }), + ...extraPlugins, ].filter(Boolean) as RsbuildPlugin[], }, }); @@ -366,7 +373,7 @@ export const createRsbuildServer = async ({ }: { isWatchMode: boolean; rsbuildInstance: RsbuildInstance; - inspectedConfig: RstestContext['normalizedConfig'] & { + inspectedConfig?: RstestContext['normalizedConfig'] & { projects: NormalizedProjectConfig[]; }; globTestSourceEntries: (name: string) => Promise>; @@ -422,7 +429,7 @@ export const createRsbuildServer = async ({ getPortSilently: true, }); - if (isDebug()) { + if (isDebug() && inspectedConfig) { await rsbuildInstance.inspectConfig({ writeToDisk: true, extraConfigs: { diff --git a/packages/core/src/core/rstest.ts b/packages/core/src/core/rstest.ts index 3d43236e7..c1ba738b1 100644 --- a/packages/core/src/core/rstest.ts +++ b/packages/core/src/core/rstest.ts @@ -12,6 +12,7 @@ import { JUnitReporter } from '../reporter/junit'; import { MdReporter } from '../reporter/md'; import { VerboseReporter } from '../reporter/verbose'; import type { + FileFilterMode, NormalizedConfig, NormalizedProjectConfig, Project, @@ -38,6 +39,7 @@ type Options = { cwd: string; command: RstestCommand; fileFilters?: string[]; + fileFilterMode?: FileFilterMode; configFilePath?: string; projects: Project[]; }; @@ -46,6 +48,9 @@ export class Rstest implements RstestContext { public cwd: string; public command: RstestCommand; public fileFilters?: string[]; + public fileFilterMode?: FileFilterMode; + public relatedFilters?: string[]; + public relatedResolutionEmpty?: boolean; public configFilePath?: string; public reporters: Reporter[]; public snapshotManager: SnapshotManager; @@ -81,6 +86,7 @@ export class Rstest implements RstestContext { cwd = process.cwd(), command, fileFilters, + fileFilterMode, configFilePath, projects, }: Options, @@ -89,6 +95,7 @@ export class Rstest implements RstestContext { this.cwd = cwd; this.command = command; this.fileFilters = fileFilters; + this.fileFilterMode = fileFilterMode; this.configFilePath = configFilePath; const rootPath = userConfig.root diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index 173ccc9a4..ea60a7c18 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -42,6 +42,97 @@ const getSignalExitCode = (signal: NodeJS.Signals): number => { return typeof signalNumber === 'number' ? 128 + signalNumber : 1; }; +const reportNoTestFiles = ({ + context, + mode = 'all', +}: { + context: Rstest; + mode?: 'all' | 'on-demand'; +}) => { + if (context.command === 'watch') { + if (mode === 'on-demand') { + logger.log(color.yellow('No test files need re-run.')); + } else { + logger.log(color.yellow('No test files found.')); + } + } else { + const code = context.normalizedConfig.passWithNoTests ? 0 : 1; + const message = `No test files found, exiting with code ${code}.`; + + if (code === 0) { + logger.log(color.yellow(message)); + } else { + logger.error(color.red(message)); + } + + process.exitCode = code; + } + + if (mode === 'all') { + if (context.relatedFilters?.length) { + logger.log( + color.gray('related: '), + context.relatedFilters.join(color.gray(', ')), + ); + } else if (context.fileFilters?.length) { + logger.log( + color.gray('filter: '), + context.fileFilters.join(color.gray(', ')), + ); + } + + context.projects.forEach((p) => { + if (context.projects.length > 1) { + logger.log(''); + logger.log(color.gray('project:'), p.name); + } + logger.log(color.gray('root:'), p.rootPath); + + logger.log( + color.gray('include:'), + p.normalizedConfig.include.join(color.gray(', ')), + ); + logger.log( + color.gray('exclude:'), + p.normalizedConfig.exclude.patterns.join(color.gray(', ')), + ); + }); + } +}; + +const notifyReportersOnTestRunEnd = async ({ + context, + coverage, + duration, + getSourcemap, + unhandledErrors, + filterRerunTestPaths, +}: { + context: Rstest; + coverage?: CoverageMap; + duration: { + totalTime: number; + buildTime: number; + testTime: number; + }; + getSourcemap: (sourcePath: string) => Promise; + unhandledErrors?: Error[]; + filterRerunTestPaths?: string[]; +}) => { + for (const reporter of context.reporters) { + await reporter.onTestRunEnd?.({ + results: context.reporterResults.results, + coverage: coverage?.toJSON(), + testResults: context.reporterResults.testResults, + unhandledErrors, + snapshotSummary: context.snapshotManager.summary, + duration, + getSourcemap, + filterRerunTestPaths, + }); + } +}; + export async function runTests(context: Rstest): Promise { cleanCoverageReports(context.normalizedConfig.coverage); @@ -61,9 +152,32 @@ export async function runTests(context: Rstest): Promise { // For non-watch mode with both browser and node tests, we need to unify reporter output const shouldUnifyReporter = !isWatchMode && hasBrowserProjects && hasNodeProjects; + const getEmptyRunDuration = () => ({ + totalTime: 0, + buildTime: 0, + testTime: 0, + }); // If only browser tests, run them and generate coverage if (hasBrowserProjects && !hasNodeProjects) { + if (context.relatedResolutionEmpty) { + if (isWatchMode) { + await runBrowserModeTests(context, browserProjects, { + skipOnTestRunEnd: false, + allowEmptyWatchRun: true, + }); + } else { + reportNoTestFiles({ context }); + await notifyReportersOnTestRunEnd({ + context, + duration: getEmptyRunDuration(), + getSourcemap: async () => null, + }); + } + + return; + } + const { coverage } = context.normalizedConfig; if (coverage.enabled) { @@ -121,6 +235,9 @@ export async function runTests(context: Rstest): Promise { const globTestSourceEntries = async ( name: string, ): Promise> => { + if (context.relatedResolutionEmpty) { + return {}; + } if (!isWatchMode && shard && entriesCache.has(name)) { return entriesCache.get(name)!.entries; } @@ -134,6 +251,7 @@ export async function runTests(context: Rstest): Promise { rootPath, projectRoot: root, fileFilters: context.fileFilters || [], + fileFilterMode: context.fileFilterMode, }); entriesCache.set(name, { @@ -179,6 +297,11 @@ export async function runTests(context: Rstest): Promise { }); } + if (isWatchMode && context.relatedResolutionEmpty) { + browserProjectsToRun = browserProjects; + nodeProjectsToRun = []; + } + const hasBrowserTestsToRun = browserProjectsToRun.length > 0; const hasNodeTestsToRun = nodeProjectsToRun.length > 0; @@ -196,6 +319,7 @@ export async function runTests(context: Rstest): Promise { browserResultPromise = runBrowserModeTests(context, browserProjectsToRun, { skipOnTestRunEnd: shouldUnifyReporter, shardedEntries: shard ? browserEntries : undefined, + allowEmptyWatchRun: isWatchMode && context.relatedResolutionEmpty, }); // Prevent an unhandled rejection window in mixed node+browser runs. @@ -323,7 +447,7 @@ export async function runTests(context: Rstest): Promise { await reporter.onTestRunStart?.(); } - let testStart: number; + let testStart: number | undefined; const currentEntries: EntryInfo[] = []; const currentDeletedEntries: string[] = []; @@ -435,9 +559,10 @@ export async function runTests(context: Rstest): Promise { }), ); - const buildTime = testStart! - buildStart; + testStart ??= buildStart; + const buildTime = testStart - buildStart; - const testTime = Date.now() - testStart!; + const testTime = Date.now() - testStart; // Wait for browser tests to complete if running in parallel const browserResult = browserResultPromise @@ -525,50 +650,10 @@ export async function runTests(context: Rstest): Promise { const browserHasFailure = shouldUnifyReporter && browserResult?.hasFailure; - if (results.length === 0 && !errors.length) { - if (command === 'watch') { - if (mode === 'on-demand') { - logger.log(color.yellow('No test files need re-run.')); - } else { - logger.log(color.yellow('No test files found.')); - } - } else { - const code = context.normalizedConfig.passWithNoTests ? 0 : 1; + const noTestsDiscovered = results.length === 0 && !errors.length; - const message = `No test files found, exiting with code ${code}.`; - if (code === 0) { - logger.log(color.yellow(message)); - } else { - logger.error(color.red(message)); - } - - process.exitCode = code; - } - if (mode === 'all') { - if (context.fileFilters?.length) { - logger.log( - color.gray('filter: '), - context.fileFilters.join(color.gray(', ')), - ); - } - - allProjects.forEach((p) => { - if (allProjects.length > 1) { - logger.log(''); - logger.log(color.gray('project:'), p.name); - } - logger.log(color.gray('root:'), p.rootPath); - - logger.log( - color.gray('include:'), - p.normalizedConfig.include.join(color.gray(', ')), - ); - logger.log( - color.gray('exclude:'), - p.normalizedConfig.exclude.patterns.join(color.gray(', ')), - ); - }); - } + if (noTestsDiscovered) { + reportNoTestFiles({ context, mode }); } const isFailure = nodeHasFailure || browserHasFailure; @@ -577,20 +662,16 @@ export async function runTests(context: Rstest): Promise { process.exitCode = 1; } - for (const reporter of reporters) { - await reporter.onTestRunEnd?.({ - results: context.reporterResults.results, - coverage: mergedCoverageMap?.toJSON(), - testResults: context.reporterResults.testResults, - unhandledErrors: errors, - snapshotSummary: snapshotManager.summary, - duration, - getSourcemap, - filterRerunTestPaths: currentEntries.length - ? currentEntries.map((e) => e.testPath) - : undefined, - }); - } + await notifyReportersOnTestRunEnd({ + context, + coverage: mergedCoverageMap, + duration, + getSourcemap, + unhandledErrors: errors, + filterRerunTestPaths: currentEntries.length + ? currentEntries.map((e) => e.testPath) + : undefined, + }); // Generate coverage reports after all tests complete if (coverageProvider && (!isFailure || coverage.reportOnFailure)) { diff --git a/packages/core/src/types/browser.ts b/packages/core/src/types/browser.ts index 8bd82cae8..da95d2e5d 100644 --- a/packages/core/src/types/browser.ts +++ b/packages/core/src/types/browser.ts @@ -26,6 +26,10 @@ export interface BrowserTestRunOptions { * Key is project environmentName. */ shardedEntries?: Map }>; + /** + * Keep watch infrastructure alive even when the initial browser test set is empty. + */ + allowEmptyWatchRun?: boolean; } /** diff --git a/packages/core/src/types/core.ts b/packages/core/src/types/core.ts index 3bc336b3c..d9578e85b 100644 --- a/packages/core/src/types/core.ts +++ b/packages/core/src/types/core.ts @@ -20,6 +20,7 @@ export type ProjectEntries = { }; export type RstestCommand = 'watch' | 'run' | 'list' | 'merge-reports'; +export type FileFilterMode = 'fuzzy' | 'exact'; export type Project = { config: RstestConfig; configFilePath?: string }; @@ -61,6 +62,12 @@ export type RstestContext = { normalizedConfig: NormalizedConfig; /** filter by a filename regex pattern */ fileFilters?: string[]; + /** How file filters should match discovered test files. */ + fileFilterMode?: FileFilterMode; + /** Original source filters passed to `--related` / `--findRelatedTests`. */ + relatedFilters?: string[]; + /** `--related` resolved successfully but matched no test files. */ + relatedResolutionEmpty?: boolean; /** The config file path. */ configFilePath?: string; /** diff --git a/packages/core/src/utils/shard.ts b/packages/core/src/utils/shard.ts index ec87f147e..470cf561c 100644 --- a/packages/core/src/utils/shard.ts +++ b/packages/core/src/utils/shard.ts @@ -53,6 +53,7 @@ export async function resolveShardedEntries( rootPath, projectRoot: root, fileFilters: fileFilters || [], + fileFilterMode: context.fileFilterMode, }); return Object.entries(entries).map(([alias, testPath]) => ({ project: p.environmentName, diff --git a/packages/core/src/utils/testFiles.ts b/packages/core/src/utils/testFiles.ts index 18fee87db..03d5dc696 100644 --- a/packages/core/src/utils/testFiles.ts +++ b/packages/core/src/utils/testFiles.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import pathe from 'pathe'; import { glob } from 'tinyglobby'; -import type { Project } from '../types'; +import type { FileFilterMode, Project } from '../types'; import { castArray, parsePosix } from './helper'; import { color } from './logger'; @@ -9,6 +9,7 @@ export const filterFiles = ( testFiles: string[], filters: string[], dir: string, + mode: FileFilterMode = 'fuzzy', ): string[] => { if (!filters.length) { return testFiles; @@ -19,6 +20,28 @@ export const filterFiles = ( ? filters.map((f) => f.split(pathe.sep).join('/')) : filters; + if (mode === 'exact') { + const normalizeExactMatchPath = (filePath: string) => { + const normalizedPath = pathe.normalize(filePath); + return process.platform === 'win32' + ? normalizedPath.toLocaleLowerCase() + : normalizedPath; + }; + + const exactFilters = new Set( + fileFilters.map((filter) => normalizeExactMatchPath(filter)), + ); + + return testFiles.filter((testFilePath) => { + const absolutePath = normalizeExactMatchPath(testFilePath); + const relativePath = normalizeExactMatchPath( + pathe.relative(dir, testFilePath), + ); + + return exactFilters.has(absolutePath) || exactFilters.has(relativePath); + }); + } + return testFiles.filter((t) => { const testFile = pathe.relative(dir, t).toLocaleLowerCase(); return fileFilters.some((f) => { @@ -77,6 +100,7 @@ export const getTestEntries = async ({ rootPath, projectRoot, fileFilters, + fileFilterMode, includeSource, }: { rootPath: string; @@ -84,6 +108,7 @@ export const getTestEntries = async ({ exclude: string[]; includeSource: string[]; fileFilters: string[]; + fileFilterMode?: FileFilterMode; projectRoot: string; }): Promise> => { const testFiles = await glob(include, { @@ -118,10 +143,12 @@ export const getTestEntries = async ({ } return Object.fromEntries( - filterFiles(testFiles, fileFilters, rootPath).map((entry) => { - const relativePath = pathe.relative(rootPath, entry); - return [formatTestEntryName(relativePath), entry]; - }), + filterFiles(testFiles, fileFilters, rootPath, fileFilterMode).map( + (entry) => { + const relativePath = pathe.relative(rootPath, entry); + return [formatTestEntryName(relativePath), entry]; + }, + ), ); }; diff --git a/packages/core/tests/core/related.test.ts b/packages/core/tests/core/related.test.ts new file mode 100644 index 000000000..12c50df5f --- /dev/null +++ b/packages/core/tests/core/related.test.ts @@ -0,0 +1,59 @@ +import { resolveStatsPathCandidate } from '../../src/core/related'; +import { filterFiles } from '../../src/utils/testFiles'; + +describe('resolveStatsPathCandidate', () => { + it('preserves POSIX absolute paths after stripping file protocol', () => { + expect( + resolveStatsPathCandidate({ + candidate: 'file:///home/app/src/index.ts', + projectRoot: '/repo', + }), + ).toBe('/home/app/src/index.ts'); + }); + + it('preserves Windows absolute paths after stripping file protocol', () => { + expect( + resolveStatsPathCandidate({ + candidate: 'file:///C:/repo/src/index.ts', + projectRoot: '/repo', + }), + ).toBe('C:/repo/src/index.ts'); + }); + + it('resolves relative stats paths against the project root', () => { + expect( + resolveStatsPathCandidate({ + candidate: './src/index.ts?query', + projectRoot: '/repo', + }), + ).toBe('/repo/src/index.ts'); + }); +}); + +describe('filterFiles', () => { + it('matches exact related test paths without prefix expansion', () => { + expect( + filterFiles( + ['/repo/tests/index.test.ts', '/repo/tests/index.test.tsx'], + ['/repo/tests/index.test.ts'], + '/repo', + 'exact', + ), + ).toEqual(['/repo/tests/index.test.ts']); + }); + + it('keeps exact matching case-sensitive outside Windows', () => { + expect( + filterFiles( + ['/repo/tests/Foo.test.ts', '/repo/tests/foo.test.ts'], + ['/repo/tests/Foo.test.ts'], + '/repo', + 'exact', + ), + ).toEqual( + process.platform === 'win32' + ? ['/repo/tests/Foo.test.ts', '/repo/tests/foo.test.ts'] + : ['/repo/tests/Foo.test.ts'], + ); + }); +}); diff --git a/website/docs/en/guide/basic/cli.mdx b/website/docs/en/guide/basic/cli.mdx index e6b76d467..75205d1ee 100644 --- a/website/docs/en/guide/basic/cli.mdx +++ b/website/docs/en/guide/basic/cli.mdx @@ -63,6 +63,28 @@ $ npx rstest --watch `rstest run` will perform a single run, and the command is suitable for CI environments or scenarios where tests are not required to be performed while modifying. +### Run related tests + + + +Use `--related` when you want Rstest to treat positional arguments as source files and only run the tests that depend on those files. + +```bash +npx rstest run --related src/button.ts +``` + +Rstest resolves related tests from the build module graph, so the same filter works for Node mode and Browser Mode projects. You can also use the Jest-compatible alias: + +```bash +npx rstest run --findRelatedTests src/button.ts +``` + +If you only want to inspect the affected test files, combine it with `rstest list`: + +```bash +npx rstest list --related src/button.ts --filesOnly +``` + ## rstest watch `rstest watch` will start listening mode and execute tests, and when the test or dependent file modifications, the associated test file will be re-execute. @@ -234,6 +256,8 @@ Rstest CLI options are registered per command instead of being shared by every c | `-c, --config ` | Specify the configuration file, can be a relative or absolute path, see [Specify config file](/guide/basic/configure-rstest#specify-config-file) | | `--config-loader ` | Specify the config loader (`auto` \| `jiti` \| `native`), see [Rsbuild - Specify config loader](https://rsbuild.rs/guide/configuration/rsbuild#specify-config-loader) | | `-r, --root ` | Specify the project root directory, see [root](/config/test/root) | +| `--related` | Treat positional arguments as source file paths and only run related tests | +| `--findRelatedTests` | Alias for `--related` for Jest compatibility | | `--globals` | Provide global APIs, see [globals](/config/test/globals) | | `--isolate` | Run tests in an isolated environment, see [isolate](/config/test/isolate) | | `--reporter ` | Specify the test reporter, see [reporters](/config/test/reporters) | diff --git a/website/docs/zh/guide/basic/cli.mdx b/website/docs/zh/guide/basic/cli.mdx index d8bf83368..ec3a431f2 100644 --- a/website/docs/zh/guide/basic/cli.mdx +++ b/website/docs/zh/guide/basic/cli.mdx @@ -63,6 +63,28 @@ $ npx rstest --watch `rstest run` 将会执行单次测试,该命令适用于 CI 环境或不需要一边修改一边执行测试的场景。 +### 运行相关测试 + + + +当你希望把命令行位置参数视为源码文件,并只运行依赖这些源码的测试时,可以使用 `--related`: + +```bash +npx rstest run --related src/button.ts +``` + +Rstest 会基于构建得到的 module graph 解析相关测试,因此同一套过滤逻辑同时适用于 Node mode 和 Browser Mode。你也可以使用兼容 Jest 的别名: + +```bash +npx rstest run --findRelatedTests src/button.ts +``` + +如果你只想查看受影响的测试文件,可以配合 `rstest list` 使用: + +```bash +npx rstest list --related src/button.ts --filesOnly +``` + ## rstest watch `rstest watch` 将会启动监听模式并执行测试,当测试或依赖文件修改时,将重新执行关联的测试文件。 @@ -234,6 +256,8 @@ Rstest CLI 参数是按命令注册的,并不是所有命令共享同一套参 | `-c, --config ` | 指定配置文件路径(相对或绝对路径),详见 [指定配置文件](/guide/basic/configure-rstest#指定配置文件) | | `--config-loader ` | 指定配置加载器 (`auto` \| `jiti` \| `native`),详见 [Rsbuild - 指定加载方式](https://rsbuild.rs/guide/configuration/rsbuild#specify-config-loader) | | `-r, --root ` | 指定项目根目录,详见 [root](/config/test/root) | +| `--related` | 将位置参数视为源码文件路径,只运行相关测试 | +| `--findRelatedTests` | `--related` 的 Jest 兼容别名 | | `--globals` | 提供全局 API,详见 [globals](/config/test/globals) | | `--isolate` | 在隔离环境中运行测试,详见 [isolate](/config/test/isolate) | | `--exclude ` | 排除指定文件,详见 [exclude](/config/test/exclude) |