diff --git a/e2e/scripts/index.ts b/e2e/scripts/index.ts index 129900a29..0bd01f820 100644 --- a/e2e/scripts/index.ts +++ b/e2e/scripts/index.ts @@ -50,6 +50,7 @@ class Cli { for (const io of toReset) { this[io] = ''; } + this.log = ''; }; private waitForStd = (expect: string | RegExp, io: IoType): Promise => { diff --git a/e2e/watch/fixtures-setup/advanced.test.ts b/e2e/watch/fixtures-setup/advanced.test.ts new file mode 100644 index 000000000..6f3f261c4 --- /dev/null +++ b/e2e/watch/fixtures-setup/advanced.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from '@rstest/core'; + +describe('advanced test', () => { + it('should handle complex operations', () => { + console.log('Running advanced test...'); + const result = [1, 2, 3].map((x) => x * 2); + expect(result).toEqual([2, 4, 6]); + }); +}); diff --git a/e2e/watch/fixtures-setup/basic.test.ts b/e2e/watch/fixtures-setup/basic.test.ts new file mode 100644 index 000000000..e43c6d46a --- /dev/null +++ b/e2e/watch/fixtures-setup/basic.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@rstest/core'; + +describe('basic test', () => { + it('should pass', () => { + console.log('Running basic test...'); + expect(true).toBe(true); + }); +}); diff --git a/e2e/watch/fixtures-setup/rstest.config.ts b/e2e/watch/fixtures-setup/rstest.config.ts new file mode 100644 index 000000000..401e9eca2 --- /dev/null +++ b/e2e/watch/fixtures-setup/rstest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + passWithNoTests: true, + setupFiles: ['./rstest.setup.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + tools: { + rspack: { + watchOptions: { + aggregateTimeout: 10, + }, + }, + }, +}); diff --git a/e2e/watch/fixtures-setup/rstest.setup.ts b/e2e/watch/fixtures-setup/rstest.setup.ts new file mode 100644 index 000000000..381dfa312 --- /dev/null +++ b/e2e/watch/fixtures-setup/rstest.setup.ts @@ -0,0 +1,9 @@ +import { afterAll, beforeAll } from '@rstest/core'; + +beforeAll(() => { + console.log('[beforeAll] setup'); +}); + +afterAll(() => { + console.log('[afterAll] setup'); +}); diff --git a/e2e/watch/setup.test.ts b/e2e/watch/setup.test.ts new file mode 100644 index 000000000..55932e9a7 --- /dev/null +++ b/e2e/watch/setup.test.ts @@ -0,0 +1,63 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, rs } from '@rstest/core'; +import { prepareFixtures, runRstestCli } from '../scripts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +rs.setConfig({ + retry: 3, +}); + +describe.skipIf(process.platform === 'win32')( + 'watch setup file changes', + () => { + it('should re-run all tests when setup file changes', async () => { + const fixturesTargetPath = `${__dirname}/fixtures-test-setup${process.env.RSTEST_OUTPUT_MODULE ? '-module' : ''}`; + + const { fs } = await prepareFixtures({ + fixturesPath: `${__dirname}/fixtures-setup`, + fixturesTargetPath, + }); + + const { cli, expectLog } = await runRstestCli({ + command: 'rstest', + args: ['watch', '--disableConsoleIntercept'], + options: { + nodeOptions: { + cwd: fixturesTargetPath, + }, + }, + }); + + // initial run + await cli.waitForStdout('Duration'); + expect(cli.stdout).toMatch('Tests 2 passed'); + expectLog('Running advanced test...'); + expectLog('Running basic test...'); + expect(cli.stdout).toMatch('[beforeAll] setup'); + expect(cli.stdout).toMatch('[afterAll] setup'); + expect(cli.stdout).not.toMatch('Test files to re-run:'); + + const setupFilePath = path.join(fixturesTargetPath, 'rstest.setup.ts'); + + // modify setup file + cli.resetStd(); + fs.update(setupFilePath, (content) => { + return content.replace( + "console.log('[beforeAll] setup')", + "console.log('[beforeAll] setup - modified')", + ); + }); + + await cli.waitForStdout('Duration'); + expect(cli.stdout).toMatch('Tests 2 passed'); + expectLog('Running advanced test...'); + expectLog('Running basic test...'); + expect(cli.stdout).toMatch('[beforeAll] setup - modified'); + + cli.exec.kill(); + }); + }, +); diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index 4f05aed20..99d91d30a 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -62,6 +62,7 @@ const collectTests = async ({ const { getRsbuildStats, closeServer } = await createRsbuildServer({ globTestSourceEntries, globalSetupFiles, + isWatchMode: false, inspectedConfig: { ...context.normalizedConfig, projects: context.projects.map((p) => p.normalizedConfig), diff --git a/packages/core/src/core/rsbuild.ts b/packages/core/src/core/rsbuild.ts index 6ad4b84de..7510ecc23 100644 --- a/packages/core/src/core/rsbuild.ts +++ b/packages/core/src/core/rsbuild.ts @@ -155,16 +155,20 @@ export const prepareRsbuild = async ( export const calcEntriesToRerun = ( entries: EntryInfo[], chunks: Rspack.StatsChunk[] | undefined, - buildData: { entryToChunkHashes?: TestEntryToChunkHashes }, + buildData: { + entryToChunkHashes?: TestEntryToChunkHashes; + setupEntryToChunkHashes?: TestEntryToChunkHashes; + }, runtimeChunkName: string, + setupEntries: EntryInfo[], ): { affectedEntries: EntryInfo[]; deletedEntries: string[]; } => { - const entryToChunkHashesMap = new Map>(); - - // Build current chunk hashes map - const buildChunkHashes = (entry: EntryInfo) => { + const buildChunkHashes = ( + entry: EntryInfo, + map: Map>, + ) => { const validChunks = (entry.chunks || []).filter( (chunk) => chunk !== runtimeChunkName, ); @@ -174,70 +178,115 @@ export const calcEntriesToRerun = ( c.names?.includes(chunkName as string), ); if (chunkInfo) { - const existing = entryToChunkHashesMap.get(entry.testPath) || {}; + const existing = map.get(entry.testPath) || {}; existing[chunkName] = chunkInfo.hash ?? ''; - entryToChunkHashesMap.set(entry.testPath, existing); + map.set(entry.testPath, existing); } }); }; - (entries || []).forEach(buildChunkHashes); + const processEntryChanges = ( + _entries: EntryInfo[], + prevHashes: TestEntryToChunkHashes | undefined, + currentHashesMap: Map>, + ): { + affectedPaths: Set; + deletedPaths: string[]; + } => { + const affectedPaths = new Set(); + const deletedPaths: string[] = []; + + if (prevHashes) { + const prevMap = new Map(prevHashes.map((e) => [e.name, e.chunks])); + const currentNames = new Set(currentHashesMap.keys()); + + deletedPaths.push( + ...Array.from(prevMap.keys()).filter((name) => !currentNames.has(name)), + ); - const entryToChunkHashes: TestEntryToChunkHashes = Array.from( - entryToChunkHashesMap.entries(), - ).map(([name, chunks]) => ({ name, chunks })); + const findAffectedEntry = (testPath: string) => { + const currentChunks = currentHashesMap.get(testPath); + const prevChunks = prevMap.get(testPath); - // Process changes if we have previous data - const affectedTestPaths = new Set(); - const deletedEntries: string[] = []; + if (!currentChunks) return; - if (buildData.entryToChunkHashes) { - const prevMap = new Map( - buildData.entryToChunkHashes.map((e) => [e.name, e.chunks]), - ); - const currentNames = new Set(entryToChunkHashesMap.keys()); + if (!prevChunks) { + affectedPaths.add(testPath); + return; + } - // Find deleted entries - deletedEntries.push( - ...Array.from(prevMap.keys()).filter((name) => !currentNames.has(name)), - ); + const hasChanges = Object.entries(currentChunks).some( + ([chunkName, hash]) => prevChunks[chunkName] !== hash, + ); - // Find modified or added entries - const findAffectedEntry = (testPath: string) => { - const currentChunks = entryToChunkHashesMap.get(testPath); - const prevChunks = prevMap.get(testPath); + if (hasChanges) { + affectedPaths.add(testPath); + } + }; - if (!currentChunks) return; + currentHashesMap.forEach((_, testPath) => { + findAffectedEntry(testPath); + }); + } - if (!prevChunks) { - // New entry - affectedTestPaths.add(testPath); - return; - } + return { affectedPaths, deletedPaths }; + }; + + const previousSetupHashes = buildData.setupEntryToChunkHashes; + const previousEntryHashes = buildData.entryToChunkHashes; + + const setupEntryToChunkHashesMap = new Map>(); + setupEntries.forEach((entry) => { + buildChunkHashes(entry, setupEntryToChunkHashesMap); + }); + + const setupEntryToChunkHashes: TestEntryToChunkHashes = Array.from( + setupEntryToChunkHashesMap.entries(), + ).map(([name, chunks]) => ({ name, chunks })); - // Check for modified chunks - const hasChanges = Object.entries(currentChunks).some( - ([chunkName, hash]) => prevChunks[chunkName] !== hash, + // apply latest setup entry chunk hashes + buildData.setupEntryToChunkHashes = setupEntryToChunkHashes; + + const entryToChunkHashesMap = new Map>(); + (entries || []).forEach((entry) => { + buildChunkHashes(entry, entryToChunkHashesMap); + }); + + const entryToChunkHashes: TestEntryToChunkHashes = Array.from( + entryToChunkHashesMap.entries(), + ).map(([name, chunks]) => ({ name, chunks })); + + // apply latest entry chunk hashes + buildData.entryToChunkHashes = entryToChunkHashes; + + const isSetupChanged = () => { + const { affectedPaths: affectedSetupPaths, deletedPaths: deletedSetups } = + processEntryChanges( + setupEntries, + previousSetupHashes, + setupEntryToChunkHashesMap, ); - if (hasChanges) { - affectedTestPaths.add(testPath); - } - }; + const affectedSetups = Array.from(affectedSetupPaths) + .map((testPath) => setupEntries.find((e) => e.testPath === testPath)) + .filter((entry): entry is EntryInfo => entry !== undefined); - entryToChunkHashesMap.forEach((_, testPath) => { - findAffectedEntry(testPath); - }); + return affectedSetups.length > 0 || deletedSetups.length > 0; + }; + + if (isSetupChanged()) { + // if setup files changed, all test entries are affected + return { affectedEntries: entries, deletedEntries: [] }; } - buildData.entryToChunkHashes = entryToChunkHashes; + const { affectedPaths: affectedTestPaths, deletedPaths } = + processEntryChanges(entries, previousEntryHashes, entryToChunkHashesMap); - // Convert affected test paths to EntryInfo objects const affectedEntries = Array.from(affectedTestPaths) .map((testPath) => entries.find((e) => e.testPath === testPath)) .filter((entry): entry is EntryInfo => entry !== undefined); - return { affectedEntries, deletedEntries }; + return { affectedEntries, deletedEntries: deletedPaths }; }; class AssetsMemorySafeMap extends Map { @@ -259,7 +308,9 @@ export const createRsbuildServer = async ({ globalSetupFiles, rsbuildInstance, inspectedConfig, + isWatchMode, }: { + isWatchMode: boolean; rsbuildInstance: RsbuildInstance; inspectedConfig: RstestContext['normalizedConfig'] & { projects: NormalizedProjectConfig[]; @@ -280,7 +331,9 @@ export const createRsbuildServer = async ({ assetNames: string[]; getAssetFiles: (names: string[]) => Promise>; getSourceMaps: (names: string[]) => Promise>; + /** affected test entries only available in watch mode */ affectedEntries: EntryInfo[]; + /** deleted test entries only available in watch mode */ deletedEntries: string[]; }>; closeServer: () => Promise; @@ -360,7 +413,10 @@ export const createRsbuildServer = async ({ const buildData: Record< string, - { entryToChunkHashes?: TestEntryToChunkHashes } + { + entryToChunkHashes?: TestEntryToChunkHashes; + setupEntryToChunkHashes?: TestEntryToChunkHashes; + } > = {}; const getEntryFiles = async (manifest: ManifestData, outputPath: string) => { @@ -475,12 +531,15 @@ export const createRsbuildServer = async ({ // affectedEntries: entries affected by source code. // deletedEntries: entry files deleted from compilation. - const { affectedEntries, deletedEntries } = calcEntriesToRerun( - entries, - chunks, - buildData[environmentName], - `${environmentName}-${RUNTIME_CHUNK_NAME}`, - ); + const { affectedEntries, deletedEntries } = isWatchMode + ? calcEntriesToRerun( + entries, + chunks, + buildData[environmentName], + `${environmentName}-${RUNTIME_CHUNK_NAME}`, + setupEntries, + ) + : { affectedEntries: [], deletedEntries: [] }; const cachedAssetFiles = new AssetsMemorySafeMap(); const cachedSourceMaps = new AssetsMemorySafeMap(); diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index 738806d87..8ddbdadf7 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -85,20 +85,22 @@ export async function runTests(context: Rstest): Promise { globalSetupFiles, ); + const isWatchMode = command === 'watch'; + const { getRsbuildStats, closeServer } = await createRsbuildServer({ inspectedConfig: { ...context.normalizedConfig, projects: context.projects.map((p) => p.normalizedConfig), }, - globTestSourceEntries: - command === 'watch' - ? globTestSourceEntries - : async (name) => { - if (entriesCache.has(name)) { - return entriesCache.get(name)!.entries; - } - return globTestSourceEntries(name); - }, + isWatchMode, + globTestSourceEntries: isWatchMode + ? globTestSourceEntries + : async (name) => { + if (entriesCache.has(name)) { + return entriesCache.get(name)!.entries; + } + return globTestSourceEntries(name); + }, setupFiles, globalSetupFiles, rsbuildInstance,