Skip to content

Commit 7fabf18

Browse files
authored
fix(browser): support quitting watch mode from the terminal (#1026)
1 parent 1d6646e commit 7fabf18

5 files changed

Lines changed: 157 additions & 31 deletions

File tree

e2e/browser-mode/watch.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,8 @@ describe('browser mode - watch', () => {
114114
// Initial run outputs full summary with Duration
115115
await cli.waitForStdout('Duration');
116116
expect(cli.stdout).toMatch('Test Files 2 passed');
117-
if (
118-
!cli.stdout.includes(
119-
'Watch mode enabled - will re-run tests on file changes',
120-
)
121-
) {
122-
await cli.waitForStdout(
123-
'Watch mode enabled - will re-run tests on file changes',
124-
);
117+
if (!cli.stdout.includes('Waiting for file changes...')) {
118+
await cli.waitForStdout('Waiting for file changes...');
125119
}
126120

127121
const newTestPath = path.join(fixturesTargetPath, 'tests/new.test.ts');

e2e/browser-mode/watchReporter.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,8 @@ describe('browser mode - watch reporter lifecycle', () => {
6969

7070
await waitForHookCounts(1, 1);
7171

72-
if (
73-
!cli.stdout.includes(
74-
'Watch mode enabled - will re-run tests on file changes',
75-
)
76-
) {
77-
await cli.waitForStdout(
78-
'Watch mode enabled - will re-run tests on file changes',
79-
);
72+
if (!cli.stdout.includes('Waiting for file changes...')) {
73+
await cli.waitForStdout('Waiting for file changes...');
8074
}
8175

8276
const testFilePath = path.join(fixturesTargetPath, 'tests/index.test.ts');

packages/browser/src/hostController.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ import {
7878
type SourceMapPayload,
7979
} from './sourceMap/sourceMapLoader';
8080
import { resolveBrowserViewportPreset } from './viewportPresets';
81+
import {
82+
isBrowserWatchCliShortcutsEnabled,
83+
logBrowserWatchReadyMessage,
84+
setupBrowserWatchCliShortcuts,
85+
} from './watchCliShortcuts';
8186
import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
8287

8388
const { createRsbuild, rspack } = rsbuild;
@@ -317,6 +322,8 @@ type WatchContext = {
317322
lastTestFiles: TestFileInfo[];
318323
hooksEnabled: boolean;
319324
cleanupRegistered: boolean;
325+
cleanupPromise: Promise<void> | null;
326+
closeCliShortcuts: (() => void) | null;
320327
chunkHashes: Map<string, string>;
321328
affectedTestFiles: string[];
322329
};
@@ -326,6 +333,8 @@ const watchContext: WatchContext = {
326333
lastTestFiles: [],
327334
hooksEnabled: false,
328335
cleanupRegistered: false,
336+
cleanupPromise: null,
337+
closeCliShortcuts: null,
329338
chunkHashes: new Map(),
330339
affectedTestFiles: [],
331340
};
@@ -998,27 +1007,39 @@ const destroyBrowserRuntime = async (
9981007
.catch(() => {});
9991008
};
10001009

1001-
const registerWatchCleanup = (): void => {
1002-
if (watchContext.cleanupRegistered) {
1003-
return;
1010+
const cleanupWatchRuntime = (): Promise<void> => {
1011+
if (watchContext.cleanupPromise) {
1012+
return watchContext.cleanupPromise;
10041013
}
10051014

1006-
const cleanup = async () => {
1015+
watchContext.cleanupPromise = (async () => {
1016+
watchContext.closeCliShortcuts?.();
1017+
watchContext.closeCliShortcuts = null;
1018+
10071019
if (!watchContext.runtime) {
10081020
return;
10091021
}
1022+
10101023
await destroyBrowserRuntime(watchContext.runtime);
10111024
watchContext.runtime = null;
1012-
};
1025+
})();
1026+
1027+
return watchContext.cleanupPromise;
1028+
};
1029+
1030+
const registerWatchCleanup = (): void => {
1031+
if (watchContext.cleanupRegistered) {
1032+
return;
1033+
}
10131034

10141035
for (const signal of ['SIGINT', 'SIGTERM', 'SIGTSTP'] as const) {
10151036
process.once(signal, () => {
1016-
void cleanup();
1037+
void cleanupWatchRuntime();
10171038
});
10181039
}
10191040

10201041
process.once('exit', () => {
1021-
void cleanup();
1042+
void cleanupWatchRuntime();
10221043
});
10231044

10241045
watchContext.cleanupRegistered = true;
@@ -1718,6 +1739,7 @@ export const runBrowserController = async (
17181739
await notifyTestRunStart();
17191740

17201741
const isWatchMode = context.command === 'watch';
1742+
const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
17211743
const tempDir =
17221744
isWatchMode && watchContext.runtime
17231745
? watchContext.runtime.tempDir
@@ -1771,6 +1793,12 @@ export const runBrowserController = async (
17711793
if (isWatchMode) {
17721794
watchContext.runtime = runtime;
17731795
registerWatchCleanup();
1796+
1797+
if (enableCliShortcuts && !watchContext.closeCliShortcuts) {
1798+
watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
1799+
close: cleanupWatchRuntime,
1800+
});
1801+
}
17741802
}
17751803
}
17761804

@@ -2411,6 +2439,7 @@ export const runBrowserController = async (
24112439
? [rerunFatalError]
24122440
: undefined,
24132441
});
2442+
logBrowserWatchReadyMessage(enableCliShortcuts);
24142443
}
24152444
},
24162445
onError: async (error) => {
@@ -2455,6 +2484,7 @@ export const runBrowserController = async (
24552484
logger.log(
24562485
color.cyan('No browser test files remain after update.\n'),
24572486
);
2487+
logBrowserWatchReadyMessage(enableCliShortcuts);
24582488
return;
24592489
}
24602490

@@ -2473,6 +2503,7 @@ export const runBrowserController = async (
24732503
'No affected browser test files detected, skipping re-run.\n',
24742504
),
24752505
);
2506+
logBrowserWatchReadyMessage(enableCliShortcuts);
24762507
return;
24772508
}
24782509

@@ -2531,11 +2562,7 @@ export const runBrowserController = async (
25312562

25322563
if (isWatchMode && triggerRerun) {
25332564
watchContext.hooksEnabled = true;
2534-
logger.log(
2535-
color.cyan(
2536-
'\nWatch mode enabled - will re-run tests on file changes\n',
2537-
),
2538-
);
2565+
logBrowserWatchReadyMessage(enableCliShortcuts);
25392566
}
25402567

25412568
return result;
@@ -2883,9 +2910,13 @@ export const runBrowserController = async (
28832910
? [rerunFatalError]
28842911
: undefined,
28852912
});
2913+
logBrowserWatchReadyMessage(enableCliShortcuts);
28862914
}
28872915
} else if (!rerunPlan.filesChanged) {
28882916
logger.log(color.cyan('Tests will be re-executed automatically\n'));
2917+
logBrowserWatchReadyMessage(enableCliShortcuts);
2918+
} else {
2919+
logBrowserWatchReadyMessage(enableCliShortcuts);
28892920
}
28902921
};
28912922
}
@@ -2946,9 +2977,7 @@ export const runBrowserController = async (
29462977
// Enable watch hooks AFTER initial test run to avoid duplicate runs
29472978
if (isWatchMode && triggerRerun) {
29482979
watchContext.hooksEnabled = true;
2949-
logger.log(
2950-
color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'),
2951-
);
2980+
logBrowserWatchReadyMessage(enableCliShortcuts);
29522981
}
29532982

29542983
return result;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { color, logger } from '@rstest/core/browser';
2+
3+
const isTTY = (): boolean => Boolean(process.stdin.isTTY && !process.env.CI);
4+
5+
export const isBrowserWatchCliShortcutsEnabled = (): boolean => isTTY();
6+
7+
export const getBrowserWatchCliShortcutsHintMessage = (): string => {
8+
return ` ${color.dim('press')} ${color.bold('q')} ${color.dim('to quit')}\n`;
9+
};
10+
11+
export const logBrowserWatchReadyMessage = (
12+
enableCliShortcuts: boolean,
13+
): void => {
14+
logger.log(color.green(' Waiting for file changes...'));
15+
16+
if (enableCliShortcuts) {
17+
logger.log(getBrowserWatchCliShortcutsHintMessage());
18+
}
19+
};
20+
21+
export async function setupBrowserWatchCliShortcuts({
22+
close,
23+
}: {
24+
close: () => Promise<void>;
25+
}): Promise<() => void> {
26+
const { emitKeypressEvents } = await import('node:readline');
27+
28+
emitKeypressEvents(process.stdin);
29+
process.stdin.setRawMode(true);
30+
process.stdin.resume();
31+
process.stdin.setEncoding('utf8');
32+
33+
let isClosing = false;
34+
35+
const handleKeypress = (
36+
str: string,
37+
key: { name: string; ctrl: boolean },
38+
) => {
39+
if (key.ctrl && key.name === 'c') {
40+
process.kill(process.pid, 'SIGINT');
41+
return;
42+
}
43+
44+
if (key.ctrl && key.name === 'z') {
45+
if (process.platform !== 'win32') {
46+
process.kill(process.pid, 'SIGTSTP');
47+
}
48+
return;
49+
}
50+
51+
if (str !== 'q' || isClosing) {
52+
return;
53+
}
54+
55+
// TODO: Support more browser watch shortcuts only after this path is
56+
// refactored to share the same shortcut model as node mode.
57+
isClosing = true;
58+
void (async () => {
59+
try {
60+
await close();
61+
} finally {
62+
process.exit(0);
63+
}
64+
})();
65+
};
66+
67+
process.stdin.on('keypress', handleKeypress);
68+
69+
return () => {
70+
try {
71+
process.stdin.setRawMode(false);
72+
process.stdin.pause();
73+
} catch {}
74+
75+
process.stdin.off('keypress', handleKeypress);
76+
};
77+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from '@rstest/core';
2+
import { getBrowserWatchCliShortcutsHintMessage } from '../src/watchCliShortcuts';
3+
4+
const stripAnsi = (value: string): string => {
5+
let result = '';
6+
let index = 0;
7+
8+
while (index < value.length) {
9+
if (value[index] === String.fromCharCode(27) && value[index + 1] === '[') {
10+
index += 2;
11+
while (index < value.length && value[index] !== 'm') {
12+
index += 1;
13+
}
14+
index += 1;
15+
continue;
16+
}
17+
18+
result += value[index];
19+
index += 1;
20+
}
21+
22+
return result;
23+
};
24+
25+
describe('browser watch cli shortcuts', () => {
26+
it('should render the watch hint message', () => {
27+
const message = stripAnsi(getBrowserWatchCliShortcutsHintMessage());
28+
29+
expect(message).toContain('press q to quit');
30+
expect(message).not.toContain('press h to show help');
31+
});
32+
});

0 commit comments

Comments
 (0)