Skip to content
Draft
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
3 changes: 2 additions & 1 deletion e2e/watch/fixtures-shortcuts/rstest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion e2e/watch/fixtures/rstest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default defineConfig({
tools: {
rspack: {
watchOptions: {
aggregateTimeout: 10,
aggregateTimeout: 20,
},
},
},
Expand Down
29 changes: 23 additions & 6 deletions e2e/watch/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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 { prepareFixtures, runRstestCli } from '../scripts';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

rs.setConfig({
retry: 3,
});

describe('watch', () => {
it('test files should be ran when create / update / delete', async () => {
const { fs } = await prepareFixtures({
Expand Down Expand Up @@ -42,8 +46,13 @@
});`,
);

await cli.waitForStdout('Duration');
expect(cli.stdout).toMatch('Tests 2 passed');
await expect
.poll(() => cli.stdout, {
interval: 20,
timeout: 2000,
})
.toMatch('Tests 2 passed');

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

View workflow job for this annotation

GitHub Actions / e2e (windows-latest, 18)

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

Matcher did not succeed in 2000ms

expect(cli.stdout).toMatch(/Test files to re-run.*:\n.*bar\.test\.ts\n\n/);

// update
Expand All @@ -52,17 +61,25 @@
return content.replace("toBe('bar')", "toBe('BAR')");
});

await cli.waitForStdout('Duration');
await expect
.poll(() => cli.stdout, {
interval: 20,
timeout: 2000,
})
.toMatch('Test Files 1 failed');
expect(cli.stdout).toMatch('Test Files 1 failed');
expect(cli.stdout).toMatch('✗ bar > bar should be to bar');
expect(cli.stdout).toMatch(/Test files to re-run.*:\n.*bar\.test\.ts\n\n/);

// delete
cli.resetStd();
fs.delete('./fixtures-test-0/bar.test.ts');
await cli.waitForStdout('Duration');
await expect
.poll(() => cli.stdout, {
interval: 20,
})
.toMatch('Test Files 1 passed');
expect(cli.stdout).toMatch('No test files need re-run.');
expect(cli.stdout).toMatch('Test Files 1 passed');

cli.exec.kill();
});
Expand Down
17 changes: 15 additions & 2 deletions e2e/watch/restart.test.ts
Original file line number Diff line number Diff line change
@@ -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 { fs } = await prepareFixtures({
Expand All @@ -23,7 +27,16 @@ describe('restart', () => {
fs.create(
configFile,
`import { defineConfig } from '@rstest/core';
export default defineConfig({});
export default defineConfig({
name: 'restart',
tools: {
rspack: {
watchOptions: {
ignored: '**/**'
},
},
},
});
`,
);

Expand Down
37 changes: 37 additions & 0 deletions e2e/watch/shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
47 changes: 46 additions & 1 deletion packages/core/src/core/plugins/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ class TestFileWatchPlugin {
}
}

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

let rerunTrigger: Record<string, () => void> = {};

const registerRerunTrigger = (name: string, fn: () => void) => {
rerunTrigger[name] = fn;
};

export const triggerRerun = (): void => {
Object.values(rerunTrigger).forEach((fn) => {
fn();
});
};

export const pluginEntryWatch: (params: {
context: RstestContext;
globTestSourceEntries: (name: string) => Promise<Record<string, string>>;
Expand All @@ -40,8 +54,16 @@ export const pluginEntryWatch: (params: {
}) => ({
name: 'rstest:entry-watch',
setup: (api) => {
api.modifyRspackConfig(async (config, { environment }) => {
api.onCloseDevServer(() => {
rerunTrigger = {};
});

api.modifyRspackConfig(async (config, { environment, 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[environment.name]) {
return;
}
config.plugins.push(new TestFileWatchPlugin(environment.config.root));
config.entry = async () => {
const sourceEntries = await globTestSourceEntries(environment.name);
Expand All @@ -51,7 +73,30 @@ export const pluginEntryWatch: (params: {
};
};

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

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

registerRerunTrigger(environment.name, () =>
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.watchOptions ??= {};
config.watchOptions.aggregateTimeout = 5;

// TODO: rspack should support `(string | RegExp)[]` type
// https://github.com/web-infra-dev/rspack/issues/10596
config.watchOptions.ignored = castArray(
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/core/runTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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 @@ -281,9 +282,13 @@ export async function runTests(context: Rstest): Promise<void> {
}
});

let forceRerunOnce = false;

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

if (isFirstCompile && enableCliShortcuts) {
const closeCliShortcuts = await setupCliShortcuts({
Expand All @@ -297,9 +302,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) => {
clearScreen();
Expand Down
Loading