Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion e2e/watch/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
const __dirname = path.dirname(__filename);

describe('watch', () => {
it('test files should be ran when create / update / delete', async () => {

Check failure on line 10 in e2e/watch/index.test.ts

View workflow job for this annotation

GitHub Actions / e2e (macos-14, 20)

watch/index.test.ts > watch > test files should be ran when create / update / delete

test timed out in 10000ms

Check failure on line 10 in e2e/watch/index.test.ts

View workflow job for this annotation

GitHub Actions / e2e (macos-14, 18)

watch/index.test.ts > watch > test files should be ran when create / update / delete

test timed out in 10000ms

Check failure on line 10 in e2e/watch/index.test.ts

View workflow job for this annotation

GitHub Actions / e2e (macos-14, 22)

watch/index.test.ts > watch > test files should be ran when create / update / delete

test timed out in 10000ms
const { fs } = await prepareFixtures({
fixturesPath: `${__dirname}/fixtures`,
fixturesTargetPath: `${__dirname}/fixtures-test-0`,
Expand All @@ -28,7 +28,7 @@
await cli.waitForStdout('Duration');
expect(cli.stdout).toMatch('Tests 1 passed');
expect(cli.stdout).not.toMatch('Test files to re-run:');
expect(cli.stdout).toMatch('Fully run test files for first run.');
expect(cli.stdout).toMatch('Run all tests.');

// create
cli.resetStd();
Expand Down
41 changes: 39 additions & 2 deletions e2e/watch/shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('CLI shortcuts', () => {
await cli.waitForStdout('Duration');
expect(cli.stdout).toMatch('Tests 1 failed | 1 passed');
await cli.waitForStdout('press h to show help');
expect(cli.stdout).toMatch('Fully run test files for first run.');
expect(cli.stdout).toMatch('Run all tests.');

cli.exec.process!.stdin!.write('h');

Expand Down Expand Up @@ -82,7 +82,7 @@ describe('CLI shortcuts', () => {
await cli.waitForStdout('Duration');
expect(cli.stdout).toMatch('Tests 1 failed | 1 passed');
await cli.waitForStdout('press h to show help');
expect(cli.stdout).toMatch('Fully run test files for first run.');
expect(cli.stdout).toMatch('Run all tests.');
cli.resetStd();

// rerun failed tests
Expand All @@ -93,4 +93,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();
});
});
7 changes: 7 additions & 0 deletions examples/node/test/index1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from '@rstest/core';

describe('Index1', () => {
it('should add two numbers correctly', () => {
expect(1 + 1).toBe(2);
});
});
46 changes: 45 additions & 1 deletion packages/core/src/core/plugins/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ class TestFileWatchPlugin {
}
}

const rstestVirtualEntryFlag = 'rstest-virtual-entry-';

let rerunTrigger: (() => void) | null = null;

const registerRerunTrigger = (fn: () => void) => {
rerunTrigger = fn;
};

export const triggerRerun = (): void => {
rerunTrigger?.();
};

export const pluginEntryWatch: (params: {
globTestSourceEntries: () => Promise<Record<string, string>>;
setupFiles: Record<string, string>;
Expand All @@ -38,14 +50,46 @@ export const pluginEntryWatch: (params: {
}) => ({
name: 'rstest:entry-watch',
setup: (api) => {
api.modifyRspackConfig(async (config) => {
api.onCloseDevServer(() => {
rerunTrigger = null;
});

api.modifyRspackConfig(async (config, { rspack }) => {
if (isWatch) {
// FIXME: inspect config will retrigger initConfig
Copy link
Preview

Copilot AI Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FIXME comment indicates a known issue but lacks detail about the problem and potential solutions. Consider expanding this comment to explain what 'inspect config will retrigger initConfig' means and its implications.

Suggested change
// FIXME: inspect config will retrigger initConfig
// FIXME: When the configuration is inspected (e.g., by tools or plugins that read or modify the config),
// it will cause initConfig to be retriggered. This can lead to multiple initializations,
// which may result in redundant plugin setup, unexpected side effects, or performance issues.
// Ideally, we should detect and prevent repeated initialization, or refactor the config
// inspection logic to avoid retriggering initConfig. See related discussion in issue tracker
// or consider implementing a guard to ensure initConfig runs only once per session.

Copilot uses AI. Check for mistakes.

if (rerunTrigger) {
return;
}

config.plugins.push(new TestFileWatchPlugin(api.context.rootPath));

// Add virtual entry to trigger recompile
const virtualEntryName = `${rstestVirtualEntryFlag}${config.name!}.js`;
const virtualEntryPath = `${config.context!}/${virtualEntryName}`;

const virtualModulesPlugin =
new rspack.experiments.VirtualModulesPlugin({
[virtualEntryPath]: `export const virtualEntry = ${Date.now()}`,
});

registerRerunTrigger(() =>
virtualModulesPlugin.writeModule(
virtualEntryPath,
`export const virtualEntry = ${Date.now()}`,
),
);
Copy link
Preview

Copilot AI Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Date.now() as a timestamp in code generation could be problematic for deterministic builds or testing. Consider using a counter or hash-based approach instead.

Suggested change
);
[virtualEntryPath]: `export const virtualEntry = ${virtualEntryVersion}`,
});
registerRerunTrigger(() => {
virtualEntryVersion += 1;
virtualModulesPlugin.writeModule(
virtualEntryPath,
`export const virtualEntry = ${virtualEntryVersion}`,
);
});

Copilot uses AI. Check for mistakes.

Copy link
Preview

Copilot AI Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above - using Date.now() for virtual module content could cause non-deterministic behavior. Consider using a counter or hash-based approach.

Suggested change
);
[virtualEntryPath]: `export const virtualEntry = ${virtualEntryVersion}`,
});
registerRerunTrigger(() => {
virtualEntryVersion += 1;
virtualModulesPlugin.writeModule(
virtualEntryPath,
`export const virtualEntry = ${virtualEntryVersion}`,
);
});

Copilot uses AI. Check for mistakes.


config.experiments ??= {};
config.experiments.nativeWatcher = true;

config.plugins.push(virtualModulesPlugin);

config.entry = async () => {
const sourceEntries = await globTestSourceEntries();
return {
...sourceEntries,
...setupFiles,
[virtualEntryPath]: virtualEntryPath,
};
};

Expand Down
7 changes: 0 additions & 7 deletions packages/core/src/core/rsbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,13 @@ export const createRsbuildServer = async ({
assetFiles: Record<string, string>;
sourceMaps: Record<string, SourceMapInput>;
getSourcemap: (sourcePath: string) => SourceMapInput | null;
isFirstRun: boolean;
affectedEntries: EntryInfo[];
deletedEntries: string[];
}>;
closeServer: () => Promise<void>;
}> => {
// Read files from memory via `rspackCompiler.outputFileSystem`
let rspackCompiler: Rspack.Compiler | Rspack.MultiCompiler | undefined;
let isFirstCompile = false;

const rstestCompilerPlugin: RsbuildPlugin = {
name: 'rstest:compiler',
Expand All @@ -245,10 +243,6 @@ export const createRsbuildServer = async ({
// outputFileSystem to be updated later by `rsbuild-dev-middleware`
rspackCompiler = compiler;
});

api.onAfterDevCompile(({ isFirstCompile: _isFirstCompile }) => {
isFirstCompile = _isFirstCompile;
});
},
};

Expand Down Expand Up @@ -410,7 +404,6 @@ export const createRsbuildServer = async ({
return {
affectedEntries,
deletedEntries,
isFirstRun: isFirstCompile,
hash,
entries,
setupEntries,
Expand Down
37 changes: 20 additions & 17 deletions packages/core/src/core/runTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,28 +122,23 @@ export async function runTests(context: Rstest): Promise<void> {
getSourcemap,
buildTime,
hash,
isFirstRun,
affectedEntries,
deletedEntries,
} = await getRsbuildStats({ fileFilters });
const testStart = Date.now();

let finalEntries: EntryInfo[] = entries;
if (mode === 'on-demand') {
if (isFirstRun) {
logger.debug(color.yellow('Fully run test files for first run.\n'));
if (affectedEntries.length === 0) {
logger.debug(color.yellow('No test files are re-run.'));
} else {
if (affectedEntries.length === 0) {
logger.debug(color.yellow('No test files are re-run.'));
} else {
logger.debug(
color.yellow('Test files to re-run:\n') +
affectedEntries.map((e) => e.testPath).join('\n') +
'\n',
);
}
finalEntries = affectedEntries;
logger.debug(
color.yellow('Test files to re-run:\n') +
affectedEntries.map((e) => e.testPath).join('\n') +
'\n',
);
}
finalEntries = affectedEntries;
} else {
logger.debug(
color.yellow(
Expand Down Expand Up @@ -219,6 +214,7 @@ export async function runTests(context: Rstest): Promise<void> {
};

const { onBeforeRestart } = await import('./restart');
const { triggerRerun } = await import('./plugins/entry');

onBeforeRestart(async () => {
await pool.close();
Expand All @@ -231,9 +227,17 @@ export async function runTests(context: Rstest): Promise<void> {
}
});

let forceRerunOnce = false;

rsbuildInstance.onAfterDevCompile(async ({ isFirstCompile }) => {
snapshotManager.clear();
await run({ mode: 'on-demand' });
await run({
mode: isFirstCompile || forceRerunOnce ? 'all' : 'on-demand',
});

if (forceRerunOnce) {
forceRerunOnce = false;
}

if (isFirstCompile && enableCliShortcuts) {
const closeCliShortcuts = await setupCliShortcuts({
Expand All @@ -247,9 +251,8 @@ export async function runTests(context: Rstest): Promise<void> {
context.normalizedConfig.testNamePattern = undefined;
context.fileFilters = undefined;

// TODO: should rerun compile with new entries
await run({ mode: 'all' });
afterTestsWatchRun();
forceRerunOnce = true;
triggerRerun();
},
runWithTestNamePattern: async (pattern?: string) => {
clearLogs();
Expand Down
Loading