Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/scripts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Cli {
for (const io of toReset) {
this[io] = '';
}
this.log = '';
};

private waitForStd = (expect: string | RegExp, io: IoType): Promise<void> => {
Expand Down
9 changes: 9 additions & 0 deletions e2e/watch/fixtures-setup/advanced.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
8 changes: 8 additions & 0 deletions e2e/watch/fixtures-setup/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions e2e/watch/fixtures-setup/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
},
});
9 changes: 9 additions & 0 deletions e2e/watch/fixtures-setup/rstest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { afterAll, beforeAll } from '@rstest/core';

beforeAll(() => {
console.log('[beforeAll] setup');
});

afterAll(() => {
console.log('[afterAll] setup');
});
63 changes: 63 additions & 0 deletions e2e/watch/setup.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
},
);
1 change: 1 addition & 0 deletions packages/core/src/core/listTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
165 changes: 112 additions & 53 deletions packages/core/src/core/rsbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, string>>();

// Build current chunk hashes map
const buildChunkHashes = (entry: EntryInfo) => {
const buildChunkHashes = (
entry: EntryInfo,
map: Map<string, Record<string, string>>,
) => {
const validChunks = (entry.chunks || []).filter(
(chunk) => chunk !== runtimeChunkName,
);
Expand All @@ -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<string, Record<string, string>>,
): {
affectedPaths: Set<string>;
deletedPaths: string[];
} => {
const affectedPaths = new Set<string>();
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<string>();
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<string, Record<string, string>>();
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<string, Record<string, string>>();
(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<string, string> {
Expand All @@ -259,7 +308,9 @@ export const createRsbuildServer = async ({
globalSetupFiles,
rsbuildInstance,
inspectedConfig,
isWatchMode,
}: {
isWatchMode: boolean;
rsbuildInstance: RsbuildInstance;
inspectedConfig: RstestContext['normalizedConfig'] & {
projects: NormalizedProjectConfig[];
Expand All @@ -280,7 +331,9 @@ export const createRsbuildServer = async ({
assetNames: string[];
getAssetFiles: (names: string[]) => Promise<Record<string, string>>;
getSourceMaps: (names: string[]) => Promise<Record<string, string>>;
/** affected test entries only available in watch mode */
affectedEntries: EntryInfo[];
/** deleted test entries only available in watch mode */
deletedEntries: string[];
}>;
closeServer: () => Promise<void>;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 11 additions & 9 deletions packages/core/src/core/runTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,22 @@ export async function runTests(context: Rstest): Promise<void> {
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,
Expand Down
Loading