diff --git a/e2e/watch/fixtures-shortcuts/rstest.config.mts b/e2e/watch/fixtures-shortcuts/rstest.config.mts index 03ca6b8b6..d354f9b77 100644 --- a/e2e/watch/fixtures-shortcuts/rstest.config.mts +++ b/e2e/watch/fixtures-shortcuts/rstest.config.mts @@ -4,12 +4,13 @@ process.stdin.isTTY = true; process.stdin.setRawMode = () => process.stdin; export default defineConfig({ + name: 'shortcuts', reporters: ['default'], disableConsoleIntercept: true, tools: { rspack: { watchOptions: { - aggregateTimeout: 10, + aggregateTimeout: 20, }, }, }, diff --git a/e2e/watch/fixtures/rstest.config.mts b/e2e/watch/fixtures/rstest.config.mts index 3114ac1eb..953fa7b49 100644 --- a/e2e/watch/fixtures/rstest.config.mts +++ b/e2e/watch/fixtures/rstest.config.mts @@ -4,7 +4,7 @@ export default defineConfig({ tools: { rspack: { watchOptions: { - aggregateTimeout: 10, + aggregateTimeout: 20, }, }, }, diff --git a/e2e/watch/restart.test.ts b/e2e/watch/restart.test.ts index f55048639..5e4d56132 100644 --- a/e2e/watch/restart.test.ts +++ b/e2e/watch/restart.test.ts @@ -1,12 +1,16 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rs } from '@rstest/core'; import { remove } from 'fs-extra'; import { prepareFixtures, runRstestCli } from '../scripts/'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +rs.setConfig({ + retry: 3, +}); + describe('restart', () => { it('should restart when rstest config file changed', async () => { const fixturesTargetPath = `${__dirname}/fixtures-test-1${process.env.RSTEST_OUTPUT_MODULE !== 'false' ? '-module' : ''}`; @@ -22,7 +26,16 @@ describe('restart', () => { fs.create( configFile, `import { defineConfig } from '@rstest/core'; -export default defineConfig({}); +export default defineConfig({ + name: 'restart', + tools: { + rspack: { + watchOptions: { + ignored: '**/**' + }, + }, + }, +}); `, ); diff --git a/e2e/watch/shortcuts.test.ts b/e2e/watch/shortcuts.test.ts index f2a4788b0..532b40a24 100644 --- a/e2e/watch/shortcuts.test.ts +++ b/e2e/watch/shortcuts.test.ts @@ -101,4 +101,41 @@ describe('CLI shortcuts', () => { cli.exec.kill(); }); + + it('shortcut `a` should work as expected with command filter', async () => { + const fixturesTargetPath = `${__dirname}/fixtures-test-shortcuts-a`; + await prepareFixtures({ + fixturesPath: `${__dirname}/fixtures-shortcuts`, + fixturesTargetPath, + }); + + const { cli } = await runRstestCli({ + command: 'rstest', + args: ['watch', 'index1'], + options: { + nodeOptions: { + env: { + FORCE_TTY: 'true', + CI: undefined, + }, + cwd: fixturesTargetPath, + }, + }, + }); + + // initial run + await cli.waitForStdout('Duration'); + expect(cli.stdout).toMatch('Tests 1 failed'); + + await cli.waitForStdout('press h to show help'); + + cli.resetStd(); + + // rerun all tests + cli.exec.process!.stdin!.write('a'); + await cli.waitForStdout('Duration'); + expect(cli.stdout).toMatch('Tests 1 failed | 1 passed'); + + cli.exec.kill(); + }); }); diff --git a/examples/node/test/index1.test.ts b/examples/node/test/index1.test.ts new file mode 100644 index 000000000..4fca44ec8 --- /dev/null +++ b/examples/node/test/index1.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from '@rstest/core'; + +describe('Index1', () => { + it('should add two numbers correctly', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/packages/core/src/core/plugins/entry.ts b/packages/core/src/core/plugins/entry.ts index f2f755813..7b16677b5 100644 --- a/packages/core/src/core/plugins/entry.ts +++ b/packages/core/src/core/plugins/entry.ts @@ -26,6 +26,20 @@ class TestFileWatchPlugin { } } +const rstestVirtualEntryFlag = 'rstest-virtual-entry-'; + +const rerunTriggers = new Map void>(); +const configuredWatchConfigs = new WeakMap>(); + +export const triggerRerun = (): boolean => { + let hasTrigger = false; + for (const trigger of rerunTriggers.values()) { + hasTrigger = true; + trigger(); + } + return hasTrigger; +}; + export const pluginEntryWatch: (params: { context: RstestContext; globTestSourceEntries: (name: string) => Promise>; @@ -42,9 +56,23 @@ export const pluginEntryWatch: (params: { }) => ({ name: 'rstest:entry-watch', setup: (api) => { + api.onCloseDevServer(() => { + rerunTriggers.clear(); + }); + const outputDistPathRoot = context.normalizedConfig.output.distPath.root; - api.modifyRspackConfig(async (config, { environment }) => { + api.modifyRspackConfig(async (config, { environment, rspack }) => { if (isWatch) { + let configuredEnvironments = configuredWatchConfigs.get(config); + if (!configuredEnvironments) { + configuredEnvironments = new Set(); + configuredWatchConfigs.set(config, configuredEnvironments); + } + if (configuredEnvironments.has(environment.name)) { + return; + } + configuredEnvironments.add(environment.name); + config.plugins.push(new TestFileWatchPlugin(environment.config.root)); config.entry = async () => { const sourceEntries = await globTestSourceEntries(environment.name); @@ -55,6 +83,32 @@ export const pluginEntryWatch: (params: { }; }; + const virtualEntryName = `${rstestVirtualEntryFlag}${environment.name}.js`; + const virtualEntryPath = `${environment.config.root}/${virtualEntryName}`; + let virtualEntryVersion = 0; + const getVirtualEntryContent = () => + `export const virtualEntry = ${virtualEntryVersion};`; + + const virtualModulesPlugin = + new rspack.experiments.VirtualModulesPlugin({ + [virtualEntryPath]: getVirtualEntryContent(), + }); + + config.experiments ??= {}; + config.experiments.nativeWatcher = true; + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + virtualModulesPlugin.apply(compiler); + rerunTriggers.set(environment.name, () => { + virtualEntryVersion += 1; + virtualModulesPlugin.writeModule( + virtualEntryPath, + getVirtualEntryContent(), + ); + }); + }, + }); + config.watchOptions ??= {}; // FIXME: Temporarily default to 5 to debounce rerun in watch mode. config.watchOptions.aggregateTimeout = 5; diff --git a/packages/core/src/core/projectPlan.ts b/packages/core/src/core/projectPlan.ts index 51fa7a4e7..50c85c88d 100644 --- a/packages/core/src/core/projectPlan.ts +++ b/packages/core/src/core/projectPlan.ts @@ -25,6 +25,13 @@ const getProjectEntries = async ({ }); }; +const areFileFiltersEqual = (a?: string[], b?: string[]): boolean => { + const left = a || []; + const right = b || []; + + return left.length === right.length && left.every((v, i) => v === right[i]); +}; + export type RunProjectPlan = { projects: ProjectContext[]; entriesCache: Map; @@ -75,8 +82,14 @@ export const createRunProjectPlanState = ({ if (context.relatedResolutionEmpty) { return {}; } - if (entriesCache.has(name)) { - return entriesCache.get(name)!.entries; + const cachedEntries = entriesCache.get(name); + if ( + cachedEntries && + (!isWatchMode || + context.normalizedConfig.shard || + areFileFiltersEqual(cachedEntries.fileFilters, context.fileFilters)) + ) { + return cachedEntries.entries; } const project = allProjects.find((p) => p.environmentName === name); diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index 02b5e1bc4..74ef31e9f 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -955,6 +955,7 @@ export async function runTests(context: Rstest): Promise { }; const { onBeforeRestart } = await import('./restart'); + const { triggerRerun } = await import('./plugins/entry'); onBeforeRestart(async () => { await runLifecycleStep('global teardown', () => runGlobalTeardown()); @@ -977,10 +978,16 @@ export async function runTests(context: Rstest): Promise { } }); + let forceRerunOnce = false; + rsbuildInstance.onAfterDevCompile(async ({ isFirstCompile }) => { snapshotManager.clear(); - await run({ buildStart, mode: isFirstCompile ? 'all' : 'on-demand' }); + await run({ + buildStart, + mode: isFirstCompile || forceRerunOnce ? 'all' : 'on-demand', + }); buildStart = undefined; + forceRerunOnce = false; if (isFirstCompile && enableCliShortcuts) { const closeCliShortcuts = await setupCliShortcuts({ @@ -1002,9 +1009,12 @@ export async function runTests(context: Rstest): Promise { context.normalizedConfig.testNamePattern = undefined; context.fileFilters = undefined; - // TODO: should rerun compile with new entries - await run({ mode: 'all' }); - afterTestsWatchRun(); + forceRerunOnce = true; + if (!triggerRerun()) { + await run({ mode: 'all' }); + forceRerunOnce = false; + afterTestsWatchRun(); + } }, runWithTestNamePattern: async (pattern?: string) => { clearScreen();