@rstest/browser 0.11.5 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/browser-container/container-static/css/index.43daaf6c93.css +1 -0
  2. package/dist/browser-container/container-static/js/260.3739fe815c.js +194 -0
  3. package/dist/browser-container/container-static/js/index.c0f87bfb3e.js +1 -0
  4. package/dist/browser-container/index.html +1 -1
  5. package/dist/browserExecutor.d.ts +7 -5
  6. package/dist/browserRsbuild.d.ts +139 -0
  7. package/dist/browserV8Coverage.d.ts +15 -0
  8. package/dist/client/240~1.js +150 -0
  9. package/dist/{augmentExpect.d.ts → client/client/augmentExpect.d.ts} +1 -1
  10. package/dist/client/{browserRpc.d.ts → client/browserRpc.d.ts} +1 -1
  11. package/dist/client/{dispatchTransport.d.ts → client/dispatchTransport.d.ts} +8 -4
  12. package/dist/client/client/index.d.ts +2 -0
  13. package/dist/client/client/runIdentity.d.ts +12 -0
  14. package/dist/client/client/runner.d.ts +4 -0
  15. package/dist/client/client/snapshot.d.ts +27 -0
  16. package/dist/client/client/sourceMapSupport.d.ts +36 -0
  17. package/dist/{browser.js → client/index.js} +3 -102
  18. package/dist/client/protocol.d.ts +225 -0
  19. package/dist/client/rpcProtocol.d.ts +138 -0
  20. package/dist/client/runner.js +675 -0
  21. package/dist/client/sourceMap/sourceMapLoader.d.ts +25 -0
  22. package/dist/containerRpc.d.ts +60 -0
  23. package/dist/dispatchCapabilities.d.ts +1 -2
  24. package/dist/headedRunRegistry.d.ts +51 -0
  25. package/dist/headedScheduler.d.ts +58 -0
  26. package/dist/headlessScheduler.d.ts +38 -0
  27. package/dist/headlessTransport.d.ts +4 -2
  28. package/dist/hostController.d.ts +12 -47
  29. package/dist/hostPayloads.d.ts +24 -0
  30. package/dist/index.js +2019 -1516
  31. package/dist/protocol.d.ts +48 -8
  32. package/dist/providers/index.d.ts +21 -0
  33. package/dist/providers/playwright/v8Coverage.d.ts +2 -0
  34. package/dist/rpcProtocol.d.ts +1 -2
  35. package/dist/schedulerSeam.d.ts +42 -0
  36. package/dist/sourceMap/sourceMapLoader.d.ts +12 -1
  37. package/dist/watchRerunPlanner.d.ts +20 -4
  38. package/dist/watchRuntime.d.ts +21 -0
  39. package/dist/watchSignals.d.ts +29 -0
  40. package/package.json +12 -12
  41. package/dist/33.js +0 -7
  42. package/dist/browser-container/container-static/css/index.0521ff95b1.css +0 -1
  43. package/dist/browser-container/container-static/js/506.b2f920fa67.js +0 -194
  44. package/dist/browser-container/container-static/js/index.d27fd63f5e.js +0 -1
  45. package/dist/browser.d.ts +0 -2
  46. package/dist/headlessLatestRerunScheduler.d.ts +0 -18
  47. package/src/augmentExpect.ts +0 -62
  48. package/src/browser.ts +0 -3
  49. package/src/browserExecutor.ts +0 -140
  50. package/src/browserRpcRegistry.ts +0 -57
  51. package/src/client/api.ts +0 -213
  52. package/src/client/browserRpc.ts +0 -86
  53. package/src/client/dispatchTransport.ts +0 -240
  54. package/src/client/entry.ts +0 -807
  55. package/src/client/locator.ts +0 -448
  56. package/src/client/snapshot.ts +0 -113
  57. package/src/client/sourceMapSupport.ts +0 -159
  58. package/src/concurrency.ts +0 -65
  59. package/src/configValidation.ts +0 -263
  60. package/src/dispatchCapabilities.ts +0 -182
  61. package/src/dispatchRouter.ts +0 -82
  62. package/src/env.d.ts +0 -54
  63. package/src/headedSerialTaskQueue.ts +0 -23
  64. package/src/headlessLatestRerunScheduler.ts +0 -76
  65. package/src/headlessTransport.ts +0 -28
  66. package/src/hostController.ts +0 -4633
  67. package/src/index.ts +0 -51
  68. package/src/manifest.d.ts +0 -41
  69. package/src/protocol.ts +0 -225
  70. package/src/providers/index.ts +0 -126
  71. package/src/providers/playwright/compileLocator.ts +0 -130
  72. package/src/providers/playwright/dispatchBrowserRpc.ts +0 -372
  73. package/src/providers/playwright/expectUtils.ts +0 -57
  74. package/src/providers/playwright/implementation.ts +0 -35
  75. package/src/providers/playwright/index.ts +0 -1
  76. package/src/providers/playwright/runtime.ts +0 -159
  77. package/src/providers/playwright/textMatcher.ts +0 -10
  78. package/src/rpcProtocol.ts +0 -218
  79. package/src/runSession.ts +0 -110
  80. package/src/sessionRegistry.ts +0 -89
  81. package/src/sourceMap/sourceMapLoader.ts +0 -96
  82. package/src/viewportPresets.ts +0 -69
  83. package/src/watchRerunPlanner.ts +0 -77
  84. /package/dist/{rslib-runtime.js → 612~0.js} +0 -0
  85. /package/dist/client/{api.d.ts → client/api.d.ts} +0 -0
  86. /package/dist/client/{locator.d.ts → client/locator.d.ts} +0 -0
@@ -1,4633 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import fs from 'node:fs/promises';
3
- import type { IncomingMessage, ServerResponse } from 'node:http';
4
- import type { AddressInfo } from 'node:net';
5
- import { fileURLToPath } from 'node:url';
6
- import { isDeepStrictEqual } from 'node:util';
7
- import type { Rspack } from '@rstest/core';
8
- import {
9
- applyWatchInvalidation,
10
- applyWebMockRspackConfig,
11
- type BrowserTestRunOptions,
12
- type BrowserTestRunResult,
13
- type BrowserWatchHandles,
14
- buildBrowserCoverageMap,
15
- type CoverageMapData,
16
- type EntryHashSnapshot,
17
- type ExecutorCycleOutcome,
18
- FATAL_SIGNALS,
19
- finalizeRunCycle,
20
- type ListBrowserTestsOptions,
21
- color,
22
- createCoverageProvider,
23
- createRunnerEventSink,
24
- createSilentConsoleController,
25
- DEFAULT_TEST_TIMEOUT,
26
- type FormattedError,
27
- getNoTestFilesMessage,
28
- getPrettyConsoleName,
29
- getSetupFiles,
30
- getTestEntries,
31
- hasUserRstestConfigPlugins,
32
- importMetaRstestDefine,
33
- initModifyRstestConfigHooks,
34
- isDebug,
35
- isTTY,
36
- type ListCommandResult,
37
- loadCoverageProvider,
38
- logger,
39
- logWatchReadyMessage,
40
- pluginMockRuntime,
41
- prepareWatchRerunState,
42
- projectRuntimeConfig,
43
- PhaseTracker,
44
- type ProjectContext,
45
- type Reporter,
46
- type RunnerEventSink,
47
- type RstestContext,
48
- resolveProjectBuildCache,
49
- resolveSnapshotPathDefault,
50
- resolveShardedEntries,
51
- RSTEST_ENV_SYMBOL_KEY,
52
- rsbuild,
53
- serializableConfig,
54
- type Test,
55
- type TestFileResult,
56
- type TestResult,
57
- type UserConsoleLog,
58
- type WatchInvalidationState,
59
- } from '@rstest/core/internal/browser';
60
- import { type BirpcReturn, createBirpc } from 'birpc';
61
- import openEditor from 'open-editor';
62
- import { dirname, join, normalize, relative, resolve } from 'pathe';
63
- import picomatch from 'picomatch';
64
- import sirv from 'sirv';
65
- import { type WebSocket, WebSocketServer } from 'ws';
66
- import { getHeadlessConcurrency } from './concurrency';
67
- import {
68
- createHostDispatchRouter,
69
- type HostDispatchRouterOptions,
70
- } from './dispatchCapabilities';
71
- import { validateBrowserConfig } from './configValidation';
72
- import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
73
- import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
74
- import { attachHeadlessRunnerTransport } from './headlessTransport';
75
- import type {
76
- BrowserClientMessage,
77
- BrowserDispatchHandler,
78
- BrowserDispatchRequest,
79
- BrowserDispatchResponse,
80
- BrowserHostConfig,
81
- BrowserLogPayload,
82
- BrowserProjectRuntime,
83
- BrowserRpcRequest,
84
- BrowserViewport,
85
- SnapshotRpcRequest,
86
- TestFileInfo,
87
- } from './protocol';
88
- import {
89
- DISPATCH_MESSAGE_TYPE,
90
- DISPATCH_NAMESPACE_BROWSER,
91
- DISPATCH_NAMESPACE_RUNNER,
92
- validateBrowserRpcRequest,
93
- } from './protocol';
94
- import {
95
- type BrowserProvider,
96
- type BrowserProviderBrowser,
97
- type BrowserProviderContext,
98
- type BrowserProviderImplementation,
99
- type BrowserProviderPage,
100
- getBrowserProviderImplementation,
101
- } from './providers';
102
- import {
103
- createRunSession,
104
- type RunSession,
105
- RunSessionLifecycle,
106
- } from './runSession';
107
- import { RunnerSessionRegistry } from './sessionRegistry';
108
- import {
109
- loadSourceMapWithCache,
110
- normalizeJavaScriptUrl,
111
- type SourceMapPayload,
112
- } from './sourceMap/sourceMapLoader';
113
- import { resolveBrowserViewportPreset } from './viewportPresets';
114
- import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
115
-
116
- const { createRsbuild, rspack } = rsbuild;
117
- type RsbuildDevServer = rsbuild.RsbuildDevServer;
118
- type RsbuildInstance = rsbuild.RsbuildInstance;
119
- type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
120
- Pick<rsbuild.RsbuildConfig, 'root'>;
121
-
122
- const __dirname = dirname(fileURLToPath(import.meta.url));
123
- const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
124
-
125
- /**
126
- * Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
127
- * mode. Browser host runs every test file inside the same Node process, so
128
- * without an override every file would emit events under the same `pid` and
129
- * share a single track labelled `worker <hostPid>`. Giving each file its own
130
- * synthetic `pid` makes the track title surface the file path instead,
131
- * matching node mode's default `isolate: true` behavior. The 1_000_000_000
132
- * base keeps each synthetic `pid` well clear of real OS `pid` values in
133
- * mixed-mode traces.
134
- */
135
- let nextBrowserFilePid = 1_000_000_000;
136
-
137
- /**
138
- * Serialize JSON for inline <script> injection.
139
- * Escapes '<' to prevent accidental </script> break-out.
140
- * Escapes U+2028/U+2029 to keep script parsing safe.
141
- */
142
- const serializeForInlineScript = (value: unknown): string => {
143
- return JSON.stringify(value)
144
- .replace(/</g, '\\u003c')
145
- .replace(/\u2028/g, '\\u2028')
146
- .replace(/\u2029/g, '\\u2029');
147
- };
148
-
149
- // ============================================================================
150
- // Type Definitions
151
- // ============================================================================
152
-
153
- type BrowserProjectEntries = {
154
- project: ProjectContext;
155
- setupFiles: string[];
156
- testFiles: string[];
157
- };
158
-
159
- type BrowserProviderProject = {
160
- rootPath: string;
161
- provider: BrowserProvider;
162
- };
163
-
164
- type BrowserLaunchOptions = {
165
- provider: BrowserProvider;
166
- browser: ProjectContext['normalizedConfig']['browser']['browser'];
167
- headless: ProjectContext['normalizedConfig']['browser']['headless'];
168
- port: ProjectContext['normalizedConfig']['browser']['port'];
169
- strictPort: ProjectContext['normalizedConfig']['browser']['strictPort'];
170
- providerOptions: Record<string, unknown>;
171
- };
172
-
173
- const getBrowserProviderOptions = (
174
- project: ProjectContext,
175
- ): Record<string, unknown> => {
176
- const browserConfig = project.normalizedConfig.browser as {
177
- providerOptions?: Record<string, unknown>;
178
- };
179
-
180
- return browserConfig.providerOptions ?? {};
181
- };
182
-
183
- /** Payload for test file start event */
184
- type TestFileStartPayload = {
185
- testPath: string;
186
- projectName: string;
187
- };
188
-
189
- /** Payload for log event — single-sourced from the wire protocol. */
190
- type LogPayload = BrowserLogPayload;
191
-
192
- /** Payload for fatal error event */
193
- type FatalPayload = {
194
- message: string;
195
- stack?: string;
196
- };
197
-
198
- type ReporterHookArg<THook extends keyof Reporter> =
199
- NonNullable<Reporter[THook]> extends (...args: infer TArgs) => unknown
200
- ? TArgs[0]
201
- : never;
202
-
203
- type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
204
- type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
205
- type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
206
- type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
207
- type ReloadTestFileAck = {
208
- runId: string;
209
- };
210
- type HeadedTestFileCompletePayload = TestFileResult & {
211
- runId?: string;
212
- };
213
-
214
- type DeferredPromise<T> = {
215
- promise: Promise<T>;
216
- resolve: (value: T | PromiseLike<T>) => void;
217
- reject: (reason?: unknown) => void;
218
- };
219
-
220
- const getFileTaskId = (testPath: string): string => {
221
- return `file:${testPath}`;
222
- };
223
-
224
- const createDeferredPromise = <T>(): DeferredPromise<T> => {
225
- let resolve!: DeferredPromise<T>['resolve'];
226
- let reject!: DeferredPromise<T>['reject'];
227
- const promise = new Promise<T>((res, rej) => {
228
- resolve = res;
229
- reject = rej;
230
- });
231
-
232
- return {
233
- promise,
234
- resolve,
235
- reject,
236
- };
237
- };
238
-
239
- /** RPC methods exposed by the host (server) to the container (client) */
240
- type HostRpcMethods = {
241
- rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
242
- getTestFiles: () => Promise<TestFileInfo[]>;
243
- onRunnerFramesReady: (testFiles: string[]) => Promise<void>;
244
- // Test result callbacks from container
245
- onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
246
- onTestCaseResult: (payload: TestResult) => Promise<void>;
247
- onTestFileComplete: (payload: HeadedTestFileCompletePayload) => Promise<void>;
248
- onLog: (payload: LogPayload) => Promise<void>;
249
- onFatal: (payload: FatalPayload) => Promise<void>;
250
- // Generic dispatch endpoint used by runner RPC requests.
251
- dispatch: (
252
- request: BrowserDispatchRequest,
253
- ) => Promise<BrowserDispatchResponse>;
254
- };
255
-
256
- /** RPC methods exposed by the container (client) to the host (server) */
257
- type ContainerRpcMethods = {
258
- onTestFileUpdate: (testFiles: TestFileInfo[]) => Promise<void>;
259
- reloadTestFile: (
260
- testFile: string,
261
- testNamePattern?: string,
262
- ) => Promise<ReloadTestFileAck>;
263
- /**
264
- * Replace the container's copy of the host config so runner iframes loaded
265
- * from now on receive fresh values (e.g. the 'u' shortcut flipping
266
- * `snapshot.updateSnapshot` between watch reruns).
267
- */
268
- onHostConfigUpdate: (config: BrowserHostConfig) => Promise<void>;
269
- };
270
-
271
- type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
272
-
273
- // ============================================================================
274
- // RPC Manager - Encapsulates WebSocket and birpc management
275
- // ============================================================================
276
-
277
- /**
278
- * Manages the WebSocket connection and birpc communication with the container UI.
279
- * Provides a clean interface for sending RPC calls and handling connections.
280
- */
281
- class ContainerRpcManager {
282
- private wss: WebSocketServer;
283
- private ws: WebSocket | null = null;
284
- private rpc: ContainerRpc | null = null;
285
- private methods: HostRpcMethods;
286
- private onDisconnect?: (error: Error) => void;
287
- private detachActiveSocketListeners: (() => void) | null = null;
288
-
289
- constructor(
290
- wss: WebSocketServer,
291
- methods: HostRpcMethods,
292
- onDisconnect?: (error: Error) => void,
293
- ) {
294
- this.wss = wss;
295
- this.methods = methods;
296
- this.onDisconnect = onDisconnect;
297
- this.setupConnectionHandler();
298
- }
299
-
300
- /** Update the RPC methods (used when starting a new test run) */
301
- updateMethods(
302
- methods: HostRpcMethods,
303
- onDisconnect?: (error: Error) => void,
304
- ): void {
305
- this.methods = methods;
306
- this.onDisconnect = onDisconnect;
307
- // Re-create birpc with new methods if already connected
308
- if (this.ws && this.ws.readyState === this.ws.OPEN) {
309
- this.attachWebSocket(this.ws);
310
- }
311
- }
312
-
313
- private setupConnectionHandler(): void {
314
- this.wss.on('connection', (ws: WebSocket) => {
315
- logger.debug('[Browser UI] Container WebSocket connected');
316
- logger.debug(
317
- `[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`,
318
- );
319
- this.attachWebSocket(ws);
320
- });
321
- }
322
-
323
- private attachWebSocket(ws: WebSocket): void {
324
- this.detachActiveSocketListeners?.();
325
- if (this.rpc && !this.rpc.$closed) {
326
- this.rpc.$close(new Error('Container RPC transport reattached'));
327
- }
328
- this.ws = ws;
329
- const messageHandlers = new WeakMap<
330
- (data: any) => void,
331
- (message: any) => void
332
- >();
333
-
334
- this.rpc = createBirpc<ContainerRpcMethods, HostRpcMethods>(this.methods, {
335
- timeout: -1,
336
- post: (data) => {
337
- if (ws.readyState === ws.OPEN) {
338
- ws.send(JSON.stringify(data));
339
- }
340
- },
341
- on: (fn) => {
342
- const handler = (message: any) => {
343
- try {
344
- const data = JSON.parse(message.toString());
345
- fn(data);
346
- } catch {
347
- // ignore invalid messages
348
- }
349
- };
350
- messageHandlers.set(fn, handler);
351
- ws.on('message', handler);
352
- },
353
- off: (fn) => {
354
- const handler = messageHandlers.get(fn);
355
- if (!handler) {
356
- return;
357
- }
358
- ws.off('message', handler);
359
- messageHandlers.delete(fn);
360
- },
361
- });
362
-
363
- const handleClose = () => {
364
- // Only clear if this is still the active connection
365
- // This prevents a race condition when a new connection is established
366
- // before the old one's close event fires
367
- if (this.ws === ws) {
368
- this.ws = null;
369
- }
370
- this.detachActiveSocketListeners?.();
371
- this.detachActiveSocketListeners = null;
372
- if (this.rpc && !this.rpc.$closed) {
373
- const disconnectError = new Error(
374
- 'Browser UI WebSocket disconnected before reload completed',
375
- );
376
- this.rpc.$close(disconnectError);
377
- this.onDisconnect?.(disconnectError);
378
- }
379
- this.rpc = null;
380
- };
381
-
382
- ws.on('close', handleClose);
383
- this.detachActiveSocketListeners = () => {
384
- ws.off('close', handleClose);
385
- };
386
- }
387
-
388
- /** Check if a container is currently connected */
389
- get isConnected(): boolean {
390
- return this.ws !== null && this.ws.readyState === this.ws.OPEN;
391
- }
392
-
393
- /** Get the current WebSocket instance (for reuse in watch mode) */
394
- get currentWebSocket(): WebSocket | null {
395
- return this.ws;
396
- }
397
-
398
- /** Reattach an existing WebSocket (for watch mode reuse) */
399
- reattach(ws: WebSocket): void {
400
- this.attachWebSocket(ws);
401
- }
402
-
403
- /** Notify container of test file changes */
404
- async notifyTestFileUpdate(files: TestFileInfo[]): Promise<void> {
405
- await this.rpc?.onTestFileUpdate(files);
406
- }
407
-
408
- /** Push a refreshed host config to the container (watch reruns) */
409
- async updateHostConfig(config: BrowserHostConfig): Promise<void> {
410
- await this.rpc?.onHostConfigUpdate(config);
411
- }
412
-
413
- /** Request container to reload a specific test file */
414
- async reloadTestFile(
415
- testFile: string,
416
- testNamePattern?: string,
417
- ): Promise<ReloadTestFileAck> {
418
- logger.debug(
419
- `[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
420
- );
421
- if (!this.rpc) {
422
- throw new Error('Browser UI RPC not available for reloadTestFile');
423
- }
424
- logger.debug(`[Browser UI] Calling reloadTestFile: ${testFile}`);
425
- return this.rpc.reloadTestFile(testFile, testNamePattern);
426
- }
427
- }
428
-
429
- // ============================================================================
430
- // Browser Runtime - Core runtime state
431
- // ============================================================================
432
-
433
- // One isolated rsbuild instance + dev server per browser project. Physical
434
- // isolation (separate compiler, dev server, port, virtual manifest, runner)
435
- // prevents one project's build/resolve config from leaking into another's
436
- // compilation and avoids shared-dev-server races (chunk filenames,
437
- // lazyCompilation backend) between projects.
438
- type BrowserProjectServer = {
439
- projectName: string;
440
- environmentName: string;
441
- rsbuildInstance: RsbuildInstance;
442
- devServer: RsbuildDevServer;
443
- port: number;
444
- manifestPath: string;
445
- };
446
-
447
- // Watch diff/rerun state. Lives on the BrowserRuntime (one per set of
448
- // per-project compilers, surviving controller re-entry that reuses the
449
- // runtime) instead of module scope, so its lifetime always matches the
450
- // compilers whose baselines it holds.
451
- type BrowserWatchState = {
452
- lastTestFiles: TestFileInfo[];
453
- hooksEnabled: boolean;
454
- // Diff baselines keyed per project: sibling projects have isolated
455
- // compilers, so a shared flat baseline would let one project's compile
456
- // clobber another's (missed reruns) and collide on compiler-local chunk
457
- // keys.
458
- invalidation: Map<string, WatchInvalidationState>;
459
- // Affected files accumulated per project until a rerun drains them, so a
460
- // compile finishing while another project's rerun is being planned cannot
461
- // drop pending work.
462
- pendingAffectedTestFiles: Map<string, Set<string>>;
463
- // Per-project compile start times and the accumulated compile duration of
464
- // the pending rerun, so the rerun's finalize reports the real buildTime.
465
- compileStartTimes: Map<string, number>;
466
- pendingBuildTimeMs: number;
467
- };
468
-
469
- const createBrowserWatchState = (): BrowserWatchState => ({
470
- lastTestFiles: [],
471
- hooksEnabled: false,
472
- invalidation: new Map(),
473
- pendingAffectedTestFiles: new Map(),
474
- compileStartTimes: new Map(),
475
- pendingBuildTimeMs: 0,
476
- });
477
-
478
- const drainPendingBuildTime = (watchState: BrowserWatchState): number => {
479
- const buildTime = watchState.pendingBuildTimeMs;
480
- watchState.pendingBuildTimeMs = 0;
481
- return buildTime;
482
- };
483
-
484
- const drainPendingAffectedTestFiles = (
485
- watchState: BrowserWatchState,
486
- ): string[] => {
487
- const affected = new Set<string>();
488
- for (const files of watchState.pendingAffectedTestFiles.values()) {
489
- for (const file of files) {
490
- affected.add(file);
491
- }
492
- }
493
- watchState.pendingAffectedTestFiles.clear();
494
- return Array.from(affected);
495
- };
496
-
497
- type BrowserRuntime = {
498
- // Per-project servers, keyed by project name.
499
- projectServers: Map<string, BrowserProjectServer>;
500
- // The server that hosts the container UI HTML (headed mode). The WebSocket
501
- // server below is shared and reachable from any origin.
502
- containerServer: BrowserProjectServer;
503
- browser: BrowserProviderBrowser;
504
- browserLaunchOptions: BrowserLaunchOptions;
505
- wsPort: number;
506
- tempDir: string;
507
- containerPage?: BrowserProviderPage;
508
- containerContext?: BrowserProviderContext;
509
- setContainerOptions: (options: BrowserHostConfig) => void;
510
- // Reserved extension seam for host-side dispatch capabilities.
511
- dispatchHandlers: Map<string, BrowserDispatchHandler>;
512
- wss: WebSocketServer;
513
- rpcManager?: ContainerRpcManager;
514
- projectEntries: BrowserProjectEntries[];
515
- watchState: BrowserWatchState;
516
- };
517
-
518
- // ============================================================================
519
- // Watch Mode Context - Process-lifecycle watch state
520
- // ============================================================================
521
-
522
- // Only process-wide concerns stay module-level: the runtime handle reused
523
- // across controller re-entry (config-change restarts), and the signal/exit
524
- // cleanup that must run once per process. Diff/rerun state lives on
525
- // `BrowserRuntime.watchState`.
526
- type WatchContext = {
527
- runtime: BrowserRuntime | null;
528
- cleanupRegistered: boolean;
529
- cleanupPromise: Promise<void> | null;
530
- };
531
-
532
- const watchContext: WatchContext = {
533
- runtime: null,
534
- cleanupRegistered: false,
535
- cleanupPromise: null,
536
- };
537
-
538
- // ============================================================================
539
- // Utility Functions
540
- // ============================================================================
541
-
542
- const resolveViewport = (
543
- viewport: BrowserViewport | undefined,
544
- ): { width: number; height: number } | null => {
545
- if (!viewport) {
546
- return null;
547
- }
548
-
549
- if (typeof viewport === 'string') {
550
- return resolveBrowserViewportPreset(viewport);
551
- }
552
-
553
- if (
554
- typeof viewport.width === 'number' &&
555
- Number.isFinite(viewport.width) &&
556
- viewport.width > 0 &&
557
- typeof viewport.height === 'number' &&
558
- Number.isFinite(viewport.height) &&
559
- viewport.height > 0
560
- ) {
561
- return {
562
- width: viewport.width,
563
- height: viewport.height,
564
- };
565
- }
566
-
567
- return null;
568
- };
569
-
570
- const mapViewportByProject = (
571
- projects: BrowserProjectRuntime[],
572
- ): Map<string, { width: number; height: number }> => {
573
- const map = new Map<string, { width: number; height: number }>();
574
- for (const project of projects) {
575
- const viewport = resolveViewport(project.viewport);
576
- if (viewport) {
577
- map.set(project.name, viewport);
578
- }
579
- }
580
- return map;
581
- };
582
-
583
- const ensureProcessExitCode = (code: number): void => {
584
- if (process.exitCode === undefined || process.exitCode === 0) {
585
- process.exitCode = code;
586
- }
587
- };
588
-
589
- const castArray = <T>(arr?: T | T[]): T[] => {
590
- if (arr === undefined) {
591
- return [];
592
- }
593
- return Array.isArray(arr) ? arr : [arr];
594
- };
595
-
596
- const applyDefaultWatchOptions = (
597
- rspackConfig: Rspack.Configuration,
598
- isWatchMode: boolean,
599
- ) => {
600
- rspackConfig.watchOptions ??= {};
601
-
602
- if (!isWatchMode) {
603
- rspackConfig.watchOptions.ignored = '**/**';
604
- return;
605
- }
606
-
607
- rspackConfig.watchOptions.ignored = castArray(
608
- rspackConfig.watchOptions.ignored || [],
609
- ) as string[];
610
-
611
- if (rspackConfig.watchOptions.ignored.length === 0) {
612
- rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
613
- }
614
-
615
- if (rspackConfig.output?.path) {
616
- rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
617
- }
618
- };
619
-
620
- type LazyCompilationModule = {
621
- nameForCondition?: () => string | null | undefined;
622
- };
623
-
624
- type BrowserLazyCompilationConfig = {
625
- imports: true;
626
- entries: false;
627
- test?: (module: LazyCompilationModule) => boolean;
628
- };
629
-
630
- /**
631
- * Resolve the actual port the dev server is listening on.
632
- *
633
- * Rsbuild's `devServer.listen()` may return `0` when configured with
634
- * `server.port: 0` because its internal `getPort` never reads back the
635
- * OS-assigned ephemeral port. This helper falls back to
636
- * `httpServer.address()` to obtain the real bound port.
637
- */
638
- export const resolveListenPort = (
639
- listenPort: number,
640
- httpServer: {
641
- address: () => ReturnType<import('node:net').Server['address']>;
642
- } | null,
643
- ): number => {
644
- if (listenPort) {
645
- return listenPort;
646
- }
647
- const addr = httpServer?.address();
648
- if (addr && typeof addr === 'object') {
649
- return addr.port;
650
- }
651
- return listenPort;
652
- };
653
-
654
- export const createBrowserLazyCompilationConfig = (
655
- setupFiles: string[],
656
- ): BrowserLazyCompilationConfig => {
657
- const eagerSetupFiles = new Set(
658
- setupFiles.map((filePath) => normalize(filePath)),
659
- );
660
-
661
- if (eagerSetupFiles.size === 0) {
662
- return {
663
- imports: true,
664
- entries: false,
665
- };
666
- }
667
-
668
- return {
669
- imports: true,
670
- entries: false,
671
- test(module: LazyCompilationModule) {
672
- const filePath = module.nameForCondition?.();
673
- return !filePath || !eagerSetupFiles.has(normalize(filePath));
674
- },
675
- };
676
- };
677
-
678
- /**
679
- * HMR — and the lazyCompilation transport it carries — is wired only for headed
680
- * watch, the sole path that reuses a persistent page and applies module updates
681
- * in place. Headless always loads each test file in a fresh page (pulling the
682
- * latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
683
- * so pushing HMR updates there is dead weight that only races factory
684
- * registration for chunk-split node_modules (rspack#11922) and lets
685
- * lazyCompilation's accept-chain walk abort the next spec when no boundary
686
- * exists (#1472). Disabling HMR does not make watch rebuilds any less
687
- * incremental — HMR is only the client push transport.
688
- */
689
- export const shouldEnableBrowserHmr = (
690
- isWatchMode: boolean,
691
- isHeadless: boolean,
692
- ): boolean => isWatchMode && !isHeadless;
693
-
694
- export const createBrowserRsbuildDevConfig = (
695
- enableHmr: boolean,
696
- ): {
697
- writeToDisk: boolean;
698
- hmr: boolean;
699
- client: {
700
- logLevel: 'error';
701
- };
702
- } => {
703
- return {
704
- writeToDisk: isDebug(),
705
- // `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
706
- // path that reuses a page. See that helper for why fresh-page runs (headless,
707
- // or any one-shot) must not receive HMR pushes.
708
- hmr: enableHmr,
709
- client: {
710
- logLevel: 'error' as const,
711
- },
712
- };
713
- };
714
-
715
- /**
716
- * Convert a single glob pattern to RegExp using picomatch
717
- * Based on Storybook's implementation
718
- */
719
- const globToRegexp = (glob: string): RegExp => {
720
- const regex = picomatch.makeRe(glob, {
721
- fastpaths: false,
722
- noglobstar: false,
723
- bash: false,
724
- dot: true,
725
- });
726
-
727
- if (!regex) {
728
- throw new Error(`Invalid glob pattern: ${glob}`);
729
- }
730
-
731
- // picomatch generates regex starting with ^
732
- // For patterns starting with ./, we need special handling
733
- if (!glob.startsWith('./')) {
734
- return regex;
735
- }
736
-
737
- // makeRe is sort of funny. If you pass it a directory starting with `./` it
738
- // creates a matcher that expects files with no prefix (e.g. `src/file.js`)
739
- // but if you pass it a directory that starts with `../` it expects files that
740
- // start with `../`. Let's make it consistent.
741
- // Globs starting `**` need special treatment due to the regex they produce
742
- return new RegExp(
743
- [
744
- '^\\.',
745
- glob.startsWith('./**') ? '' : '[\\\\/]',
746
- regex.source.substring(1),
747
- ].join(''),
748
- );
749
- };
750
-
751
- /**
752
- * Convert rstest include glob patterns to RegExp for import.meta.webpackContext
753
- * Uses picomatch for robust glob-to-regexp conversion
754
- */
755
- const globPatternsToRegExp = (patterns: string[]): RegExp => {
756
- const regexParts = patterns.map((pattern) => {
757
- const regex = globToRegexp(pattern);
758
- // Remove ^ anchor and $ anchor to allow combining patterns
759
- let source = regex.source;
760
- if (source.startsWith('^')) {
761
- source = source.substring(1);
762
- }
763
- if (source.endsWith('$')) {
764
- source = source.substring(0, source.length - 1);
765
- }
766
- return source;
767
- });
768
-
769
- return new RegExp(`(?:${regexParts.join('|')})$`);
770
- };
771
-
772
- const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
773
- const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
774
- const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
775
-
776
- const escapeRegExp = (value: string): string =>
777
- value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
778
-
779
- const normalizePathForRegExp = (value: string): string =>
780
- normalize(value).replaceAll('\\', '/');
781
-
782
- const normalizeExcludePatternForRegExp = (value: string): string =>
783
- value.startsWith('./')
784
- ? `./${normalizePathForRegExp(value.substring(2))}`
785
- : normalizePathForRegExp(value);
786
-
787
- const isAbsolutePatternForRegExp = (value: string): boolean =>
788
- value.startsWith('/') || /^[A-Za-z]:\//.test(value);
789
-
790
- const isEscapedRegExpCharacter = (source: string, index: number): boolean => {
791
- let backslashCount = 0;
792
- for (
793
- let current = index - 1;
794
- current >= 0 && source[current] === '\\';
795
- current--
796
- ) {
797
- backslashCount++;
798
- }
799
- return backslashCount % 2 === 1;
800
- };
801
-
802
- const replacePathSeparatorsInRegExpSource = (source: string): string => {
803
- let result = '';
804
- let inCharacterClass = false;
805
-
806
- for (let index = 0; index < source.length; index++) {
807
- const character = source[index];
808
- const isEscaped = isEscapedRegExpCharacter(source, index);
809
-
810
- if (character === '[' && !isEscaped) {
811
- inCharacterClass = true;
812
- }
813
-
814
- if (!inCharacterClass && character === '\\' && source[index + 1] === '/') {
815
- result += PATH_SEPARATOR_SOURCE;
816
- index++;
817
- continue;
818
- }
819
-
820
- result += character;
821
-
822
- if (character === ']' && !isEscaped) {
823
- inCharacterClass = false;
824
- }
825
- }
826
-
827
- return result;
828
- };
829
-
830
- type BrowserContextExcludeSource = {
831
- relative: string;
832
- absolute: string;
833
- isAbsolute: boolean;
834
- };
835
-
836
- const createRelativeContextExcludeSource = (
837
- source: string,
838
- normalizedPattern: string,
839
- ): string => {
840
- if (normalizedPattern.startsWith('./')) {
841
- return source;
842
- }
843
-
844
- return normalizedPattern.startsWith('**/')
845
- ? `(?:(?:${source})|\\.(?:${source}))`
846
- : `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
847
- };
848
-
849
- const createProjectAbsoluteExcludeSource = (
850
- source: string,
851
- normalizedPattern: string,
852
- ): string =>
853
- normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/')
854
- ? source
855
- : `${PATH_SEPARATOR_SOURCE}(?:${source})`;
856
-
857
- /**
858
- * Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
859
- * This is used at compile time to filter out files during bundling
860
- *
861
- * Example:
862
- * Input: ['**\/node_modules\/**', '**\/dist\/**']
863
- * Output: a regexp matching node_modules or dist path segments.
864
- */
865
- const excludePatternsToRegExpSources = (
866
- patterns: string[],
867
- ): BrowserContextExcludeSource[] | null => {
868
- const sources = patterns.map((pattern) => {
869
- const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
870
- const regex = globToRegexp(normalizedPattern);
871
- let source = regex.source;
872
- if (source.startsWith('^')) {
873
- source = source.substring(1);
874
- }
875
- if (source.endsWith('$')) {
876
- source = source.substring(0, source.length - 1);
877
- }
878
-
879
- source = replacePathSeparatorsInRegExpSource(source);
880
- const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
881
- const absolute = normalizedPattern.startsWith('./')
882
- ? source.substring(2)
883
- : source;
884
-
885
- return {
886
- relative: isAbsolute
887
- ? source
888
- : createRelativeContextExcludeSource(source, normalizedPattern),
889
- absolute: isAbsolute
890
- ? absolute
891
- : createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
892
- isAbsolute,
893
- };
894
- });
895
-
896
- if (sources.length === 0) {
897
- return null;
898
- }
899
-
900
- return sources;
901
- };
902
-
903
- export const createBrowserContextExcludeRegExp = (
904
- patterns: string[],
905
- projectRoot: string,
906
- ): RegExp | null => {
907
- const excludeSources = excludePatternsToRegExpSources(patterns);
908
- if (!excludeSources) {
909
- return null;
910
- }
911
-
912
- const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(
913
- /[\\/]$/,
914
- '',
915
- );
916
- const projectRootSource = normalizedProjectRoot
917
- .split('/')
918
- .map(escapeRegExp)
919
- .join(PATH_SEPARATOR_SOURCE);
920
- const relativeExcludeSources = excludeSources.filter(
921
- (source) => !source.isAbsolute,
922
- );
923
- const absoluteExcludeSources = excludeSources.filter(
924
- (source) => source.isAbsolute,
925
- );
926
- const sourceBranches: string[] = [];
927
-
928
- if (relativeExcludeSources.length > 0) {
929
- const relativePatternSource = `(?:${relativeExcludeSources
930
- .map((source) => source.relative)
931
- .join('|')})`;
932
- const absolutePatternSource = `(?:${relativeExcludeSources
933
- .map((source) => source.absolute)
934
- .join('|')})`;
935
- const relativeSource = `(?:${relativePatternSource})`;
936
- const absoluteSource = normalizedProjectRoot
937
- ? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})`
938
- : `(?:${absolutePatternSource})`;
939
-
940
- sourceBranches.push(
941
- `(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`,
942
- absoluteSource,
943
- );
944
- }
945
-
946
- if (absoluteExcludeSources.length > 0) {
947
- sourceBranches.push(
948
- `(?:${absoluteExcludeSources
949
- .map((source) => source.relative)
950
- .join('|')})`,
951
- );
952
- }
953
-
954
- return new RegExp(`^(?:${sourceBranches.join('|')})$`);
955
- };
956
-
957
- type StatsModule = {
958
- nameForCondition?: string;
959
- children?: StatsModule[];
960
- };
961
-
962
- type StatsChunk = {
963
- id?: string | number;
964
- names?: string[];
965
- hash?: string;
966
- files?: string[];
967
- modules?: StatsModule[];
968
- };
969
-
970
- /**
971
- * Find test file path from chunk modules by matching against known entry files.
972
- */
973
- const findTestFileInModules = (
974
- modules: StatsModule[] | undefined,
975
- entryTestFiles: Set<string>,
976
- ): string | null => {
977
- if (!modules) return null;
978
-
979
- for (const m of modules) {
980
- if (m.nameForCondition) {
981
- const normalizedPath = normalize(m.nameForCondition);
982
- if (entryTestFiles.has(normalizedPath)) {
983
- return normalizedPath;
984
- }
985
- }
986
- if (m.children) {
987
- const found = findTestFileInModules(m.children, entryTestFiles);
988
- if (found) return found;
989
- }
990
- }
991
- return null;
992
- };
993
-
994
- /**
995
- * Get a stable identifier for a chunk.
996
- * Prefers chunk.id or chunk.names[0] over file paths for stability.
997
- */
998
- const getChunkKey = (chunk: StatsChunk): string | null => {
999
- if (chunk.id != null) {
1000
- return String(chunk.id);
1001
- }
1002
- if (chunk.names && chunk.names.length > 0) {
1003
- return chunk.names[0]!;
1004
- }
1005
- if (chunk.files && chunk.files.length > 0) {
1006
- return chunk.files[0]!;
1007
- }
1008
- return null;
1009
- };
1010
-
1011
- /**
1012
- * Fold one project compile's chunks into per-entry hash snapshots and apply
1013
- * the shared watch-invalidation policy against that project's baseline.
1014
- * Chunks are attributed to a test/setup file by scanning their modules; the
1015
- * chunk.id/names key is only the hash-record key, never a cross-project one.
1016
- */
1017
- const getAffectedTestFiles = ({
1018
- chunks,
1019
- entryTestFiles,
1020
- setupFiles,
1021
- state,
1022
- }: {
1023
- chunks: StatsChunk[] | undefined;
1024
- entryTestFiles: Set<string>;
1025
- setupFiles: Set<string>;
1026
- state: WatchInvalidationState;
1027
- }): string[] => {
1028
- const entryHashes: EntryHashSnapshot = new Map();
1029
- const setupHashes: EntryHashSnapshot = new Map();
1030
-
1031
- const recordChunk = (
1032
- snapshot: EntryHashSnapshot,
1033
- entryPath: string,
1034
- chunkKey: string,
1035
- hash: string,
1036
- ) => {
1037
- const record = snapshot.get(entryPath) ?? {};
1038
- record[chunkKey] = hash;
1039
- snapshot.set(entryPath, record);
1040
- };
1041
-
1042
- for (const chunk of chunks || []) {
1043
- if (!chunk.hash) continue;
1044
-
1045
- const chunkKey = getChunkKey(chunk);
1046
- if (!chunkKey) continue;
1047
-
1048
- const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
1049
- if (testFile) {
1050
- recordChunk(entryHashes, testFile, chunkKey, chunk.hash);
1051
- continue;
1052
- }
1053
-
1054
- const setupFile = findTestFileInModules(chunk.modules, setupFiles);
1055
- if (setupFile) {
1056
- recordChunk(setupHashes, setupFile, chunkKey, chunk.hash);
1057
- }
1058
- }
1059
-
1060
- // Headed watch compiles chunks on demand (lazyCompilation), so an entry's
1061
- // first appearance in stats means "just loaded", not "just added": its first
1062
- // sighting establishes the baseline instead of marking a change. Genuinely
1063
- // new and deleted test files are owned by the test-file-set diff in
1064
- // `planWatchRerun` / `collectDeletedTestPaths`.
1065
- const seedFirstSeen = (
1066
- baseline: EntryHashSnapshot | undefined,
1067
- current: EntryHashSnapshot,
1068
- ) => {
1069
- if (!baseline) return;
1070
- for (const [entryPath, record] of current) {
1071
- if (!baseline.has(entryPath)) {
1072
- baseline.set(entryPath, record);
1073
- }
1074
- }
1075
- };
1076
- seedFirstSeen(state.entryHashes, entryHashes);
1077
- seedFirstSeen(state.setupHashes, setupHashes);
1078
-
1079
- const outcome = applyWatchInvalidation(state, { entryHashes, setupHashes });
1080
-
1081
- if (outcome.rerunAll) {
1082
- logger.debug(
1083
- '[Watch] Setup file changed, re-running all test files of the project',
1084
- );
1085
- return Array.from(entryTestFiles);
1086
- }
1087
-
1088
- for (const affected of outcome.affectedPaths) {
1089
- logger.debug(`[Watch] Chunk hash changed for test: ${affected}`);
1090
- }
1091
-
1092
- return outcome.affectedPaths;
1093
- };
1094
-
1095
- const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
1096
- context.projects.filter(
1097
- (project) => project.normalizedConfig.browser.enabled,
1098
- );
1099
-
1100
- const getBrowserRsbuildEnvironmentConfig = (
1101
- project: ProjectContext,
1102
- ): RsbuildEnvironmentConfig => ({
1103
- plugins: project.normalizedConfig.plugins,
1104
- root: project.rootPath,
1105
- });
1106
-
1107
- // Max testTimeout across browser projects, used as the host->client RPC timeout.
1108
- const getMaxTestTimeoutForRpc = (projects: ProjectContext[]): number =>
1109
- Math.max(
1110
- ...projects.map(
1111
- (p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
1112
- ),
1113
- );
1114
-
1115
- const getBrowserLaunchOptions = (
1116
- project: ProjectContext,
1117
- ): BrowserLaunchOptions => ({
1118
- provider: project.normalizedConfig.browser.provider,
1119
- browser: project.normalizedConfig.browser.browser,
1120
- headless: project.normalizedConfig.browser.headless,
1121
- port: project.normalizedConfig.browser.port,
1122
- strictPort: project.normalizedConfig.browser.strictPort,
1123
- providerOptions: getBrowserProviderOptions(project),
1124
- });
1125
-
1126
- const ensureConsistentBrowserLaunchOptions = (
1127
- projects: ProjectContext[],
1128
- ): BrowserLaunchOptions => {
1129
- if (projects.length === 0) {
1130
- throw new Error('No browser-enabled projects found.');
1131
- }
1132
-
1133
- const firstProject = projects[0]!;
1134
- const firstOptions = getBrowserLaunchOptions(firstProject);
1135
-
1136
- for (const project of projects.slice(1)) {
1137
- const options = getBrowserLaunchOptions(project);
1138
- // Each browser project now runs on its own rsbuild dev server, so ports may
1139
- // differ per project. Only the shared single Playwright browser forces
1140
- // provider/browser/headless/providerOptions to match across projects.
1141
- if (
1142
- options.provider !== firstOptions.provider ||
1143
- options.browser !== firstOptions.browser ||
1144
- options.headless !== firstOptions.headless ||
1145
- !isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
1146
- ) {
1147
- throw new Error(
1148
- `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
1149
- 'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
1150
- );
1151
- }
1152
- }
1153
-
1154
- return firstOptions;
1155
- };
1156
-
1157
- const resolveProviderForTestPath = ({
1158
- testPath,
1159
- browserProjects,
1160
- }: {
1161
- testPath: string;
1162
- browserProjects: BrowserProviderProject[];
1163
- }): BrowserProvider => {
1164
- const normalizedTestPath = normalize(testPath);
1165
- const sortedProjects = [...browserProjects].sort(
1166
- (a, b) => b.rootPath.length - a.rootPath.length,
1167
- );
1168
-
1169
- for (const project of sortedProjects) {
1170
- if (normalizedTestPath.startsWith(project.rootPath)) {
1171
- return project.provider;
1172
- }
1173
- }
1174
-
1175
- throw new Error(
1176
- `Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
1177
- `Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
1178
- );
1179
- };
1180
-
1181
- const collectProjectEntries = async (
1182
- context: RstestContext,
1183
- // The explicit browser-project subset the executor was constructed with. Falls
1184
- // back to re-deriving from `context` for internal callers (e.g. the watch
1185
- // plugin) that do not carry the plan's project list.
1186
- browserProjects: ProjectContext[] = getBrowserProjects(context),
1187
- ): Promise<BrowserProjectEntries[]> => {
1188
- return Promise.all(
1189
- browserProjects.map(async (project) => {
1190
- const {
1191
- normalizedConfig: { include, exclude, includeSource, setupFiles },
1192
- } = project;
1193
-
1194
- const tests = await getTestEntries({
1195
- include,
1196
- exclude: exclude.patterns,
1197
- includeSource,
1198
- rootPath: context.rootPath,
1199
- projectRoot: project.rootPath,
1200
- fileFilters: context.fileFilters || [],
1201
- fileFilterMode: context.fileFilterMode,
1202
- });
1203
-
1204
- const setup = getSetupFiles(setupFiles, project.rootPath);
1205
-
1206
- return {
1207
- project,
1208
- setupFiles: Object.values(setup),
1209
- testFiles: Object.values(tests),
1210
- };
1211
- }),
1212
- );
1213
- };
1214
-
1215
- const resolveBrowserFile = (relativePath: string): string => {
1216
- // __dirname points to packages/browser/dist when running from built code
1217
- // or packages/browser/src when running from source
1218
- const candidates = [
1219
- // When running from built dist: look in ../src for source files
1220
- resolve(__dirname, '../src', relativePath),
1221
- // When running from source (dev mode)
1222
- resolve(__dirname, relativePath),
1223
- ];
1224
-
1225
- for (const candidate of candidates) {
1226
- if (existsSync(candidate)) {
1227
- return candidate;
1228
- }
1229
- }
1230
-
1231
- throw new Error(`Unable to resolve browser client file: ${relativePath}`);
1232
- };
1233
-
1234
- const resolveContainerDist = (): string => {
1235
- // When running from built dist: browser-container is in the same dist folder
1236
- const distPath = resolve(__dirname, 'browser-container');
1237
- if (existsSync(distPath)) {
1238
- return distPath;
1239
- }
1240
-
1241
- throw new Error(
1242
- `Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`,
1243
- );
1244
- };
1245
-
1246
- // ============================================================================
1247
- // Manifest Generation
1248
- // ============================================================================
1249
-
1250
- /**
1251
- * Format environment name to a valid JavaScript identifier.
1252
- * Replaces non-alphanumeric characters with underscores.
1253
- */
1254
- const toSafeVarName = (name: string): string => {
1255
- return name.replace(/[^a-zA-Z0-9_]/g, '_');
1256
- };
1257
-
1258
- // Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
1259
- // `./<path-relative-to-project-root>` with forward slashes. The runtime derives
1260
- // the same key from the target test file, so the non-watch import map below must
1261
- // key by the identical form for `loadTest(key)` to resolve.
1262
- export const toContextKey = (
1263
- filePath: string,
1264
- projectRootPosix: string,
1265
- ): string => {
1266
- const posixPath = normalize(filePath);
1267
- // Only strip the root at a path boundary: a bare `startsWith` would mangle a
1268
- // sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
1269
- const withinRoot =
1270
- posixPath === projectRootPosix ||
1271
- posixPath.startsWith(`${projectRootPosix}/`);
1272
- if (!withinRoot) {
1273
- // Test file outside the project root: use the absolute path as the key so
1274
- // the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
1275
- // key would be re-rooted under projectRoot and point at a nonexistent file.
1276
- return posixPath;
1277
- }
1278
- const rel = posixPath.slice(projectRootPosix.length);
1279
- return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
1280
- };
1281
-
1282
- const generateManifestModule = ({
1283
- manifestPath,
1284
- entries,
1285
- isWatchMode,
1286
- }: {
1287
- manifestPath: string;
1288
- entries: BrowserProjectEntries[];
1289
- isWatchMode: boolean;
1290
- }): string => {
1291
- const manifestDirPosix = normalize(dirname(manifestPath));
1292
-
1293
- const toRelativeImport = (filePath: string): string => {
1294
- const posixPath = normalize(filePath);
1295
- let relativePath = relative(manifestDirPosix, posixPath);
1296
- if (!relativePath.startsWith('.')) {
1297
- relativePath = `./${relativePath}`;
1298
- }
1299
- return relativePath;
1300
- };
1301
-
1302
- const lines: string[] = [];
1303
-
1304
- // 1. Export all projects configuration
1305
- lines.push('// All projects configuration');
1306
- lines.push('export const projects = [');
1307
- for (const { project } of entries) {
1308
- lines.push(' {');
1309
- lines.push(` name: ${JSON.stringify(project.name)},`);
1310
- lines.push(
1311
- ` environmentName: ${JSON.stringify(project.environmentName)},`,
1312
- );
1313
- lines.push(
1314
- ` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
1315
- );
1316
- lines.push(' },');
1317
- }
1318
- lines.push('];');
1319
- lines.push('');
1320
-
1321
- // 2. Setup loaders for each project
1322
- lines.push('// Setup loaders for each project');
1323
- lines.push('export const projectSetupLoaders = {');
1324
- for (const { project, setupFiles } of entries) {
1325
- lines.push(` ${JSON.stringify(project.name)}: [`);
1326
- for (const filePath of setupFiles) {
1327
- const relativePath = toRelativeImport(filePath);
1328
- lines.push(` () => import(${JSON.stringify(relativePath)}),`);
1329
- }
1330
- lines.push(' ],');
1331
- }
1332
- lines.push('};');
1333
- lines.push('');
1334
-
1335
- // 3. Test context for each project. Both branches expose the same shape as a
1336
- // webpackContext (callable by key, plus `keys()`), consumed in section 4.
1337
- lines.push('// Test context for each project');
1338
- for (const { project, testFiles } of entries) {
1339
- const varName = `context_${toSafeVarName(project.environmentName)}`;
1340
- const projectRootPosix = normalize(project.rootPath);
1341
-
1342
- if (isWatchMode) {
1343
- // Watch mode keeps the include-glob context so newly added files are
1344
- // picked up on rebuild via `keys()` without regenerating the manifest.
1345
- // `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
1346
- // works with or without lazyCompilation; headed watch additionally layers
1347
- // lazyCompilation on top to keep the initial build cheap.
1348
- const includeRegExp = globPatternsToRegExp(
1349
- project.normalizedConfig.include,
1350
- );
1351
- const excludeRegExp = createBrowserContextExcludeRegExp(
1352
- project.normalizedConfig.exclude.patterns,
1353
- projectRootPosix,
1354
- );
1355
- const { includeSource } = project.normalizedConfig;
1356
- const emitContext = (contextVarName: string, regExp: RegExp): void => {
1357
- lines.push(
1358
- `const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1359
- );
1360
- lines.push(' recursive: true,');
1361
- lines.push(` regExp: ${regExp.toString()},`);
1362
- if (excludeRegExp) {
1363
- lines.push(` exclude: ${excludeRegExp.toString()},`);
1364
- }
1365
- lines.push(" mode: 'lazy',");
1366
- lines.push('});');
1367
- };
1368
-
1369
- if (includeSource.length === 0) {
1370
- emitContext(varName, includeRegExp);
1371
- } else {
1372
- // In-source test files (`includeSource`) carry an
1373
- // `if (import.meta.rstest)` block. The include context can't see them,
1374
- // so a second context over the `includeSource` globs backs
1375
- // host-scheduled loads, while `keys()` only unions the entry-probed
1376
- // in-source files (the probe does not apply inside the bundle, so raw
1377
- // source-context keys would execute never-probed files and fail with
1378
- // "No test suites found"). The probed list can go stale until a
1379
- // manifest refresh; scheduled-by-path loading never does.
1380
- emitContext(`${varName}_include`, includeRegExp);
1381
- emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
1382
- const probedKeys = testFiles.map((filePath) =>
1383
- toContextKey(filePath, projectRootPosix),
1384
- );
1385
- lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
1386
- lines.push(
1387
- `const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
1388
- );
1389
- lines.push(`const ${varName} = Object.assign(`);
1390
- lines.push(
1391
- ` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
1392
- );
1393
- lines.push(' {');
1394
- lines.push(
1395
- ` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
1396
- );
1397
- lines.push(' },');
1398
- lines.push(');');
1399
- }
1400
- } else {
1401
- // One-shot runs: the file set is fixed and already filtered, so emit an
1402
- // explicit lazy-import map (one chunk per literal `import()`, like the
1403
- // setup loaders above). The eager, non-lazyCompilation build then compiles
1404
- // only the run set instead of every included test file.
1405
- lines.push(`const ${varName}_modules = {`);
1406
- for (const filePath of testFiles) {
1407
- const key = toContextKey(filePath, projectRootPosix);
1408
- const importPath = toRelativeImport(filePath);
1409
- lines.push(
1410
- ` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
1411
- );
1412
- }
1413
- lines.push('};');
1414
- lines.push(
1415
- `const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
1416
- );
1417
- lines.push(` keys: () => Object.keys(${varName}_modules),`);
1418
- lines.push('});');
1419
- }
1420
- lines.push('');
1421
- }
1422
-
1423
- // 4. Export test contexts object
1424
- lines.push('export const projectTestContexts = {');
1425
- for (const { project } of entries) {
1426
- const varName = `context_${toSafeVarName(project.environmentName)}`;
1427
- lines.push(` ${JSON.stringify(project.name)}: {`);
1428
- lines.push(` getTestKeys: () => ${varName}.keys(),`);
1429
- lines.push(` loadTest: (key) => ${varName}(key),`);
1430
- lines.push(
1431
- ` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
1432
- );
1433
- lines.push(' },');
1434
- }
1435
- lines.push('};');
1436
- lines.push('');
1437
-
1438
- // 5. Backward compatibility exports (use first project as default)
1439
- lines.push('// Backward compatibility: export first project as default');
1440
- lines.push('export const projectConfig = projects[0];');
1441
- lines.push(
1442
- 'export const setupLoaders = projectSetupLoaders[projects[0].name] || [];',
1443
- );
1444
- lines.push('const _defaultCtx = projectTestContexts[projects[0].name];');
1445
- lines.push(
1446
- 'export const getTestKeys = () => _defaultCtx ? _defaultCtx.getTestKeys() : [];',
1447
- );
1448
- lines.push(
1449
- 'export const loadTest = (key) => _defaultCtx ? _defaultCtx.loadTest(key) : Promise.reject(new Error("No project found"));',
1450
- );
1451
-
1452
- return `${lines.join('\n')}\n`;
1453
- };
1454
-
1455
- const htmlTemplate = `<!DOCTYPE html>
1456
- <html lang="en">
1457
- <head>
1458
- <meta charset="UTF-8" />
1459
- <title>Rstest Browser Runner</title>
1460
- </head>
1461
- <body>
1462
- <script type="module" src="/static/js/runner.js"></script>
1463
- </body>
1464
- </html>
1465
- `;
1466
-
1467
- // Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
1468
- // Rsbuild suppresses the removed-file log if all removed paths include "virtual":
1469
- // https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
1470
- const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1471
-
1472
- // ============================================================================
1473
- // Browser Runtime Lifecycle
1474
- // ============================================================================
1475
-
1476
- const closeAllProjectServers = (
1477
- servers: Iterable<BrowserProjectServer>,
1478
- ): Promise<unknown> =>
1479
- Promise.allSettled([...servers].map((server) => server.devServer.close()));
1480
-
1481
- // Copy a proxied fetch Response's status + headers onto the Node response,
1482
- // dropping content-length (the body is re-sent, so the original length may not
1483
- // match).
1484
- const copyProxyResponseHeaders = (
1485
- response: Response,
1486
- res: ServerResponse,
1487
- ): void => {
1488
- res.statusCode = response.status;
1489
- response.headers.forEach((value, key) => {
1490
- if (key.toLowerCase() === 'content-length') {
1491
- return;
1492
- }
1493
- res.setHeader(key, value);
1494
- });
1495
- };
1496
-
1497
- const destroyBrowserRuntime = async (
1498
- runtime: BrowserRuntime,
1499
- ): Promise<void> => {
1500
- try {
1501
- await runtime.browser?.close?.();
1502
- } catch {
1503
- // ignore
1504
- }
1505
- await closeAllProjectServers(runtime.projectServers.values());
1506
- try {
1507
- runtime.wss?.close();
1508
- } catch {
1509
- // ignore
1510
- }
1511
- await fs
1512
- .rm(runtime.tempDir, { recursive: true, force: true })
1513
- .catch(() => {});
1514
- };
1515
-
1516
- const cleanupWatchRuntime = (): Promise<void> => {
1517
- if (watchContext.cleanupPromise) {
1518
- return watchContext.cleanupPromise;
1519
- }
1520
-
1521
- watchContext.cleanupPromise = (async () => {
1522
- if (!watchContext.runtime) {
1523
- return;
1524
- }
1525
-
1526
- await destroyBrowserRuntime(watchContext.runtime);
1527
- watchContext.runtime = null;
1528
- })();
1529
-
1530
- return watchContext.cleanupPromise;
1531
- };
1532
-
1533
- const registerWatchCleanup = (embedded: boolean): void => {
1534
- if (watchContext.cleanupRegistered) {
1535
- return;
1536
- }
1537
- watchContext.cleanupRegistered = true;
1538
-
1539
- // Embedded (programmatic) hosts own the process lifecycle; they tear the
1540
- // session down through the watch handles' `close` instead of signals.
1541
- if (embedded) {
1542
- return;
1543
- }
1544
-
1545
- // Cleanup-only nets: core's watch loop owns the signal → exit-code path
1546
- // (`registerBrowserWatchSignalExit` / the mixed watch handler) and awaits
1547
- // the same idempotent `cleanupWatchRuntime` promise through `watch.close`.
1548
- for (const signal of FATAL_SIGNALS) {
1549
- process.once(signal, () => {
1550
- void cleanupWatchRuntime();
1551
- });
1552
- }
1553
-
1554
- process.once('exit', () => {
1555
- void cleanupWatchRuntime();
1556
- });
1557
- };
1558
-
1559
- const createBrowserRuntime = async ({
1560
- context,
1561
- projectEntries: initialProjectEntries,
1562
- browserProjects,
1563
- shardedEntries,
1564
- freezeShardedEntries,
1565
- tempDir,
1566
- isWatchMode,
1567
- onTriggerRerun,
1568
- containerDistPath,
1569
- containerDevServer,
1570
- forceHeadless,
1571
- skipProviderLaunch,
1572
- appliedModifyRstestConfigEnvironments,
1573
- }: {
1574
- context: RstestContext;
1575
- projectEntries: BrowserProjectEntries[];
1576
- /**
1577
- * The explicit browser-project subset (plan output). Drives launch-option
1578
- * consistency and the container origin (`browserProjects[0]`).
1579
- */
1580
- browserProjects: ProjectContext[];
1581
- shardedEntries?: Map<string, { entries: Record<string, string> }>;
1582
- freezeShardedEntries?: boolean;
1583
- tempDir: string;
1584
- isWatchMode: boolean;
1585
- onTriggerRerun?: () => Promise<void>;
1586
- containerDistPath?: string;
1587
- containerDevServer?: string;
1588
- /** Force headless mode regardless of user config (used for list command) */
1589
- forceHeadless?: boolean;
1590
- skipProviderLaunch?: boolean;
1591
- appliedModifyRstestConfigEnvironments?: Set<string>;
1592
- }): Promise<BrowserRuntime> => {
1593
- // ---- Shared singletons (created once, wired into every project server) ----
1594
- const containerHtmlTemplate = containerDistPath
1595
- ? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
1596
- : null;
1597
-
1598
- let injectedContainerHtml: string | null = null;
1599
- let serializedOptions = 'null';
1600
- // Reserved extension seam for future browser-side capabilities.
1601
- const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
1602
-
1603
- const setContainerOptions = (options: BrowserHostConfig): void => {
1604
- serializedOptions = serializeForInlineScript(options);
1605
- if (containerHtmlTemplate) {
1606
- injectedContainerHtml = containerHtmlTemplate.replace(
1607
- OPTIONS_PLACEHOLDER,
1608
- serializedOptions,
1609
- );
1610
- }
1611
- };
1612
-
1613
- let browserLaunchOptions =
1614
- ensureConsistentBrowserLaunchOptions(browserProjects);
1615
- let projectEntries = initialProjectEntries;
1616
- // Created with the runtime so the per-project watch plugins and the
1617
- // controller's rerun closures share one state whose lifetime matches the
1618
- // compilers holding the diffed chunks.
1619
- const watchState = createBrowserWatchState();
1620
- const manifestModules: Array<{
1621
- manifestPath: string;
1622
- project: ProjectContext;
1623
- modules: Record<string, string>;
1624
- }> = [];
1625
-
1626
- const createRuntimeWithoutProvider = (): BrowserRuntime => {
1627
- const firstProject = browserProjects[0]!;
1628
- return {
1629
- projectServers: new Map(),
1630
- containerServer: {
1631
- projectName: firstProject.name,
1632
- environmentName: firstProject.environmentName,
1633
- rsbuildInstance: undefined as unknown as RsbuildInstance,
1634
- devServer: {
1635
- close: async () => undefined,
1636
- } as RsbuildDevServer,
1637
- port: 0,
1638
- manifestPath: '',
1639
- },
1640
- browser: undefined as unknown as BrowserProviderBrowser,
1641
- browserLaunchOptions,
1642
- wsPort: 0,
1643
- tempDir,
1644
- setContainerOptions,
1645
- dispatchHandlers,
1646
- wss: undefined as unknown as WebSocketServer,
1647
- projectEntries,
1648
- watchState,
1649
- };
1650
- };
1651
-
1652
- const getProjectEntry = (project: ProjectContext) =>
1653
- projectEntries.find(
1654
- (item) => item.project.environmentName === project.environmentName,
1655
- );
1656
-
1657
- const refreshManifestModule = (manifestModule: {
1658
- manifestPath: string;
1659
- project: ProjectContext;
1660
- modules: Record<string, string>;
1661
- }): void => {
1662
- const entry = getProjectEntry(manifestModule.project);
1663
- manifestModule.modules[manifestModule.manifestPath] =
1664
- generateManifestModule({
1665
- manifestPath: manifestModule.manifestPath,
1666
- entries: [
1667
- {
1668
- project: manifestModule.project,
1669
- testFiles: entry?.testFiles ?? [],
1670
- setupFiles: entry?.setupFiles ?? [],
1671
- },
1672
- ],
1673
- isWatchMode,
1674
- });
1675
- };
1676
-
1677
- const refreshProjectEntries = async (): Promise<void> => {
1678
- validateBrowserConfig(context);
1679
- browserLaunchOptions =
1680
- ensureConsistentBrowserLaunchOptions(browserProjects);
1681
- const updatedShardedEntries = freezeShardedEntries
1682
- ? shardedEntries
1683
- : context.normalizedConfig.shard
1684
- ? await resolveShardedEntries(context, { silent: true })
1685
- : shardedEntries;
1686
- projectEntries = await resolveProjectEntries(
1687
- context,
1688
- updatedShardedEntries,
1689
- browserProjects,
1690
- );
1691
- for (const manifestModule of manifestModules) {
1692
- refreshManifestModule(manifestModule);
1693
- }
1694
- };
1695
-
1696
- // Rstest internal aliases that must not be overridden by user config
1697
- const browserRuntimePath = fileURLToPath(
1698
- import.meta.resolve('@rstest/core/internal/browser-runtime'),
1699
- );
1700
-
1701
- // Shared by every project — only the per-project `@rstest/browser-manifest`
1702
- // alias varies (one virtual manifest per server).
1703
- const staticRstestAliases = {
1704
- // User test code `import { describe, it } from '@rstest/core'` is NOT
1705
- // aliased: `applyWebMockRspackConfig` keeps the request external against
1706
- // `globalThis['@rstest/core']` (node parity), which also keeps the mock
1707
- // hoister's provider-import ordering correct for `rs.hoisted` callbacks.
1708
- // User test code: import { page } from '@rstest/browser'
1709
- '@rstest/browser': resolveBrowserFile('browser.ts'),
1710
- // Browser runtime APIs for entry.ts
1711
- // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
1712
- '@rstest/core/internal/browser-runtime': browserRuntimePath,
1713
- };
1714
-
1715
- // rspack `define` replaces `process.env` / `import.meta.env` with this literal
1716
- // expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
1717
- // text, so the owned key can never drift from the runtime
1718
- // `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
1719
- const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
1720
- RSTEST_ENV_SYMBOL_KEY,
1721
- )})]`;
1722
-
1723
- // Serve prebuilt container assets (SPA) via sirv (container origin only)
1724
- const serveContainer = containerDistPath
1725
- ? sirv(containerDistPath, {
1726
- dev: false,
1727
- single: 'index.html',
1728
- })
1729
- : null;
1730
-
1731
- const containerDevBase = containerDevServer
1732
- ? new URL(containerDevServer)
1733
- : null;
1734
-
1735
- const respondWithDevServerHtml = async (
1736
- url: URL,
1737
- res: ServerResponse,
1738
- ): Promise<boolean> => {
1739
- if (!containerDevBase) {
1740
- return false;
1741
- }
1742
-
1743
- try {
1744
- const target = new URL(url.pathname + url.search, containerDevBase);
1745
- const response = await fetch(target);
1746
- if (!response.ok) {
1747
- return false;
1748
- }
1749
-
1750
- let html = await response.text();
1751
- html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1752
-
1753
- copyProxyResponseHeaders(response, res);
1754
- res.setHeader('Content-Type', 'text/html');
1755
- res.end(html);
1756
- return true;
1757
- } catch (error) {
1758
- logger.debug(
1759
- `[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
1760
- );
1761
- return false;
1762
- }
1763
- };
1764
-
1765
- const proxyDevServerAsset = async (
1766
- req: IncomingMessage,
1767
- res: ServerResponse,
1768
- ): Promise<boolean> => {
1769
- if (!containerDevBase || !req.url) {
1770
- return false;
1771
- }
1772
-
1773
- try {
1774
- const target = new URL(req.url, containerDevBase);
1775
- const response = await fetch(target);
1776
- if (!response.ok) {
1777
- return false;
1778
- }
1779
-
1780
- const buffer = Buffer.from(await response.arrayBuffer());
1781
- copyProxyResponseHeaders(response, res);
1782
- res.end(buffer);
1783
- return true;
1784
- } catch (error) {
1785
- logger.debug(
1786
- `[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
1787
- );
1788
- return false;
1789
- }
1790
- };
1791
-
1792
- const serveContainerRoute = async (
1793
- req: IncomingMessage,
1794
- res: ServerResponse,
1795
- next: () => void,
1796
- ): Promise<void> => {
1797
- if (!req.url) {
1798
- next();
1799
- return;
1800
- }
1801
-
1802
- const url = new URL(req.url, 'http://localhost');
1803
- if (url.pathname === '/') {
1804
- if (await respondWithDevServerHtml(url, res)) {
1805
- return;
1806
- }
1807
-
1808
- const html =
1809
- injectedContainerHtml ||
1810
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1811
-
1812
- if (html) {
1813
- res.setHeader('Content-Type', 'text/html');
1814
- res.end(html);
1815
- return;
1816
- }
1817
-
1818
- res.statusCode = 502;
1819
- res.end('Container UI is not available.');
1820
- return;
1821
- }
1822
-
1823
- if (url.pathname.startsWith('/container-static/')) {
1824
- if (await proxyDevServerAsset(req, res)) {
1825
- return;
1826
- }
1827
-
1828
- if (serveContainer) {
1829
- serveContainer(req, res, next);
1830
- return;
1831
- }
1832
-
1833
- res.statusCode = 502;
1834
- res.end('Container assets are not available.');
1835
- return;
1836
- }
1837
-
1838
- next();
1839
- };
1840
-
1841
- // ---- Build one isolated rsbuild instance + dev server per project ----
1842
- const buildProjectServer = async (
1843
- project: ProjectContext,
1844
- isContainerServer: boolean,
1845
- ): Promise<BrowserProjectServer> => {
1846
- const manifestPath = join(
1847
- tempDir,
1848
- toSafeVarName(project.environmentName),
1849
- VIRTUAL_MANIFEST_FILENAME,
1850
- );
1851
- const entry = getProjectEntry(project);
1852
- const virtualManifestModules = {
1853
- [manifestPath]: generateManifestModule({
1854
- manifestPath,
1855
- entries: [
1856
- {
1857
- project,
1858
- testFiles: entry?.testFiles ?? [],
1859
- setupFiles: entry?.setupFiles ?? [],
1860
- },
1861
- ],
1862
- isWatchMode,
1863
- }),
1864
- };
1865
- const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
1866
- virtualManifestModules,
1867
- );
1868
- manifestModules.push({
1869
- manifestPath,
1870
- project,
1871
- modules: virtualManifestModules,
1872
- });
1873
-
1874
- const rstestInternalAliases = {
1875
- '@rstest/browser-manifest': manifestPath,
1876
- ...staticRstestAliases,
1877
- };
1878
-
1879
- const isHeadless =
1880
- forceHeadless || project.normalizedConfig.browser.headless;
1881
- const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
1882
-
1883
- const rsbuildInstance = await createRsbuild({
1884
- callerName: 'rstest',
1885
- rsbuildConfig: {
1886
- root: context.rootPath,
1887
- mode: 'development',
1888
- server: {
1889
- printUrls: false,
1890
- // Each project gets its own dev server. Honor an explicitly
1891
- // configured port; otherwise keep the historical 4000 default for the
1892
- // container server and let the OS assign free ports for the rest, so
1893
- // multiple projects never collide on one port.
1894
- port:
1895
- project.normalizedConfig.browser.port ??
1896
- (isContainerServer ? 4000 : 0),
1897
- strictPort: project.normalizedConfig.browser.strictPort,
1898
- // User plugins may emit index.html; register before Rsbuild's HTML
1899
- // completion middleware so `/` remains owned by the Browser UI.
1900
- setup: isContainerServer
1901
- ? ({ server }) => {
1902
- server.middlewares.use(serveContainerRoute);
1903
- }
1904
- : undefined,
1905
- },
1906
- dev: createBrowserRsbuildDevConfig(enableHmr),
1907
- environments: {
1908
- [project.environmentName]:
1909
- getBrowserRsbuildEnvironmentConfig(project),
1910
- },
1911
- },
1912
- });
1913
-
1914
- initModifyRstestConfigHooks(
1915
- context,
1916
- rsbuildInstance,
1917
- [project],
1918
- [project],
1919
- {
1920
- getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
1921
- onModifyRstestConfigApplied: refreshProjectEntries,
1922
- appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
1923
- },
1924
- );
1925
-
1926
- // Add plugin to merge user Rsbuild config with rstest required config
1927
- rsbuildInstance.addPlugins([
1928
- // Same mock runtime as the node build (importActual doppelganger rule +
1929
- // mock webpack runtime module); order-insensitive and self-contained.
1930
- pluginMockRuntime,
1931
- {
1932
- name: 'rstest:browser-user-config',
1933
- setup(api) {
1934
- // Internal extension entry: register host dispatch handlers without
1935
- // coupling scheduling to individual capability implementations.
1936
- (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1937
- 'rstest:browser',
1938
- {
1939
- registerDispatchHandler: (
1940
- namespace: string,
1941
- handler: BrowserDispatchHandler,
1942
- ) => {
1943
- dispatchHandlers.set(namespace, handler);
1944
- },
1945
- },
1946
- );
1947
-
1948
- api.modifyEnvironmentConfig({
1949
- handler: (config, { mergeEnvironmentConfig, name }) => {
1950
- if (name !== project.environmentName) {
1951
- return config;
1952
- }
1953
-
1954
- const userRsbuildConfig = project.normalizedConfig;
1955
- const buildCache = resolveProjectBuildCache({
1956
- context,
1957
- project,
1958
- });
1959
- const setupFiles = Object.values(
1960
- getSetupFiles(
1961
- project.normalizedConfig.setupFiles,
1962
- project.rootPath,
1963
- ),
1964
- );
1965
- // Merge order: current config -> userConfig -> rstest required config (highest priority)
1966
- const merged = mergeEnvironmentConfig(
1967
- config,
1968
- {
1969
- ...userRsbuildConfig,
1970
- performance: buildCache
1971
- ? {
1972
- ...userRsbuildConfig.performance,
1973
- buildCache,
1974
- }
1975
- : userRsbuildConfig.performance,
1976
- },
1977
- {
1978
- resolve: {
1979
- alias: rstestInternalAliases,
1980
- },
1981
- source: {
1982
- define: {
1983
- 'process.env': rstestEnvDefine,
1984
- 'import.meta.env': rstestEnvDefine,
1985
- // In-source `if (import.meta.rstest)` blocks read the
1986
- // per-file runtime API the client entry publishes on
1987
- // `globalThis` (node parity: `global['@rstest/core']`).
1988
- 'import.meta.rstest': importMetaRstestDefine('web'),
1989
- },
1990
- },
1991
- output: {
1992
- target: 'web',
1993
- // Enable source map for inline snapshot support
1994
- sourceMap: {
1995
- js: 'source-map',
1996
- },
1997
- // Every project server compiles the same asset names
1998
- // (`static/js/runner.js`, ...). With `dev.writeToDisk`
1999
- // (debug mode) the middleware serves from disk, so a
2000
- // shared dist dir would be last-writer-wins and one
2001
- // project's server would deliver another project's
2002
- // bundle — keep each project's output isolated, inside
2003
- // the run's temp dir so teardown removes it.
2004
- distPath: {
2005
- root: join(
2006
- tempDir,
2007
- 'server',
2008
- toSafeVarName(project.environmentName),
2009
- ),
2010
- },
2011
- },
2012
- tools: {
2013
- swc: (swcConfig) => {
2014
- // Fixture dependency discovery reads callback parameters
2015
- // through Function#toString(). Playwright's supported
2016
- // browsers all support parameter destructuring, so keep
2017
- // that syntax intact in the browser test bundle.
2018
- swcConfig.env ??= {};
2019
- swcConfig.env.exclude = Array.from(
2020
- new Set([
2021
- ...(swcConfig.env.exclude ?? []),
2022
- 'transform-parameters',
2023
- ]),
2024
- );
2025
- },
2026
- rspack: (rspackConfig) => {
2027
- rspackConfig.mode = 'development';
2028
- // Web parameterization of the node mock transform:
2029
- // RstestPlugin (hoist + path injection), the
2030
- // `@rstest/core` global external, and
2031
- // `exportsPresence: 'warn'`.
2032
- applyWebMockRspackConfig(rspackConfig, {
2033
- rspack,
2034
- rootPath: project.rootPath,
2035
- });
2036
- // lazyCompilation's only delivery transport is the HMR
2037
- // runtime, so it follows the same gate as HMR (see
2038
- // `shouldEnableBrowserHmr`): headed watch only, everything
2039
- // else compiles eagerly.
2040
- rspackConfig.lazyCompilation = enableHmr
2041
- ? createBrowserLazyCompilationConfig(setupFiles)
2042
- : false;
2043
- rspackConfig.plugins = rspackConfig.plugins || [];
2044
- rspackConfig.plugins.push(virtualManifestPlugin);
2045
-
2046
- applyDefaultWatchOptions(rspackConfig, isWatchMode);
2047
-
2048
- // Extract and merge sourcemaps from pre-built @rstest/core files
2049
- // This preserves the sourcemap chain for inline snapshot support
2050
- // See: https://rspack.rs/config/module-rules#rulesextractsourcemap
2051
- const browserRuntimeDir = dirname(browserRuntimePath);
2052
- rspackConfig.module = rspackConfig.module || {};
2053
- rspackConfig.module.rules =
2054
- rspackConfig.module.rules || [];
2055
- rspackConfig.module.rules.unshift({
2056
- test: /\.js$/,
2057
- include: browserRuntimeDir,
2058
- extractSourceMap: true,
2059
- });
2060
-
2061
- if (isDebug()) {
2062
- logger.log(
2063
- `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
2064
- );
2065
- }
2066
- },
2067
- },
2068
- },
2069
- );
2070
-
2071
- // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
2072
- // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
2073
- // This must be done after mergeEnvironmentConfig to ensure highest priority.
2074
- merged.source = merged.source || {};
2075
- merged.source.entry = {
2076
- runner: resolveBrowserFile('client/entry.ts'),
2077
- };
2078
-
2079
- return merged;
2080
- },
2081
- // Execute after all other plugins to ensure rstest's entry config has the highest priority
2082
- order: 'post',
2083
- });
2084
- },
2085
- },
2086
- ]);
2087
-
2088
- // Register watch plugin if in watch mode
2089
- if (isWatchMode && onTriggerRerun) {
2090
- rsbuildInstance.addPlugins([
2091
- {
2092
- name: 'rstest:browser-watch',
2093
- setup(api) {
2094
- api.onBeforeDevCompile(() => {
2095
- watchState.compileStartTimes.set(project.name, Date.now());
2096
- if (!watchState.hooksEnabled) {
2097
- return;
2098
- }
2099
- logger.log(color.cyan('\nFile changed, re-running tests...\n'));
2100
- });
2101
-
2102
- api.onAfterDevCompile(async ({ stats }) => {
2103
- const compileStart = watchState.compileStartTimes.get(
2104
- project.name,
2105
- );
2106
- if (compileStart !== undefined) {
2107
- watchState.compileStartTimes.delete(project.name);
2108
- // Only change-triggered compiles feed the pending rerun's
2109
- // build phase (the initial build is the initial run's
2110
- // buildTime). Parallel project compiles overlap; the longest
2111
- // one bounds the rerun's build phase.
2112
- if (watchState.hooksEnabled) {
2113
- watchState.pendingBuildTimeMs = Math.max(
2114
- watchState.pendingBuildTimeMs,
2115
- Date.now() - compileStart,
2116
- );
2117
- }
2118
- }
2119
- // Collect hashes even during initial build to establish baseline
2120
- if (stats) {
2121
- // This compiler only ever holds this project's entries; the
2122
- // diff baseline is keyed per project accordingly.
2123
- const [projectEntry] = await collectProjectEntries(context, [
2124
- project,
2125
- ]);
2126
- const entryTestFiles = new Set<string>(
2127
- collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
2128
- (file) => file.testPath,
2129
- ),
2130
- );
2131
- const setupFiles = new Set<string>(
2132
- (projectEntry?.setupFiles ?? []).map((file) =>
2133
- normalize(file),
2134
- ),
2135
- );
2136
-
2137
- let state = watchState.invalidation.get(project.name);
2138
- if (!state) {
2139
- state = {};
2140
- watchState.invalidation.set(project.name, state);
2141
- }
2142
-
2143
- const statsJson = stats.toJson({ all: true });
2144
- const affected = getAffectedTestFiles({
2145
- chunks: statsJson.chunks,
2146
- entryTestFiles,
2147
- setupFiles,
2148
- state,
2149
- });
2150
-
2151
- if (affected.length > 0) {
2152
- const pending =
2153
- watchState.pendingAffectedTestFiles.get(project.name) ??
2154
- new Set<string>();
2155
- for (const file of affected) {
2156
- pending.add(file);
2157
- }
2158
- watchState.pendingAffectedTestFiles.set(
2159
- project.name,
2160
- pending,
2161
- );
2162
- logger.debug(
2163
- `[Watch] Affected test files: ${affected.join(', ')}`,
2164
- );
2165
- }
2166
- }
2167
-
2168
- if (!watchState.hooksEnabled) {
2169
- return;
2170
- }
2171
-
2172
- await onTriggerRerun();
2173
- });
2174
- },
2175
- },
2176
- ]);
2177
- }
2178
-
2179
- if (skipProviderLaunch) {
2180
- await rsbuildInstance.initConfigs({ action: 'dev' });
2181
- return {
2182
- projectName: project.name,
2183
- environmentName: project.environmentName,
2184
- rsbuildInstance,
2185
- devServer: {
2186
- close: async () => undefined,
2187
- } as RsbuildDevServer,
2188
- port: 0,
2189
- manifestPath,
2190
- };
2191
- }
2192
-
2193
- // Register coverage plugin if this project enables coverage
2194
- const coverage = project.normalizedConfig.coverage;
2195
- if (coverage?.enabled && context.command !== 'list') {
2196
- const { pluginCoverage } = await loadCoverageProvider(
2197
- coverage,
2198
- context.rootPath,
2199
- );
2200
- rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
2201
- }
2202
-
2203
- const devServer = await rsbuildInstance.createDevServer({
2204
- getPortSilently: true,
2205
- });
2206
-
2207
- if (isDebug()) {
2208
- await rsbuildInstance.inspectConfig({
2209
- writeToDisk: true,
2210
- // The server's own distPath is isolated per project inside the run's
2211
- // temp dir (removed at teardown); keep the debug artifacts at the
2212
- // project's stable dist root so they survive the run and stay where
2213
- // the docs point users to.
2214
- outputPath: resolve(
2215
- context.rootPath,
2216
- context.normalizedConfig.output.distPath.root,
2217
- '.rsbuild',
2218
- ),
2219
- extraConfigs: {
2220
- rstest: {
2221
- ...context.normalizedConfig,
2222
- projects: [project.normalizedConfig],
2223
- },
2224
- },
2225
- });
2226
- }
2227
-
2228
- devServer.middlewares.use(
2229
- async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
2230
- if (!req.url) {
2231
- next();
2232
- return;
2233
- }
2234
- const url = new URL(req.url, 'http://localhost');
2235
- if (url.pathname === '/__open-in-editor') {
2236
- const file = url.searchParams.get('file');
2237
- if (!file) {
2238
- res.statusCode = 400;
2239
- res.end('Missing file');
2240
- return;
2241
- }
2242
- try {
2243
- await openEditor([{ file }]);
2244
- res.statusCode = 204;
2245
- res.end();
2246
- } catch (error) {
2247
- logger.debug(
2248
- `[Browser UI] Failed to open editor: ${String(error)}`,
2249
- );
2250
- res.statusCode = 500;
2251
- res.end('Failed to open editor');
2252
- }
2253
- return;
2254
- }
2255
- if (url.pathname === '/runner.html') {
2256
- res.setHeader('Content-Type', 'text/html');
2257
- res.end(htmlTemplate);
2258
- return;
2259
- }
2260
- next();
2261
- },
2262
- );
2263
-
2264
- const { port: listenPort } = await devServer.listen();
2265
- const port = resolveListenPort(listenPort, devServer.httpServer);
2266
-
2267
- return {
2268
- projectName: project.name,
2269
- environmentName: project.environmentName,
2270
- rsbuildInstance,
2271
- devServer,
2272
- port,
2273
- manifestPath,
2274
- };
2275
- };
2276
-
2277
- // Build each project's server sequentially. Servers must bind ports one at a
2278
- // time: projects may share a configured port and rely on strictPort:false
2279
- // bumping to the next free one, which races under concurrent listen().
2280
- const projectServers = new Map<string, BrowserProjectServer>();
2281
- try {
2282
- for (const [index, project] of browserProjects.entries()) {
2283
- const server = await buildProjectServer(project, index === 0);
2284
- projectServers.set(server.projectName, server);
2285
- }
2286
- } catch (error) {
2287
- await closeAllProjectServers(projectServers.values());
2288
- throw error;
2289
- }
2290
-
2291
- if (skipProviderLaunch) {
2292
- return createRuntimeWithoutProvider();
2293
- }
2294
-
2295
- // browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
2296
- // otherwise) and index 0 is the designated container origin.
2297
- const containerServer = projectServers.get(browserProjects[0]!.name)!;
2298
-
2299
- // Create WebSocket server on an available port
2300
- // Using port: 0 lets the OS assign an available port, avoiding conflicts
2301
- // when the fixed port (e.g., container port + 1) is already in use
2302
- const wss = new WebSocketServer({ port: 0 });
2303
- await new Promise<void>((resolve, reject) => {
2304
- wss.once('listening', resolve);
2305
- wss.once('error', reject);
2306
- });
2307
- const wsPort = (wss.address() as AddressInfo).port;
2308
- logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
2309
-
2310
- const browserName = browserLaunchOptions.browser ?? 'chromium';
2311
- try {
2312
- const providerImplementation = getBrowserProviderImplementation(
2313
- browserLaunchOptions.provider,
2314
- );
2315
- const runtime = await providerImplementation.launchRuntime({
2316
- browserName,
2317
- headless: forceHeadless ?? browserLaunchOptions.headless,
2318
- providerOptions: browserLaunchOptions.providerOptions,
2319
- });
2320
- return {
2321
- projectServers,
2322
- containerServer,
2323
- browser: runtime.browser,
2324
- browserLaunchOptions,
2325
- wsPort,
2326
- tempDir,
2327
- setContainerOptions,
2328
- dispatchHandlers,
2329
- wss,
2330
- projectEntries,
2331
- watchState,
2332
- };
2333
- } catch (error) {
2334
- wss.close();
2335
- await closeAllProjectServers(projectServers.values());
2336
- throw error;
2337
- }
2338
- };
2339
-
2340
- async function resolveProjectEntries(
2341
- context: RstestContext,
2342
- shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
2343
- browserProjects: ProjectContext[],
2344
- ): Promise<BrowserProjectEntries[]> {
2345
- if (shardedEntries) {
2346
- const projectEntries: BrowserProjectEntries[] = [];
2347
- for (const project of browserProjects) {
2348
- const entryInfo = shardedEntries.get(project.environmentName);
2349
- if (entryInfo && Object.keys(entryInfo.entries).length > 0) {
2350
- const setup = getSetupFiles(
2351
- project.normalizedConfig.setupFiles,
2352
- project.rootPath,
2353
- );
2354
- projectEntries.push({
2355
- project,
2356
- setupFiles: Object.values(setup),
2357
- testFiles: Object.values(entryInfo.entries),
2358
- });
2359
- }
2360
- }
2361
- return projectEntries;
2362
- }
2363
- return collectProjectEntries(context, browserProjects);
2364
- }
2365
-
2366
- // ============================================================================
2367
- // Main Entry Point
2368
- // ============================================================================
2369
-
2370
- export const runBrowserController = async (
2371
- context: RstestContext,
2372
- options?: BrowserTestRunOptions,
2373
- ): Promise<BrowserTestRunResult | void> => {
2374
- const {
2375
- allowEmptyRun = false,
2376
- filesOnly = false,
2377
- onTraceEvents,
2378
- env,
2379
- } = options ?? {};
2380
- const buildStart = Date.now();
2381
- // Non-watch vs watch is the live switch for self-finalize: in non-watch runs
2382
- // core owns the unified finalize (reporters, exit code, coverage) through
2383
- // `finalizeRunCycle`, so the host never self-finalizes and always returns a
2384
- // fully-populated result with `close`. Watch reruns keep their host-driven
2385
- // per-rerun finalize.
2386
- const isWatchMode = context.command === 'watch';
2387
-
2388
- // Per-file PhaseTrackers, populated only when `--trace` is on (caller
2389
- // passes `onTraceEvents`). The browser host shares one Node process across
2390
- // every test file, so each tracker is assigned a synthetic per-file pid
2391
- // (`nextBrowserFilePid`) that lets Perfetto render each file as its own
2392
- // process track with the file path as the title. Keyed by project + path so
2393
- // concurrent projects running the same file keep separate trackers.
2394
- const phaseTrackers = onTraceEvents
2395
- ? new Map<string, PhaseTracker>()
2396
- : undefined;
2397
- const trackerKey = (project: string, testPath: string) =>
2398
- `${project}\u0000${testPath}`;
2399
- // Explicit projects input (plan output) replaces re-deriving `browser.enabled`
2400
- // projects from `context`, whose `projects` array is mutated during planning.
2401
- // Falls back to re-derivation only when the caller passes no list at all —
2402
- // an explicit empty subset must stay empty, not widen to every project.
2403
- const browserProjects = options?.projects ?? getBrowserProjects(context);
2404
- const useHeadlessDirect = browserProjects.every(
2405
- (project) => project.normalizedConfig.browser.headless,
2406
- );
2407
-
2408
- const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
2409
-
2410
- const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
2411
-
2412
- const resolveBrowserSourcemap = async (sourcePath: string) => {
2413
- if (!isHttpLikeFile(sourcePath)) {
2414
- return {
2415
- handled: false,
2416
- sourcemap: null,
2417
- };
2418
- }
2419
-
2420
- const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
2421
- if (!normalizedUrl) {
2422
- return {
2423
- handled: true,
2424
- sourcemap: null,
2425
- };
2426
- }
2427
-
2428
- if (browserSourceMapCache.has(normalizedUrl)) {
2429
- return {
2430
- handled: true,
2431
- sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
2432
- };
2433
- }
2434
-
2435
- return {
2436
- handled: true,
2437
- sourcemap: await loadSourceMapWithCache({
2438
- jsUrl: normalizedUrl,
2439
- cache: browserSourceMapCache,
2440
- }),
2441
- };
2442
- };
2443
-
2444
- const getBrowserSourcemap = async (
2445
- sourcePath: string,
2446
- ): Promise<SourceMapPayload | null> => {
2447
- const result = await resolveBrowserSourcemap(sourcePath);
2448
- return result.handled ? result.sourcemap : null;
2449
- };
2450
-
2451
- /**
2452
- * Build an error BrowserTestRunResult and call onTestRunEnd if needed.
2453
- * Used for early-exit error paths to ensure errors reach the summary report.
2454
- */
2455
- const buildErrorResult = async (
2456
- error: Error,
2457
- close?: () => Promise<void>,
2458
- ): Promise<BrowserTestRunResult> => {
2459
- const elapsed = Math.max(0, Date.now() - buildStart);
2460
- const errorResult = {
2461
- results: [],
2462
- testResults: [],
2463
- duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
2464
- hasFailure: true,
2465
- unhandledErrors: [error],
2466
- getSourcemap: getBrowserSourcemap,
2467
- resolveSourcemap: resolveBrowserSourcemap,
2468
- close,
2469
- };
2470
-
2471
- if (isWatchMode) {
2472
- for (const reporter of context.reporters) {
2473
- await (reporter as Reporter).onTestRunEnd?.({
2474
- results: [],
2475
- testResults: [],
2476
- duration: errorResult.duration,
2477
- snapshotSummary: context.snapshotManager.summary,
2478
- getSourcemap: getBrowserSourcemap,
2479
- unhandledErrors: errorResult.unhandledErrors,
2480
- });
2481
- }
2482
- }
2483
-
2484
- return errorResult;
2485
- };
2486
-
2487
- const toError = (error: unknown): Error => {
2488
- return error instanceof Error ? error : new Error(String(error));
2489
- };
2490
-
2491
- const failWithError = async (
2492
- error: unknown,
2493
- cleanup?: () => Promise<void>,
2494
- ): Promise<BrowserTestRunResult> => {
2495
- // Non-watch runs defer the exit code to core's `finalizeRunCycle`, which
2496
- // raises it from the returned outcome's `errors`. Watch reruns keep owning
2497
- // their own exit code.
2498
- if (isWatchMode) {
2499
- ensureProcessExitCode(1);
2500
- }
2501
-
2502
- const normalizedError = toError(error);
2503
-
2504
- if (cleanup && !isWatchMode) {
2505
- return buildErrorResult(normalizedError, cleanup);
2506
- }
2507
-
2508
- try {
2509
- return await buildErrorResult(normalizedError);
2510
- } finally {
2511
- await cleanup?.();
2512
- }
2513
- };
2514
-
2515
- const collectDeletedTestPaths = (
2516
- previous: TestFileInfo[],
2517
- current: TestFileInfo[],
2518
- ): string[] => {
2519
- const currentPathSet = new Set(current.map((file) => file.testPath));
2520
- return previous
2521
- .map((file) => file.testPath)
2522
- .filter((testPath) => !currentPathSet.has(testPath));
2523
- };
2524
-
2525
- const notifyTestRunStart = async (): Promise<void> => {
2526
- if (!isWatchMode) {
2527
- return;
2528
- }
2529
-
2530
- for (const reporter of context.reporters) {
2531
- await reporter.onTestRunStart?.();
2532
- }
2533
- };
2534
-
2535
- const coverageConfig = browserProjects.find(
2536
- (project) => project.normalizedConfig.coverage?.enabled,
2537
- )?.normalizedConfig.coverage;
2538
- const coverageProvider = coverageConfig?.enabled
2539
- ? await createCoverageProvider(coverageConfig, context.rootPath)
2540
- : null;
2541
-
2542
- const notifyTestRunEnd = async ({
2543
- duration,
2544
- coverage,
2545
- }: {
2546
- duration: {
2547
- totalTime: number;
2548
- buildTime: number;
2549
- testTime: number;
2550
- };
2551
- coverage?: CoverageMapData;
2552
- }): Promise<void> => {
2553
- if (!isWatchMode) {
2554
- return;
2555
- }
2556
-
2557
- for (const reporter of context.reporters) {
2558
- await reporter.onTestRunEnd?.({
2559
- results: context.reporterResults.results,
2560
- coverage,
2561
- testResults: context.reporterResults.testResults,
2562
- duration,
2563
- snapshotSummary: context.snapshotManager.summary,
2564
- getSourcemap: getBrowserSourcemap,
2565
- });
2566
- }
2567
- };
2568
-
2569
- const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
2570
- let containerDevServer: string | undefined;
2571
- let containerDistPath: string | undefined;
2572
-
2573
- if (!useHeadlessDirect) {
2574
- if (containerDevServerEnv) {
2575
- try {
2576
- containerDevServer = new URL(containerDevServerEnv).toString();
2577
- logger.debug(
2578
- `[Browser UI] Using dev server for container: ${containerDevServer}`,
2579
- );
2580
- } catch (error) {
2581
- const originalError = toError(error);
2582
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
2583
- return failWithError(originalError);
2584
- }
2585
- }
2586
-
2587
- if (!containerDevServer) {
2588
- try {
2589
- containerDistPath = resolveContainerDist();
2590
- } catch (error) {
2591
- return failWithError(error);
2592
- }
2593
- }
2594
- }
2595
-
2596
- let projectEntries = await resolveProjectEntries(
2597
- context,
2598
- options?.shardedEntries,
2599
- browserProjects,
2600
- );
2601
- let totalTests = projectEntries.reduce(
2602
- (total, item) => total + item.testFiles.length,
2603
- 0,
2604
- );
2605
- const shouldInitializeEmptyBrowserHooks =
2606
- totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
2607
-
2608
- const createEmptyRunResult = (): BrowserTestRunResult => {
2609
- const elapsed = Math.max(0, Date.now() - buildStart);
2610
- return {
2611
- results: [],
2612
- testResults: [],
2613
- duration: {
2614
- totalTime: elapsed,
2615
- buildTime: elapsed,
2616
- testTime: 0,
2617
- },
2618
- hasFailure: false,
2619
- getSourcemap: getBrowserSourcemap,
2620
- resolveSourcemap: resolveBrowserSourcemap,
2621
- };
2622
- };
2623
-
2624
- const reportEmptyTestSet = (): void => {
2625
- const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
2626
- if (isWatchMode || !allowEmptyRun) {
2627
- const message = getNoTestFilesMessage({
2628
- context,
2629
- code,
2630
- defaultMessage: `No test files found, exiting with code ${code}.`,
2631
- });
2632
- if (code === 0) {
2633
- logger.log(color.yellow(message));
2634
- } else {
2635
- logger.error(color.red(message));
2636
- }
2637
-
2638
- if (context.relatedFilters?.length) {
2639
- logger.log(
2640
- color.gray('related: '),
2641
- context.relatedFilters.join(color.gray(', ')),
2642
- );
2643
- } else if (context.fileFilters?.length) {
2644
- logger.log(
2645
- color.gray('filter: '),
2646
- context.fileFilters.join(color.gray(', ')),
2647
- );
2648
- }
2649
- }
2650
-
2651
- // In non-watch runs the host returns a void outcome and core's
2652
- // `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
2653
- // the host must not set the code itself. Watch keeps its own exit code.
2654
- if (isWatchMode && code !== 0 && !allowEmptyRun) {
2655
- ensureProcessExitCode(code);
2656
- }
2657
- };
2658
-
2659
- if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
2660
- reportEmptyTestSet();
2661
- return allowEmptyRun ? createEmptyRunResult() : undefined;
2662
- }
2663
-
2664
- if (!filesOnly) {
2665
- await notifyTestRunStart();
2666
- }
2667
-
2668
- const enableCliShortcuts = isWatchMode && isTTY('stdin');
2669
- const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
2670
- const tempDir =
2671
- isWatchMode && watchContext.runtime
2672
- ? watchContext.runtime.tempDir
2673
- : isWatchMode
2674
- ? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch')
2675
- : join(
2676
- context.rootPath,
2677
- browserTempOutputRoot,
2678
- 'browser',
2679
- Date.now().toString(),
2680
- );
2681
-
2682
- let runtime = isWatchMode ? watchContext.runtime : null;
2683
-
2684
- // Define rerun callback for watch mode (will be populated later)
2685
- let triggerRerun: (() => Promise<void>) | undefined;
2686
- // Headless reruns complete asynchronously in the scheduler's drain loop;
2687
- // the watch handles await this so callers observe rerun completion.
2688
- let awaitHeadlessRerunIdle: (() => Promise<void>) | undefined;
2689
-
2690
- if (!runtime) {
2691
- try {
2692
- runtime = await createBrowserRuntime({
2693
- context,
2694
- projectEntries,
2695
- browserProjects,
2696
- shardedEntries: options?.shardedEntries,
2697
- freezeShardedEntries: options?.freezeShardedEntries,
2698
- tempDir,
2699
- isWatchMode,
2700
- onTriggerRerun: isWatchMode
2701
- ? async () => {
2702
- await triggerRerun?.();
2703
- }
2704
- : undefined,
2705
- containerDistPath,
2706
- containerDevServer,
2707
- skipProviderLaunch: filesOnly,
2708
- appliedModifyRstestConfigEnvironments:
2709
- options?.appliedModifyRstestConfigEnvironments,
2710
- });
2711
- } catch (error) {
2712
- return failWithError(error, async () => {
2713
- await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
2714
- });
2715
- }
2716
-
2717
- if (isWatchMode) {
2718
- watchContext.runtime = runtime;
2719
- registerWatchCleanup(context.embedded);
2720
- }
2721
- }
2722
-
2723
- const watchState = runtime.watchState;
2724
-
2725
- // Track initial test files for watch mode (from this controller's freshly
2726
- // collected entries, before adopting the runtime's entry snapshot below).
2727
- if (isWatchMode) {
2728
- watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
2729
- }
2730
-
2731
- // Mark files as pending-affected so the next `triggerRerun` reruns them
2732
- // through the normal plan/schedule/finalize pipeline (used by the watch
2733
- // handles' explicit reruns; omitted paths = all current files). Returns the
2734
- // number of seeded files so callers can skip the rerun when a path-scoped
2735
- // request matches no browser test file (mixed watch 'u' with node-only
2736
- // snapshot updates).
2737
- const seedPendingRerun = (testPaths?: string[]): number => {
2738
- const wanted = testPaths
2739
- ? new Set(testPaths.map((testPath) => normalize(testPath)))
2740
- : null;
2741
- let seeded = 0;
2742
- for (const file of watchState.lastTestFiles) {
2743
- if (wanted && !wanted.has(file.testPath)) {
2744
- continue;
2745
- }
2746
- const pending =
2747
- watchState.pendingAffectedTestFiles.get(file.projectName) ??
2748
- new Set<string>();
2749
- pending.add(file.testPath);
2750
- watchState.pendingAffectedTestFiles.set(file.projectName, pending);
2751
- seeded += 1;
2752
- }
2753
- return seeded;
2754
- };
2755
-
2756
- const watchHandles: BrowserWatchHandles | undefined = isWatchMode
2757
- ? {
2758
- rerun: async (testPaths) => {
2759
- const seeded = seedPendingRerun(testPaths);
2760
- if (testPaths && seeded === 0) {
2761
- return;
2762
- }
2763
- await triggerRerun?.();
2764
- await awaitHeadlessRerunIdle?.();
2765
- },
2766
- close: cleanupWatchRuntime,
2767
- }
2768
- : undefined;
2769
-
2770
- /**
2771
- * Per-rerun finalize for watch mode: fold the rerun into a synthetic
2772
- * `ExecutorCycleOutcome` and hand it to core's `finalizeRunCycle`, so
2773
- * reporter payloads, exit-code never-downgrade semantics, and coverage
2774
- * reports match the node watch cycle. The trace buffer stays session-owned
2775
- * (no `traceRun` here); `buildTime` is the drained duration of the
2776
- * change-triggered compile(s), not a hardcoded zero.
2777
- */
2778
- const finalizeWatchRerun = async ({
2779
- rerunTestPaths,
2780
- testTime,
2781
- unhandledErrors,
2782
- }: {
2783
- rerunTestPaths: string[];
2784
- testTime: number;
2785
- unhandledErrors?: Error[];
2786
- }): Promise<void> => {
2787
- const rerunPathSet = new Set(rerunTestPaths);
2788
- const rerunResults = context.reporterResults.results.filter((result) =>
2789
- rerunPathSet.has(result.testPath),
2790
- );
2791
- // Watch coverage is per-cycle on both transports: only the files this
2792
- // rerun executed are reported.
2793
- let rerunCoverage: CoverageMapData | undefined;
2794
- const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
2795
- if (coverageMap && coverageMap.files().length > 0) {
2796
- rerunCoverage = coverageMap.toJSON();
2797
- }
2798
-
2799
- const outcome: ExecutorCycleOutcome = {
2800
- results: rerunResults,
2801
- testResults: context.reporterResults.testResults.filter((result) =>
2802
- rerunPathSet.has(result.testPath),
2803
- ),
2804
- errors: unhandledErrors ?? [],
2805
- testPaths: rerunTestPaths,
2806
- duration: {
2807
- buildTime: drainPendingBuildTime(watchState),
2808
- testTime,
2809
- },
2810
- coverage: rerunCoverage ? { map: rerunCoverage } : undefined,
2811
- resolveSourcemap: resolveBrowserSourcemap,
2812
- };
2813
-
2814
- await finalizeRunCycle(context, {
2815
- outcomes: [outcome],
2816
- mode: 'on-demand',
2817
- isWatchMode: true,
2818
- coverageProvider,
2819
- reportOnFailure: coverageConfig?.reportOnFailure ?? false,
2820
- });
2821
- };
2822
-
2823
- projectEntries = runtime.projectEntries;
2824
- totalTests = projectEntries.reduce(
2825
- (total, item) => total + item.testFiles.length,
2826
- 0,
2827
- );
2828
-
2829
- const buildTime = Date.now() - buildStart;
2830
-
2831
- if (filesOnly) {
2832
- return {
2833
- results: [],
2834
- testResults: [],
2835
- duration: {
2836
- totalTime: buildTime,
2837
- buildTime,
2838
- testTime: 0,
2839
- },
2840
- hasFailure: false,
2841
- getSourcemap: getBrowserSourcemap,
2842
- resolveSourcemap: resolveBrowserSourcemap,
2843
- close: () => destroyBrowserRuntime(runtime),
2844
- };
2845
- }
2846
-
2847
- if (totalTests === 0) {
2848
- reportEmptyTestSet();
2849
- await destroyBrowserRuntime(runtime);
2850
- return allowEmptyRun ? createEmptyRunResult() : undefined;
2851
- }
2852
-
2853
- const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2854
-
2855
- // Collect all test files from project entries with project info
2856
- // Normalize paths to posix format for cross-platform compatibility
2857
- const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
2858
- entry.testFiles.map((testPath) => ({
2859
- testPath: normalize(testPath),
2860
- projectName: entry.project.name,
2861
- })),
2862
- );
2863
-
2864
- // Only include browser mode projects in runtime configs
2865
- // Normalize projectRoot to posix format for cross-platform compatibility
2866
- const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
2867
- (project: ProjectContext) => ({
2868
- name: project.name,
2869
- environmentName: project.environmentName,
2870
- projectRoot: normalize(project.rootPath),
2871
- runtimeConfig: serializableConfig(
2872
- // `env` is the post-globalSetup change-set from the core pre-cycle
2873
- // stage; the projection layers it between the static base and the
2874
- // user `test.env` config.
2875
- projectRuntimeConfig(project, { envMode: 'static', envOverlay: env }),
2876
- ),
2877
- viewport: project.normalizedConfig.browser.viewport,
2878
- }),
2879
- );
2880
-
2881
- const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
2882
-
2883
- const projectRunnerUrls = Object.fromEntries(
2884
- [...runtime.projectServers].map(([name, server]) => [
2885
- name,
2886
- `http://localhost:${server.port}`,
2887
- ]),
2888
- );
2889
-
2890
- const hostOptions: BrowserHostConfig = {
2891
- rootPath: normalize(context.rootPath),
2892
- projects: projectRuntimeConfigs,
2893
- snapshot: {
2894
- updateSnapshot:
2895
- options?.updateSnapshot ??
2896
- context.snapshotManager.options.updateSnapshot,
2897
- },
2898
- // Container origin (fallback). Per-project runner origins below.
2899
- runnerUrl: `http://localhost:${runtime.containerServer.port}`,
2900
- projectRunnerUrls,
2901
- wsPort,
2902
- debug: isDebug(),
2903
- rpcTimeout: maxTestTimeoutForRpc,
2904
- };
2905
-
2906
- const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
2907
- (project) => ({
2908
- rootPath: normalize(project.rootPath),
2909
- provider: project.normalizedConfig.browser.provider,
2910
- }),
2911
- );
2912
- const implementationByProvider = new Map<
2913
- BrowserProvider,
2914
- BrowserProviderImplementation
2915
- >();
2916
- for (const browserProject of browserProviderProjects) {
2917
- if (!implementationByProvider.has(browserProject.provider)) {
2918
- implementationByProvider.set(
2919
- browserProject.provider,
2920
- getBrowserProviderImplementation(browserProject.provider),
2921
- );
2922
- }
2923
- }
2924
-
2925
- let activeContainerPage: BrowserProviderPage | null = null;
2926
- let getHeadlessRunnerPageBySessionId:
2927
- ((sessionId: string) => BrowserProviderPage | undefined) | undefined;
2928
-
2929
- const dispatchBrowserRpcRequest = async ({
2930
- request,
2931
- target,
2932
- }: {
2933
- request: BrowserRpcRequest;
2934
- target?: BrowserDispatchRequest['target'];
2935
- }): Promise<unknown> => {
2936
- const timeoutFallbackMs = maxTestTimeoutForRpc;
2937
- const provider = resolveProviderForTestPath({
2938
- testPath: request.testPath,
2939
- browserProjects: browserProviderProjects,
2940
- });
2941
- const implementation = implementationByProvider.get(provider);
2942
- if (!implementation) {
2943
- throw new Error(`Browser provider implementation not found: ${provider}`);
2944
- }
2945
-
2946
- const runnerPage = target?.sessionId
2947
- ? getHeadlessRunnerPageBySessionId?.(target.sessionId)
2948
- : undefined;
2949
-
2950
- if (target?.sessionId && !runnerPage) {
2951
- throw new Error(
2952
- `Runner page session not found for browser dispatch: ${target.sessionId}`,
2953
- );
2954
- }
2955
-
2956
- if (!runnerPage && !activeContainerPage) {
2957
- throw new Error('Browser container page is not initialized');
2958
- }
2959
-
2960
- try {
2961
- return await implementation.dispatchRpc({
2962
- containerPage: runnerPage
2963
- ? undefined
2964
- : (activeContainerPage ?? undefined),
2965
- runnerPage,
2966
- request,
2967
- timeoutFallbackMs,
2968
- });
2969
- } catch (error) {
2970
- // birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
2971
- if (error instanceof Error) {
2972
- throw error.message;
2973
- }
2974
- throw String(error);
2975
- }
2976
- };
2977
-
2978
- runtime.dispatchHandlers.set(
2979
- DISPATCH_NAMESPACE_BROWSER,
2980
- async (dispatchRequest) => {
2981
- const request = validateBrowserRpcRequest(dispatchRequest.args);
2982
- return dispatchBrowserRpcRequest({
2983
- request,
2984
- target: dispatchRequest.target,
2985
- });
2986
- },
2987
- );
2988
-
2989
- runtime.setContainerOptions(hostOptions);
2990
-
2991
- // Track test results from browser runners
2992
- const reporterResults: TestFileResult[] = [];
2993
- const caseResults: TestResult[] = [];
2994
- let fatalError: Error | null = null;
2995
-
2996
- // Runner lifecycle events flow through the shared RunnerEventSink (the same
2997
- // pump the node pool uses), so browser mode feeds stateManager and fans out to
2998
- // reporters via one implementation. One sink is bound per browser project up
2999
- // front from the executor's own project plan (`browserProjects`) — never from
3000
- // `context.projects`, which planning mutates to also contain node projects. The
3001
- // previous lazy resolver fell back to `context.projects[0]`, so a browser event
3002
- // could be attributed to a *node* project's config; binding per browser project
3003
- // here removes that fallback and keeps per-project `onConsoleLog` filtering and
3004
- // `resolveSnapshotPath` correct across browser projects that share a relative
3005
- // test path.
3006
- const runnerSinks = new Map<string, RunnerEventSink>(
3007
- browserProjects.map((project) => [
3008
- project.name,
3009
- createRunnerEventSink(context, project.normalizedConfig),
3010
- ]),
3011
- );
3012
- // Every per-file wire payload carries its owning project name, so routing
3013
- // never derives a project from a test path — concurrent projects can run the
3014
- // same file, and a path-keyed lookup would attribute events to the wrong one.
3015
- // The client resolves its project from the host's own manifest, so a miss is
3016
- // a protocol bug; fail loudly rather than route through another project's
3017
- // config.
3018
- const sinkForProjectName = (projectName: string): RunnerEventSink => {
3019
- const sink = runnerSinks.get(projectName);
3020
- if (!sink) {
3021
- throw new Error(`No runner event sink for project "${projectName}"`);
3022
- }
3023
- return sink;
3024
- };
3025
-
3026
- // Silent-console buffering runs through the shared controller — the same
3027
- // engine the node worker uses — so `silent: 'passed-only'` buffers logs and
3028
- // replays only the failing tasks'. Intercepted replays route through the
3029
- // owning project's sink, so they honor per-project `onConsoleLog` and
3030
- // `disableConsoleIntercept` (the browser host previously flushed straight to
3031
- // reporters, bypassing both). `writeOriginalLog` is a host-side no-op: page
3032
- // logs have no host "original stream" — the page console and headed terminal
3033
- // forwarding already show them, so re-emitting here would double-print.
3034
- const silentConsoleController = createSilentConsoleController({
3035
- runtimeConfig: {
3036
- silent: context.normalizedConfig.silent,
3037
- disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
3038
- },
3039
- emitInterceptedLog: (log) =>
3040
- sinkForProjectName(log.project).onConsoleLog(log),
3041
- writeOriginalLog: () => {},
3042
- });
3043
-
3044
- const snapshotRpcMethods = {
3045
- async resolveSnapshotPath(testPath: string): Promise<string> {
3046
- return resolveSnapshotPathDefault(
3047
- testPath,
3048
- context.normalizedConfig.resolveSnapshotPath,
3049
- );
3050
- },
3051
- async readSnapshotFile(filepath: string): Promise<string | null> {
3052
- try {
3053
- return await fs.readFile(filepath, 'utf-8');
3054
- } catch {
3055
- return null;
3056
- }
3057
- },
3058
- async saveSnapshotFile(filepath: string, content: string): Promise<void> {
3059
- const dir = dirname(filepath);
3060
- await fs.mkdir(dir, { recursive: true });
3061
- await fs.writeFile(filepath, content, 'utf-8');
3062
- },
3063
- async removeSnapshotFile(filepath: string): Promise<void> {
3064
- try {
3065
- await fs.unlink(filepath);
3066
- } catch {
3067
- // ignore if file doesn't exist
3068
- }
3069
- },
3070
- };
3071
-
3072
- const handleTestFileStart = async (
3073
- payload: TestFileStartPayload,
3074
- ): Promise<void> => {
3075
- if (phaseTrackers) {
3076
- const tracker = new PhaseTracker({
3077
- trace: {
3078
- testPath: payload.testPath,
3079
- project: payload.projectName,
3080
- },
3081
- pid: nextBrowserFilePid++,
3082
- });
3083
- tracker.transition('prepare');
3084
- phaseTrackers.set(
3085
- trackerKey(payload.projectName, payload.testPath),
3086
- tracker,
3087
- );
3088
- }
3089
- // The client sends `{ testPath, projectName }`; the sink adapter builds the
3090
- // `TestFileInfo` the reporters and stateManager expect.
3091
- await sinkForProjectName(payload.projectName).onTestFileStart({
3092
- testId: getFileTaskId(payload.testPath),
3093
- testPath: payload.testPath,
3094
- project: payload.projectName,
3095
- tests: [],
3096
- });
3097
- };
3098
-
3099
- const handleTestFileReady = async (
3100
- payload: TestFileReadyPayload,
3101
- ): Promise<void> => {
3102
- phaseTrackers
3103
- ?.get(trackerKey(payload.project, payload.testPath))
3104
- ?.transition('tests');
3105
- await sinkForProjectName(payload.project).onTestFileReady(payload);
3106
- };
3107
-
3108
- const handleTestSuiteStart = async (
3109
- payload: TestSuiteStartPayload,
3110
- ): Promise<void> => {
3111
- phaseTrackers
3112
- ?.get(trackerKey(payload.project, payload.testPath))
3113
- ?.recordSuiteStart(payload);
3114
- await sinkForProjectName(payload.project).onTestSuiteStart(payload);
3115
- };
3116
-
3117
- const handleTestSuiteResult = async (
3118
- payload: TestSuiteResultPayload,
3119
- ): Promise<void> => {
3120
- phaseTrackers
3121
- ?.get(trackerKey(payload.project, payload.testPath))
3122
- ?.recordSuiteResult(payload);
3123
- await sinkForProjectName(payload.project).onTestSuiteResult(payload);
3124
-
3125
- if (context.normalizedConfig.silent === 'passed-only') {
3126
- silentConsoleController.flushBufferedLogsForTask({
3127
- taskId: payload.testId,
3128
- status: payload.status,
3129
- taskParentNames: payload.parentNames,
3130
- taskType: 'suite',
3131
- testPath: payload.testPath,
3132
- });
3133
- }
3134
- };
3135
-
3136
- const handleTestCaseStart = async (
3137
- payload: TestCaseStartPayload,
3138
- ): Promise<void> => {
3139
- phaseTrackers
3140
- ?.get(trackerKey(payload.project, payload.testPath))
3141
- ?.recordCaseStart(payload);
3142
- // Fire-and-forget on both transports (the sink does not await case-start).
3143
- sinkForProjectName(payload.project).onTestCaseStart(payload);
3144
- };
3145
-
3146
- const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
3147
- caseResults.push(payload);
3148
- phaseTrackers
3149
- ?.get(trackerKey(payload.project, payload.testPath))
3150
- ?.recordCaseResult(payload);
3151
- await sinkForProjectName(payload.project).onTestCaseResult(payload);
3152
-
3153
- if (context.normalizedConfig.silent === 'passed-only') {
3154
- silentConsoleController.flushBufferedLogsForTask({
3155
- taskId: payload.testId,
3156
- status: payload.status,
3157
- taskParentNames: payload.parentNames,
3158
- taskType: 'case',
3159
- testPath: payload.testPath,
3160
- });
3161
- }
3162
- };
3163
-
3164
- const handleTestFileComplete = async (
3165
- payload: TestFileResult,
3166
- ): Promise<void> => {
3167
- reporterResults.push(payload);
3168
- context.updateReporterResultState([payload], payload.results);
3169
-
3170
- if (phaseTrackers) {
3171
- const key = trackerKey(payload.project, payload.testPath);
3172
- const tracker = phaseTrackers.get(key);
3173
- if (tracker) {
3174
- tracker.end();
3175
- const events = tracker.getTraceEvents();
3176
- if (events) onTraceEvents?.(events);
3177
- phaseTrackers.delete(key);
3178
- }
3179
- }
3180
-
3181
- if (context.normalizedConfig.silent === 'passed-only') {
3182
- silentConsoleController.flushBufferedLogsForTask({
3183
- taskId: payload.testId,
3184
- status: payload.status,
3185
- taskParentNames: payload.parentNames,
3186
- taskType: 'file',
3187
- testPath: payload.testPath,
3188
- });
3189
- }
3190
-
3191
- // Feeds stateManager, fans out onTestFileResult to reporters, and ingests
3192
- // payload.snapshotResult (the snapshotManager.add moved into the sink).
3193
- await sinkForProjectName(payload.project).onTestFileResult(payload);
3194
- // In non-watch runs core owns the exit code via `finalizeRunCycle` (the
3195
- // failing file rides the returned outcome); watch reruns set it here.
3196
- if (isWatchMode && payload.status === 'fail') {
3197
- ensureProcessExitCode(1);
3198
- }
3199
- };
3200
-
3201
- const handleLog = async (payload: LogPayload): Promise<void> => {
3202
- const log: UserConsoleLog = {
3203
- content: payload.content,
3204
- // Same colored level label as the node worker's CustomConsole.
3205
- name: getPrettyConsoleName(payload.level),
3206
- taskId: payload.taskId,
3207
- taskName: payload.taskName,
3208
- taskParentNames: payload.taskParentNames,
3209
- taskType: payload.taskType,
3210
- testPath: payload.testPath,
3211
- project: payload.projectName,
3212
- type: payload.type,
3213
- trace: payload.trace,
3214
- };
3215
- silentConsoleController.onConsoleLog(log);
3216
- };
3217
-
3218
- const handleFatal = async (payload: FatalPayload): Promise<void> => {
3219
- const error = new Error(payload.message);
3220
- error.stack = payload.stack;
3221
- fatalError = error;
3222
- // Non-watch runs surface the fatal error through the returned outcome and
3223
- // let core's `finalizeRunCycle` set the exit code; watch reruns set it here.
3224
- if (isWatchMode) {
3225
- ensureProcessExitCode(1);
3226
- }
3227
- };
3228
-
3229
- const runSnapshotRpc = async (
3230
- request: SnapshotRpcRequest,
3231
- ): Promise<unknown> => {
3232
- switch (request.method) {
3233
- case 'resolveSnapshotPath':
3234
- return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
3235
- case 'readSnapshotFile':
3236
- return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
3237
- case 'saveSnapshotFile':
3238
- return snapshotRpcMethods.saveSnapshotFile(
3239
- request.args.filepath,
3240
- request.args.content,
3241
- );
3242
- case 'removeSnapshotFile':
3243
- return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
3244
- default: {
3245
- // Exhaustiveness guard: a new SnapshotRpcRequest method without a case
3246
- // here fails to compile rather than silently returning undefined.
3247
- const _exhaustive: never = request;
3248
- return _exhaustive;
3249
- }
3250
- }
3251
- };
3252
-
3253
- const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
3254
- return createHostDispatchRouter({
3255
- routerOptions: options,
3256
- runnerCallbacks: {
3257
- onTestFileStart: handleTestFileStart,
3258
- onTestFileReady: handleTestFileReady,
3259
- onTestSuiteStart: handleTestSuiteStart,
3260
- onTestSuiteResult: handleTestSuiteResult,
3261
- onTestCaseStart: handleTestCaseStart,
3262
- onTestCaseResult: handleTestCaseResult,
3263
- onTestFileComplete: handleTestFileComplete,
3264
- onLog: handleLog,
3265
- onFatal: handleFatal,
3266
- },
3267
- runSnapshotRpc,
3268
- extensionHandlers: runtime.dispatchHandlers,
3269
- onDuplicateNamespace: (namespace) => {
3270
- logger.debug(
3271
- `[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
3272
- );
3273
- },
3274
- });
3275
- };
3276
-
3277
- if (useHeadlessDirect) {
3278
- // Session-based scheduling path: lifecycle + session index + dispatch routing.
3279
- type ActiveHeadlessRun = RunSession & {
3280
- contexts: Set<BrowserProviderContext>;
3281
- };
3282
-
3283
- const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
3284
- const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
3285
- const sessionRegistry = new RunnerSessionRegistry();
3286
- getHeadlessRunnerPageBySessionId = (sessionId) => {
3287
- return sessionRegistry.getById(sessionId)?.page;
3288
- };
3289
- let dispatchRequestCounter = 0;
3290
-
3291
- const nextDispatchRequestId = (namespace: string): string => {
3292
- return `${namespace}-${++dispatchRequestCounter}`;
3293
- };
3294
-
3295
- const closeContextSafely = async (
3296
- browserContext: BrowserProviderContext,
3297
- ): Promise<void> => {
3298
- try {
3299
- await browserContext.close();
3300
- } catch {
3301
- // ignore
3302
- }
3303
- };
3304
-
3305
- const cancelRun = async (
3306
- run: ActiveHeadlessRun,
3307
- waitForDone = true,
3308
- ): Promise<void> => {
3309
- await runLifecycle.cancel(run, {
3310
- waitForDone,
3311
- onCancel: async (session) => {
3312
- await Promise.all(
3313
- Array.from(session.contexts).map((browserContext) =>
3314
- closeContextSafely(browserContext),
3315
- ),
3316
- );
3317
- },
3318
- });
3319
- };
3320
-
3321
- const dispatchRouter = createDispatchRouter({
3322
- isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
3323
- onStale: (request) => {
3324
- if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
3325
- logger.debug(
3326
- `[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
3327
- );
3328
- }
3329
- },
3330
- });
3331
-
3332
- const dispatchRunnerMessage = async (
3333
- run: ActiveHeadlessRun,
3334
- file: TestFileInfo,
3335
- sessionId: string,
3336
- message: BrowserClientMessage,
3337
- ): Promise<void> => {
3338
- const response = await dispatchRouter.dispatch({
3339
- requestId: nextDispatchRequestId(DISPATCH_NAMESPACE_RUNNER),
3340
- runToken: run.token,
3341
- namespace: DISPATCH_NAMESPACE_RUNNER,
3342
- method: message.type,
3343
- args: 'payload' in message ? message.payload : undefined,
3344
- target: {
3345
- sessionId,
3346
- testFile: file.testPath,
3347
- projectName: file.projectName,
3348
- },
3349
- });
3350
-
3351
- if (response.stale) {
3352
- return;
3353
- }
3354
-
3355
- if (response.error) {
3356
- throw new Error(response.error);
3357
- }
3358
- };
3359
-
3360
- const runSingleFile = async (
3361
- run: ActiveHeadlessRun,
3362
- file: TestFileInfo,
3363
- ): Promise<void> => {
3364
- if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
3365
- return;
3366
- }
3367
-
3368
- const viewport = viewportByProject.get(file.projectName);
3369
- const browserContext = await browser.newContext({
3370
- providerOptions: browserLaunchOptions.providerOptions,
3371
- viewport: viewport ?? null,
3372
- });
3373
- run.contexts.add(browserContext);
3374
-
3375
- let page: BrowserProviderPage | null = null;
3376
- let sessionId: string | null = null;
3377
- let settled = false;
3378
- let resolveDone: (() => void) | null = null;
3379
-
3380
- const markDone = (): void => {
3381
- if (!settled) {
3382
- settled = true;
3383
- resolveDone?.();
3384
- }
3385
- };
3386
-
3387
- const donePromise = new Promise<void>((resolve) => {
3388
- resolveDone = resolve;
3389
- });
3390
-
3391
- // Event-driven death detection (vitest-style): a renderer crash or an
3392
- // unexpected page close produces no further messages, so fail the file at
3393
- // once. Per-test/hook timeouts are enforced inside the runner, so the host
3394
- // deliberately keeps no execution-duration watchdog. Our own teardown
3395
- // close is ignored because `settled`/`run.cancelled` are set by then.
3396
- const crashDeferred = createDeferredPromise<string>();
3397
- const onPageDead = (reason: string): void => {
3398
- if (
3399
- settled ||
3400
- run.cancelled ||
3401
- !runLifecycle.isTokenActive(run.token)
3402
- ) {
3403
- return;
3404
- }
3405
- settled = true;
3406
- crashDeferred.resolve(reason);
3407
- };
3408
-
3409
- try {
3410
- page = await browserContext.newPage();
3411
- page.on('crash', () =>
3412
- onPageDead(`Browser page crashed while running ${file.testPath}.`),
3413
- );
3414
- page.on('close', () =>
3415
- onPageDead(
3416
- `Browser page closed unexpectedly while running ${file.testPath}.`,
3417
- ),
3418
- );
3419
-
3420
- const session = sessionRegistry.register({
3421
- testFile: file.testPath,
3422
- projectName: file.projectName,
3423
- runToken: run.token,
3424
- mode: 'headless-page',
3425
- context: browserContext,
3426
- page,
3427
- });
3428
- sessionId = session.id;
3429
-
3430
- await attachHeadlessRunnerTransport(page, {
3431
- onDispatchMessage: async (message) => {
3432
- try {
3433
- await dispatchRunnerMessage(run, file, session.id, message);
3434
- if (
3435
- message.type === 'file-complete' ||
3436
- message.type === 'complete'
3437
- ) {
3438
- markDone();
3439
- } else if (message.type === 'fatal') {
3440
- markDone();
3441
- await cancelRun(run, false);
3442
- }
3443
- } catch (error) {
3444
- const formatted = toError(error);
3445
- await handleFatal({
3446
- message: formatted.message,
3447
- stack: formatted.stack,
3448
- });
3449
- markDone();
3450
- await cancelRun(run, false);
3451
- }
3452
- },
3453
- onDispatchRpc: async (request) => {
3454
- return dispatchRouter.dispatch({
3455
- ...request,
3456
- runToken: run.token,
3457
- target: {
3458
- sessionId: session.id,
3459
- testFile: file.testPath,
3460
- projectName: file.projectName,
3461
- ...request.target,
3462
- },
3463
- });
3464
- },
3465
- });
3466
-
3467
- const inlineOptions: BrowserHostConfig = {
3468
- ...hostOptions,
3469
- // Read live per page load, not from the construction-time
3470
- // `hostOptions` value: the 'u' shortcut flips
3471
- // `snapshotManager.options` between reruns.
3472
- snapshot: {
3473
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
3474
- },
3475
- testFile: file.testPath,
3476
- runId: `${run.token}:${session.id}`,
3477
- };
3478
- const serializedOptions = serializeForInlineScript(inlineOptions);
3479
- await page.addInitScript(
3480
- `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
3481
- );
3482
-
3483
- const projectServer = runtime.projectServers.get(file.projectName);
3484
- if (!projectServer) {
3485
- throw new Error(
3486
- `No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`,
3487
- );
3488
- }
3489
- await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
3490
- waitUntil: 'load',
3491
- });
3492
-
3493
- const state = await Promise.race([
3494
- donePromise.then(() => ({ type: 'done' as const })),
3495
- crashDeferred.promise.then((reason) => ({
3496
- type: 'crash' as const,
3497
- reason,
3498
- })),
3499
- run.cancelSignal.then(() => ({ type: 'cancelled' as const })),
3500
- ]);
3501
-
3502
- if (state.type === 'cancelled') {
3503
- return;
3504
- }
3505
-
3506
- if (
3507
- state.type === 'crash' &&
3508
- runLifecycle.isTokenActive(run.token) &&
3509
- !run.cancelled
3510
- ) {
3511
- await handleFatal({ message: state.reason });
3512
- await cancelRun(run, false);
3513
- }
3514
- } catch (error) {
3515
- if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
3516
- const formatted = toError(error);
3517
- await handleFatal({
3518
- message: formatted.message,
3519
- stack: formatted.stack,
3520
- });
3521
- await cancelRun(run, false);
3522
- }
3523
- } finally {
3524
- if (page) {
3525
- try {
3526
- await page.close();
3527
- } catch {
3528
- // ignore
3529
- }
3530
- }
3531
- if (sessionId) {
3532
- sessionRegistry.deleteById(sessionId);
3533
- }
3534
- run.contexts.delete(browserContext);
3535
- await closeContextSafely(browserContext);
3536
- }
3537
- };
3538
-
3539
- // Bailed files never run, so they carry no case results — mirror the node
3540
- // pool's skip result (`runInPool.ts`) so the summary reports them as skipped
3541
- // rather than dropping them silently.
3542
- const makeSkippedFileResult = (file: TestFileInfo): TestFileResult => ({
3543
- testId: getFileTaskId(file.testPath),
3544
- status: 'skip',
3545
- name: '',
3546
- testPath: file.testPath,
3547
- project: file.projectName,
3548
- results: [],
3549
- });
3550
-
3551
- const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
3552
- if (files.length === 0) {
3553
- return;
3554
- }
3555
-
3556
- const previous = runLifecycle.activeSession;
3557
- if (previous) {
3558
- await cancelRun(previous);
3559
- }
3560
-
3561
- const run = runLifecycle.createSession((token) => ({
3562
- ...createRunSession(token),
3563
- contexts: new Set<BrowserProviderContext>(),
3564
- }));
3565
-
3566
- const queue = [...files];
3567
- const concurrency = getHeadlessConcurrency(context, queue.length);
3568
- const bail = context.normalizedConfig.bail;
3569
-
3570
- const worker = async (): Promise<void> => {
3571
- while (
3572
- queue.length > 0 &&
3573
- !run.cancelled &&
3574
- runLifecycle.isTokenActive(run.token)
3575
- ) {
3576
- // Cross-file bail gate (parity with the node pool's pickup-time skip
3577
- // at `runInPool.ts`): once the cycle-wide failed count reaches `bail`,
3578
- // drain the remaining files as skipped instead of running them. The
3579
- // count is cycle-scoped because `stateManager` is reset at the top of
3580
- // every run/rerun (initial run and `prepareWatchRerunState`).
3581
- if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
3582
- let skipped = queue.shift();
3583
- while (skipped) {
3584
- await handleTestFileComplete(makeSkippedFileResult(skipped));
3585
- skipped = queue.shift();
3586
- }
3587
- return;
3588
- }
3589
- const next = queue.shift();
3590
- if (!next) {
3591
- return;
3592
- }
3593
- await runSingleFile(run, next);
3594
- }
3595
- };
3596
-
3597
- run.done = Promise.all(
3598
- Array.from(
3599
- { length: Math.min(queue.length, Math.max(concurrency, 1)) },
3600
- () => worker(),
3601
- ),
3602
- ).then(() => {});
3603
-
3604
- await run.done;
3605
- runLifecycle.clearIfActive(run);
3606
- };
3607
-
3608
- const latestRerunScheduler = createHeadlessLatestRerunScheduler<
3609
- TestFileInfo,
3610
- ActiveHeadlessRun
3611
- >({
3612
- getActiveRun: () => runLifecycle.activeSession,
3613
- isRunCancelled: (run) => run.cancelled,
3614
- invalidateActiveRun: () => {
3615
- runLifecycle.invalidateActiveToken();
3616
- },
3617
- interruptActiveRun: async (run) => {
3618
- await cancelRun(run, false);
3619
- },
3620
- runFiles: async (files) => {
3621
- // Clear the previous cycle's stateManager/snapshotManager before the
3622
- // rerun streams new events through the shared sink — otherwise failed
3623
- // counts (bail) and snapshot summaries accumulate across reruns. The
3624
- // initial run does not reach here (it calls `runFilesWithPool`
3625
- // directly), so only reruns reset.
3626
- prepareWatchRerunState(context);
3627
- await notifyTestRunStart();
3628
-
3629
- const rerunStartTime = Date.now();
3630
- const fatalErrorBeforeRun = fatalError;
3631
- let rerunError: Error | undefined;
3632
-
3633
- try {
3634
- await runFilesWithPool(files);
3635
- } catch (error) {
3636
- rerunError = toError(error);
3637
- throw error;
3638
- } finally {
3639
- const testTime = Math.max(0, Date.now() - rerunStartTime);
3640
- const rerunFatalError =
3641
- fatalError && fatalError !== fatalErrorBeforeRun
3642
- ? fatalError
3643
- : undefined;
3644
- await finalizeWatchRerun({
3645
- rerunTestPaths: files.map((file) => file.testPath),
3646
- testTime,
3647
- unhandledErrors: rerunError
3648
- ? [rerunError]
3649
- : rerunFatalError
3650
- ? [rerunFatalError]
3651
- : undefined,
3652
- });
3653
- logWatchReadyMessage(context, enableCliShortcuts);
3654
- }
3655
- },
3656
- onError: async (error) => {
3657
- const formatted = toError(error);
3658
- await handleFatal({
3659
- message: formatted.message,
3660
- stack: formatted.stack,
3661
- });
3662
- },
3663
- onInterrupt: (run) => {
3664
- logger.debug(
3665
- `[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`,
3666
- );
3667
- },
3668
- });
3669
-
3670
- awaitHeadlessRerunIdle = () => latestRerunScheduler.whenIdle();
3671
-
3672
- const testStart = Date.now();
3673
- await runFilesWithPool(allTestFiles);
3674
- const testTime = Date.now() - testStart;
3675
-
3676
- if (isWatchMode) {
3677
- triggerRerun = async () => {
3678
- const newProjectEntries = await collectProjectEntries(context);
3679
- const rerunPlan = planWatchRerun({
3680
- projectEntries: newProjectEntries,
3681
- previousTestFiles: watchState.lastTestFiles,
3682
- affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3683
- });
3684
-
3685
- if (rerunPlan.filesChanged) {
3686
- const deletedTestPaths = collectDeletedTestPaths(
3687
- watchState.lastTestFiles,
3688
- rerunPlan.currentTestFiles,
3689
- );
3690
- if (deletedTestPaths.length > 0) {
3691
- context.updateReporterResultState([], [], deletedTestPaths);
3692
- }
3693
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
3694
- if (rerunPlan.currentTestFiles.length === 0) {
3695
- await latestRerunScheduler.enqueueLatest([]);
3696
- logger.log(
3697
- color.cyan('No browser test files remain after update.\n'),
3698
- );
3699
- logWatchReadyMessage(context, enableCliShortcuts);
3700
- return;
3701
- }
3702
-
3703
- logger.log(
3704
- color.cyan(
3705
- `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3706
- ),
3707
- );
3708
- await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3709
- return;
3710
- }
3711
-
3712
- if (rerunPlan.affectedTestFiles.length === 0) {
3713
- logger.log(
3714
- color.cyan(
3715
- 'No affected browser test files detected, skipping re-run.\n',
3716
- ),
3717
- );
3718
- logWatchReadyMessage(context, enableCliShortcuts);
3719
- return;
3720
- }
3721
-
3722
- logger.log(
3723
- color.cyan(
3724
- `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
3725
- ),
3726
- );
3727
- await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3728
- };
3729
- }
3730
-
3731
- const closeHeadlessRuntime = !isWatchMode
3732
- ? async () => {
3733
- sessionRegistry.clear();
3734
- await destroyBrowserRuntime(runtime);
3735
- }
3736
- : undefined;
3737
-
3738
- if (fatalError) {
3739
- return failWithError(fatalError, closeHeadlessRuntime);
3740
- }
3741
-
3742
- const duration = {
3743
- totalTime: buildTime + testTime,
3744
- buildTime,
3745
- testTime,
3746
- };
3747
-
3748
- context.updateReporterResultState(reporterResults, caseResults);
3749
-
3750
- const isFailure = reporterResults.some(
3751
- (result: TestFileResult) => result.status === 'fail',
3752
- );
3753
- // Non-watch runs let core's `finalizeRunCycle` own the exit code from the
3754
- // returned outcome; watch reruns set it here.
3755
- if (isWatchMode && isFailure) {
3756
- ensureProcessExitCode(1);
3757
- }
3758
-
3759
- // Fold and strip the initial cycle's coverage (the same per-cycle fold
3760
- // reruns get in `finalizeWatchRerun`); the map rides on the result so the
3761
- // browser-only watch path can report it without re-merging. Non-watch runs
3762
- // keep `result.coverage` intact for the executor's outcome fold.
3763
- const cycleCoverageMap =
3764
- isWatchMode && coverageProvider
3765
- ? buildBrowserCoverageMap(reporterResults, coverageProvider)
3766
- : undefined;
3767
-
3768
- const result = {
3769
- results: reporterResults,
3770
- testResults: caseResults,
3771
- duration,
3772
- hasFailure: isFailure,
3773
- getSourcemap: getBrowserSourcemap,
3774
- resolveSourcemap: resolveBrowserSourcemap,
3775
- // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3776
- // non-watch caller (core) receives the deferred close and watch does not.
3777
- close: closeHeadlessRuntime,
3778
- coverage: cycleCoverageMap,
3779
- watch: watchHandles,
3780
- };
3781
-
3782
- if (isWatchMode) {
3783
- try {
3784
- await notifyTestRunEnd({
3785
- duration,
3786
- coverage: cycleCoverageMap?.files().length
3787
- ? cycleCoverageMap.toJSON()
3788
- : undefined,
3789
- });
3790
- } finally {
3791
- await closeHeadlessRuntime?.();
3792
- }
3793
- }
3794
-
3795
- if (isWatchMode && triggerRerun) {
3796
- watchState.hooksEnabled = true;
3797
- logWatchReadyMessage(context, enableCliShortcuts);
3798
- }
3799
-
3800
- return result;
3801
- }
3802
-
3803
- let currentTestFiles = allTestFiles;
3804
- // Coincidentally equal to the runner-side CONFIG_WAIT_TIMEOUT_MS and
3805
- // DEFAULT_RPC_TIMEOUT_MS (client/entry.ts, client/dispatchTransport.ts) but
3806
- // semantically distinct and in a different runtime, so deliberately NOT shared
3807
- // with them. Invariant worth preserving: a runner must be able to receive its
3808
- // config (config-wait) before the host declares its frames un-ready, i.e.
3809
- // CONFIG_WAIT_TIMEOUT_MS <= RUNNER_FRAMES_READY_TIMEOUT_MS.
3810
- const RUNNER_FRAMES_READY_TIMEOUT_MS = 30_000;
3811
- let currentRunnerFramesSignature: string | null = null;
3812
- const runnerFramesWaiters = new Map<string, Set<() => void>>();
3813
-
3814
- const createTestFilesSignature = (testFiles: readonly string[]): string => {
3815
- return JSON.stringify(testFiles.map((testFile) => normalize(testFile)));
3816
- };
3817
-
3818
- const markRunnerFramesReady = (testFiles: string[]): void => {
3819
- const signature = createTestFilesSignature(testFiles);
3820
- currentRunnerFramesSignature = signature;
3821
- const waiters = runnerFramesWaiters.get(signature);
3822
- if (!waiters) {
3823
- return;
3824
- }
3825
- runnerFramesWaiters.delete(signature);
3826
- for (const waiter of waiters) {
3827
- waiter();
3828
- }
3829
- };
3830
-
3831
- const waitForRunnerFramesReady = async (
3832
- testFiles: readonly string[],
3833
- ): Promise<void> => {
3834
- const signature = createTestFilesSignature(testFiles);
3835
- if (currentRunnerFramesSignature === signature) {
3836
- return;
3837
- }
3838
-
3839
- await new Promise<void>((resolve, reject) => {
3840
- const waiters =
3841
- runnerFramesWaiters.get(signature) ?? new Set<() => void>();
3842
-
3843
- const cleanup = () => {
3844
- const currentWaiters = runnerFramesWaiters.get(signature);
3845
- if (!currentWaiters) {
3846
- return;
3847
- }
3848
- currentWaiters.delete(onReady);
3849
- if (currentWaiters.size === 0) {
3850
- runnerFramesWaiters.delete(signature);
3851
- }
3852
- };
3853
-
3854
- const onReady = () => {
3855
- if (timeoutId) {
3856
- clearTimeout(timeoutId);
3857
- }
3858
- cleanup();
3859
- resolve();
3860
- };
3861
-
3862
- const timeoutId = setTimeout(() => {
3863
- cleanup();
3864
- reject(
3865
- new Error(
3866
- `Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`,
3867
- ),
3868
- );
3869
- }, RUNNER_FRAMES_READY_TIMEOUT_MS);
3870
-
3871
- waiters.add(onReady);
3872
- runnerFramesWaiters.set(signature, waiters);
3873
-
3874
- if (currentRunnerFramesSignature === signature) {
3875
- onReady();
3876
- }
3877
- });
3878
- };
3879
-
3880
- const getTestFileInfo = (testFile: string): TestFileInfo => {
3881
- const normalizedTestFile = normalize(testFile);
3882
- const fileInfo = currentTestFiles.find(
3883
- (file) => file.testPath === normalizedTestFile,
3884
- );
3885
- if (!fileInfo) {
3886
- throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
3887
- }
3888
- return fileInfo;
3889
- };
3890
-
3891
- // Open a container page for user to view (reuse in watch mode)
3892
- let containerContext: BrowserProviderContext;
3893
- let containerPage: BrowserProviderPage;
3894
- let isNewPage = false;
3895
-
3896
- if (isWatchMode && runtime.containerPage && runtime.containerContext) {
3897
- containerContext = runtime.containerContext;
3898
- containerPage = runtime.containerPage;
3899
- logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
3900
- } else {
3901
- isNewPage = true;
3902
- containerContext = await browser.newContext({
3903
- providerOptions: browserLaunchOptions.providerOptions,
3904
- viewport: null,
3905
- });
3906
- containerPage = await containerContext.newPage();
3907
-
3908
- // Prevent popup windows from being created
3909
- containerPage.on('popup', async (popup: BrowserProviderPage) => {
3910
- await popup.close().catch(() => {});
3911
- });
3912
-
3913
- containerContext.on('page', async (page: BrowserProviderPage) => {
3914
- if (page !== containerPage) {
3915
- await page.close().catch(() => {});
3916
- }
3917
- });
3918
-
3919
- if (isWatchMode) {
3920
- runtime.containerPage = containerPage;
3921
- runtime.containerContext = containerContext;
3922
- }
3923
-
3924
- // Forward browser console to terminal
3925
- containerPage.on('console', (msg) => {
3926
- const text = msg.text();
3927
- if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
3928
- logger.log(color.gray(`[Browser Console] ${text}`));
3929
- }
3930
- });
3931
- }
3932
-
3933
- activeContainerPage = containerPage;
3934
-
3935
- const dispatchRouter = createDispatchRouter();
3936
- const headedReloadQueue = createHeadedSerialTaskQueue();
3937
- const pendingHeadedReloads = new Map<
3938
- string,
3939
- {
3940
- runId: string;
3941
- deferred: DeferredPromise<void>;
3942
- }
3943
- >();
3944
- let enqueueHeadedReload = async (
3945
- _file: TestFileInfo,
3946
- _testNamePattern?: string,
3947
- ): Promise<void> => {
3948
- throw new Error('Headed reload queue is not initialized');
3949
- };
3950
-
3951
- const rejectPendingHeadedReload = (
3952
- testPath: string,
3953
- error: Error,
3954
- runId?: string,
3955
- ): void => {
3956
- const pending = pendingHeadedReloads.get(testPath);
3957
- if (!pending) {
3958
- return;
3959
- }
3960
- if (runId && pending.runId !== runId) {
3961
- return;
3962
- }
3963
- pendingHeadedReloads.delete(testPath);
3964
- pending.deferred.reject(error);
3965
- };
3966
-
3967
- const rejectAllPendingHeadedReloads = (error: Error): void => {
3968
- for (const [testPath, pending] of pendingHeadedReloads) {
3969
- pendingHeadedReloads.delete(testPath);
3970
- pending.deferred.reject(error);
3971
- }
3972
- };
3973
-
3974
- const registerPendingHeadedReload = (
3975
- testPath: string,
3976
- runId: string,
3977
- ): Promise<void> => {
3978
- const previousPending = pendingHeadedReloads.get(testPath);
3979
- if (previousPending) {
3980
- previousPending.deferred.reject(
3981
- new Error(
3982
- `Reload for "${testPath}" was superseded by a newer request.`,
3983
- ),
3984
- );
3985
- pendingHeadedReloads.delete(testPath);
3986
- }
3987
-
3988
- const deferred = createDeferredPromise<void>();
3989
- pendingHeadedReloads.set(testPath, {
3990
- runId,
3991
- deferred,
3992
- });
3993
-
3994
- return deferred.promise;
3995
- };
3996
-
3997
- const resolvePendingHeadedReload = (
3998
- testPath: string,
3999
- runId?: string,
4000
- ): void => {
4001
- const pending = pendingHeadedReloads.get(testPath);
4002
- if (!pending) {
4003
- return;
4004
- }
4005
- if (runId && pending.runId !== runId) {
4006
- logger.debug(
4007
- `[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`,
4008
- );
4009
- return;
4010
- }
4011
- pendingHeadedReloads.delete(testPath);
4012
- pending.deferred.resolve();
4013
- };
4014
-
4015
- // No execution-duration watchdog: per-test/hook timeouts are enforced inside
4016
- // the runner, and a dead container is caught event-driven by the WebSocket
4017
- // `close` handler, which rejects every pending reload via `onDisconnect`.
4018
- const reloadTestFileAndWait = async (
4019
- file: TestFileInfo,
4020
- testNamePattern?: string,
4021
- ): Promise<void> => {
4022
- let reloadAck: ReloadTestFileAck | undefined;
4023
-
4024
- try {
4025
- reloadAck = await rpcManager.reloadTestFile(
4026
- file.testPath,
4027
- testNamePattern,
4028
- );
4029
- await registerPendingHeadedReload(file.testPath, reloadAck.runId);
4030
- } catch (error) {
4031
- if (reloadAck?.runId) {
4032
- rejectPendingHeadedReload(
4033
- file.testPath,
4034
- toError(error),
4035
- reloadAck.runId,
4036
- );
4037
- }
4038
- throw error;
4039
- }
4040
- };
4041
-
4042
- // Create RPC methods that can access test state variables
4043
- const createRpcMethods = (): HostRpcMethods => ({
4044
- async rerunTest(testFile: string, testNamePattern?: string) {
4045
- const projectName = context.normalizedConfig.name || 'project';
4046
- const relativePath = relative(context.rootPath, testFile);
4047
- const displayPath = `<${projectName}>/${relativePath}`;
4048
- logger.log(
4049
- color.cyan(
4050
- `\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
4051
- ),
4052
- );
4053
- await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
4054
- },
4055
- async getTestFiles() {
4056
- return currentTestFiles;
4057
- },
4058
- async onRunnerFramesReady(testFiles: string[]) {
4059
- markRunnerFramesReady(testFiles);
4060
- },
4061
- async onTestFileStart(payload: TestFileStartPayload) {
4062
- await handleTestFileStart(payload);
4063
- },
4064
- async onTestCaseResult(payload: TestResult) {
4065
- await handleTestCaseResult(payload);
4066
- },
4067
- async onTestFileComplete(payload: HeadedTestFileCompletePayload) {
4068
- try {
4069
- await handleTestFileComplete(payload);
4070
- resolvePendingHeadedReload(payload.testPath, payload.runId);
4071
- } catch (error) {
4072
- rejectPendingHeadedReload(
4073
- payload.testPath,
4074
- toError(error),
4075
- payload.runId,
4076
- );
4077
- throw error;
4078
- }
4079
- },
4080
- async onLog(payload: LogPayload) {
4081
- await handleLog(payload);
4082
- },
4083
- async onFatal(payload: FatalPayload) {
4084
- const error = new Error(payload.message);
4085
- error.stack = payload.stack;
4086
- rejectAllPendingHeadedReloads(error);
4087
- await handleFatal(payload);
4088
- },
4089
- async dispatch(request: BrowserDispatchRequest) {
4090
- // Headed/container path now shares the same dispatch contract as headless.
4091
- return dispatchRouter.dispatch(request);
4092
- },
4093
- });
4094
-
4095
- // Setup RPC manager
4096
- let rpcManager: ContainerRpcManager;
4097
-
4098
- if (isWatchMode && runtime.rpcManager) {
4099
- rpcManager = runtime.rpcManager;
4100
- // Update methods with new test state (caseResults, completedTests, etc.)
4101
- rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
4102
- // Reattach if we have an existing WebSocket
4103
- const existingWs = rpcManager.currentWebSocket;
4104
- if (existingWs) {
4105
- rpcManager.reattach(existingWs);
4106
- }
4107
- } else {
4108
- rpcManager = new ContainerRpcManager(
4109
- wss,
4110
- createRpcMethods(),
4111
- rejectAllPendingHeadedReloads,
4112
- );
4113
-
4114
- if (isWatchMode) {
4115
- runtime.rpcManager = rpcManager;
4116
- }
4117
- }
4118
-
4119
- // Only navigate on first creation
4120
- if (isNewPage) {
4121
- const pagePath = '/';
4122
- const containerPort = runtime.containerServer.port;
4123
- await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
4124
- waitUntil: 'load',
4125
- });
4126
-
4127
- logger.log(
4128
- color.cyan(
4129
- `\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
4130
- ),
4131
- );
4132
- }
4133
-
4134
- enqueueHeadedReload = async (
4135
- file: TestFileInfo,
4136
- testNamePattern?: string,
4137
- ): Promise<void> => {
4138
- return headedReloadQueue.enqueue(async () => {
4139
- if (fatalError) {
4140
- return;
4141
- }
4142
- await reloadTestFileAndWait(file, testNamePattern);
4143
- });
4144
- };
4145
-
4146
- let testTime = 0;
4147
- if (currentTestFiles.length > 0) {
4148
- const testStart = Date.now();
4149
- try {
4150
- await waitForRunnerFramesReady(
4151
- currentTestFiles.map((file) => file.testPath),
4152
- );
4153
-
4154
- for (const file of currentTestFiles) {
4155
- await enqueueHeadedReload(file);
4156
- if (fatalError) {
4157
- break;
4158
- }
4159
- }
4160
- } catch (error) {
4161
- fatalError = fatalError ?? toError(error);
4162
- // Non-watch: the fatal error rides the returned outcome and core owns the
4163
- // exit code; watch reruns set it here.
4164
- if (isWatchMode) {
4165
- ensureProcessExitCode(1);
4166
- }
4167
- }
4168
-
4169
- testTime = Date.now() - testStart;
4170
- }
4171
-
4172
- // Define rerun logic for watch mode
4173
- if (isWatchMode) {
4174
- triggerRerun = async () => {
4175
- // Re-deliver the host config so runner iframes reloaded by this rerun
4176
- // observe live per-rerun values ('u' flips updateSnapshot between
4177
- // reruns); `setContainerOptions` keeps full container reloads in sync.
4178
- const refreshedHostOptions: BrowserHostConfig = {
4179
- ...hostOptions,
4180
- snapshot: {
4181
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
4182
- },
4183
- };
4184
- runtime.setContainerOptions(refreshedHostOptions);
4185
- // Independent: config push to the container vs. local entry collection.
4186
- const [, newProjectEntries] = await Promise.all([
4187
- rpcManager.updateHostConfig(refreshedHostOptions),
4188
- collectProjectEntries(context),
4189
- ]);
4190
- const rerunPlan = planWatchRerun({
4191
- projectEntries: newProjectEntries,
4192
- previousTestFiles: watchState.lastTestFiles,
4193
- affectedTestFiles: drainPendingAffectedTestFiles(watchState),
4194
- });
4195
-
4196
- if (rerunPlan.filesChanged) {
4197
- const deletedTestPaths = collectDeletedTestPaths(
4198
- watchState.lastTestFiles,
4199
- rerunPlan.currentTestFiles,
4200
- );
4201
- if (deletedTestPaths.length > 0) {
4202
- context.updateReporterResultState([], [], deletedTestPaths);
4203
- }
4204
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
4205
- currentTestFiles = rerunPlan.currentTestFiles;
4206
- await rpcManager.notifyTestFileUpdate(currentTestFiles);
4207
- if (currentTestFiles.length === 0) {
4208
- logger.log(
4209
- color.cyan('No browser test files remain after update.\n'),
4210
- );
4211
- logWatchReadyMessage(context, enableCliShortcuts);
4212
- return;
4213
- }
4214
- await waitForRunnerFramesReady(
4215
- currentTestFiles.map((file) => file.testPath),
4216
- );
4217
- }
4218
-
4219
- if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
4220
- logger.log(
4221
- color.cyan(
4222
- `Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
4223
- ),
4224
- );
4225
- // Match the headless path: reset per-cycle state before the rerun
4226
- // streams new events, so bail counts and snapshot summaries do not
4227
- // accumulate across headed reruns.
4228
- prepareWatchRerunState(context);
4229
- await notifyTestRunStart();
4230
-
4231
- const rerunStartTime = Date.now();
4232
- const fatalErrorBeforeRun = fatalError;
4233
- let rerunError: Error | undefined;
4234
-
4235
- try {
4236
- for (const testFile of rerunPlan.normalizedAffectedTestFiles) {
4237
- await enqueueHeadedReload(getTestFileInfo(testFile));
4238
- }
4239
- } catch (error) {
4240
- rerunError = toError(error);
4241
- throw error;
4242
- } finally {
4243
- const testTime = Math.max(0, Date.now() - rerunStartTime);
4244
- const rerunFatalError =
4245
- fatalError && fatalError !== fatalErrorBeforeRun
4246
- ? fatalError
4247
- : undefined;
4248
- await finalizeWatchRerun({
4249
- rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4250
- testTime,
4251
- unhandledErrors: rerunError
4252
- ? [rerunError]
4253
- : rerunFatalError
4254
- ? [rerunFatalError]
4255
- : undefined,
4256
- });
4257
- logWatchReadyMessage(context, enableCliShortcuts);
4258
- }
4259
- } else if (!rerunPlan.filesChanged) {
4260
- logger.log(color.cyan('Tests will be re-executed automatically\n'));
4261
- logWatchReadyMessage(context, enableCliShortcuts);
4262
- } else {
4263
- logWatchReadyMessage(context, enableCliShortcuts);
4264
- }
4265
- };
4266
- }
4267
-
4268
- const closeContainerRuntime = !isWatchMode
4269
- ? async () => {
4270
- try {
4271
- await containerPage.close();
4272
- } catch {
4273
- // ignore
4274
- }
4275
- try {
4276
- await containerContext.close();
4277
- } catch {
4278
- // ignore
4279
- }
4280
- await destroyBrowserRuntime(runtime);
4281
- }
4282
- : undefined;
4283
-
4284
- if (fatalError) {
4285
- return failWithError(fatalError, closeContainerRuntime);
4286
- }
4287
-
4288
- const duration = {
4289
- totalTime: buildTime + testTime,
4290
- buildTime,
4291
- testTime,
4292
- };
4293
-
4294
- context.updateReporterResultState(reporterResults, caseResults);
4295
-
4296
- const isFailure = reporterResults.some(
4297
- (result: TestFileResult) => result.status === 'fail',
4298
- );
4299
- // Non-watch runs let core's `finalizeRunCycle` own the exit code from the
4300
- // returned outcome; watch reruns set it here.
4301
- if (isWatchMode && isFailure) {
4302
- ensureProcessExitCode(1);
4303
- }
4304
-
4305
- // Same per-cycle fold-and-strip as the headless path above.
4306
- const cycleCoverageMap =
4307
- isWatchMode && coverageProvider
4308
- ? buildBrowserCoverageMap(reporterResults, coverageProvider)
4309
- : undefined;
4310
-
4311
- const result = {
4312
- results: reporterResults,
4313
- testResults: caseResults,
4314
- duration,
4315
- hasFailure: isFailure,
4316
- getSourcemap: getBrowserSourcemap,
4317
- resolveSourcemap: resolveBrowserSourcemap,
4318
- // `closeContainerRuntime` is already `undefined` in watch mode, so the
4319
- // non-watch caller (core) receives the deferred close and watch does not.
4320
- close: closeContainerRuntime,
4321
- coverage: cycleCoverageMap,
4322
- watch: watchHandles,
4323
- };
4324
-
4325
- if (isWatchMode) {
4326
- try {
4327
- await notifyTestRunEnd({
4328
- duration,
4329
- coverage: cycleCoverageMap?.files().length
4330
- ? cycleCoverageMap.toJSON()
4331
- : undefined,
4332
- });
4333
- } finally {
4334
- await closeContainerRuntime?.();
4335
- }
4336
- }
4337
-
4338
- // Enable watch hooks AFTER initial test run to avoid duplicate runs
4339
- if (isWatchMode && triggerRerun) {
4340
- watchState.hooksEnabled = true;
4341
- logWatchReadyMessage(context, enableCliShortcuts);
4342
- }
4343
-
4344
- return result;
4345
- };
4346
-
4347
- // ============================================================================
4348
- // List Browser Tests
4349
- // ============================================================================
4350
-
4351
- /**
4352
- * Result from collecting browser tests.
4353
- * This is the return type for listBrowserTests, designed for future extraction
4354
- * to a separate browser package.
4355
- */
4356
- export type ListBrowserTestsResult = {
4357
- list: ListCommandResult[];
4358
- close: () => Promise<void>;
4359
- };
4360
-
4361
- /**
4362
- * Collect test metadata from browser mode projects without running them.
4363
- * This function creates a headless browser runtime, loads test files,
4364
- * and collects their test structure (describe/test declarations).
4365
- */
4366
- export const listBrowserTests = async (
4367
- context: RstestContext,
4368
- options?: ListBrowserTestsOptions,
4369
- ): Promise<ListBrowserTestsResult> => {
4370
- const browserProjects = options?.projects ?? getBrowserProjects(context);
4371
- const projectEntries = await resolveProjectEntries(
4372
- context,
4373
- options?.shardedEntries,
4374
- browserProjects,
4375
- );
4376
- const totalTests = projectEntries.reduce(
4377
- (total, item) => total + item.testFiles.length,
4378
- 0,
4379
- );
4380
-
4381
- if (totalTests === 0 && !hasUserRstestConfigPlugins(browserProjects)) {
4382
- return {
4383
- list: [],
4384
- close: async () => {},
4385
- };
4386
- }
4387
-
4388
- const tempDir = join(
4389
- context.rootPath,
4390
- context.normalizedConfig.output.distPath.root,
4391
- 'browser',
4392
- `list-${Date.now()}`,
4393
- );
4394
-
4395
- // Create a simplified browser runtime for collect mode
4396
- let runtime: BrowserRuntime;
4397
- try {
4398
- runtime = await createBrowserRuntime({
4399
- context,
4400
- projectEntries,
4401
- browserProjects,
4402
- shardedEntries: options?.shardedEntries,
4403
- freezeShardedEntries: options?.freezeShardedEntries,
4404
- tempDir,
4405
- isWatchMode: false,
4406
- containerDistPath: undefined,
4407
- containerDevServer: undefined,
4408
- forceHeadless: true, // Always use headless for list command
4409
- skipProviderLaunch: options?.filesOnly,
4410
- appliedModifyRstestConfigEnvironments:
4411
- options?.appliedModifyRstestConfigEnvironments,
4412
- });
4413
- } catch (error) {
4414
- const providers = [
4415
- ...new Set(
4416
- browserProjects.map((p) => p.normalizedConfig.browser.provider),
4417
- ),
4418
- ];
4419
- logger.error(
4420
- color.red(
4421
- `Failed to initialize browser provider runtime (${providers.join(', ')}).`,
4422
- ),
4423
- error,
4424
- );
4425
- throw error;
4426
- }
4427
-
4428
- if (options?.filesOnly) {
4429
- const list = runtime.projectEntries.flatMap((entry) =>
4430
- entry.testFiles.map((testPath) => ({
4431
- testPath,
4432
- project: entry.project.name,
4433
- tests: [],
4434
- })),
4435
- );
4436
- await destroyBrowserRuntime(runtime);
4437
- return {
4438
- list,
4439
- close: async () => {},
4440
- };
4441
- }
4442
-
4443
- if (!runtime.projectEntries.some((entry) => entry.testFiles.length > 0)) {
4444
- await destroyBrowserRuntime(runtime);
4445
- return {
4446
- list: [],
4447
- close: async () => {},
4448
- };
4449
- }
4450
-
4451
- const { browser, browserLaunchOptions } = runtime;
4452
-
4453
- // Get browser projects for runtime config
4454
- // Normalize projectRoot to posix format for cross-platform compatibility
4455
- const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
4456
- (project: ProjectContext) => ({
4457
- name: project.name,
4458
- environmentName: project.environmentName,
4459
- projectRoot: normalize(project.rootPath),
4460
- runtimeConfig: serializableConfig(
4461
- projectRuntimeConfig(project, { envMode: 'static' }),
4462
- ),
4463
- viewport: project.normalizedConfig.browser.viewport,
4464
- }),
4465
- );
4466
-
4467
- const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
4468
-
4469
- const hostOptions: BrowserHostConfig = {
4470
- rootPath: normalize(context.rootPath),
4471
- projects: projectRuntimeConfigs,
4472
- snapshot: {
4473
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
4474
- },
4475
- mode: 'collect', // Use collect mode
4476
- debug: isDebug(),
4477
- rpcTimeout: maxTestTimeoutForRpc,
4478
- };
4479
-
4480
- runtime.setContainerOptions(hostOptions);
4481
-
4482
- // Collect results across every project's isolated dev server. Each server
4483
- // serves only its own project's manifest, so collection navigates one page
4484
- // per project; each page returns its own results, aggregated afterwards.
4485
- const browserContext = await browser.newContext({
4486
- providerOptions: browserLaunchOptions.providerOptions,
4487
- viewport: null,
4488
- });
4489
-
4490
- const serializedOptions = serializeForInlineScript(hostOptions);
4491
-
4492
- // Per-page collect watchdog: a test file whose module evaluation stalls must
4493
- // not hang `rstest list` forever.
4494
- const collectTimeoutMs = 30_000;
4495
-
4496
- const collectFromServer = async (
4497
- server: BrowserProjectServer,
4498
- ): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
4499
- const results: ListCommandResult[] = [];
4500
- let error: Error | null = null;
4501
- let collectCompleted = false;
4502
- let resolveCollect: (() => void) | undefined;
4503
- const collectPromise = new Promise<void>((resolve) => {
4504
- resolveCollect = resolve;
4505
- });
4506
-
4507
- const page = await browserContext.newPage();
4508
-
4509
- // Expose dispatch function for browser client to send messages
4510
- await page.exposeFunction(
4511
- DISPATCH_MESSAGE_TYPE,
4512
- (message: { type: string; payload?: unknown }) => {
4513
- switch (message.type) {
4514
- case 'collect-result': {
4515
- const payload = message.payload as {
4516
- testPath: string;
4517
- project: string;
4518
- tests: Test[];
4519
- };
4520
- results.push({
4521
- testPath: payload.testPath,
4522
- project: payload.project,
4523
- tests: payload.tests,
4524
- });
4525
- break;
4526
- }
4527
- case 'collect-complete':
4528
- collectCompleted = true;
4529
- resolveCollect?.();
4530
- break;
4531
- case 'fatal': {
4532
- const payload = message.payload as {
4533
- message: string;
4534
- stack?: string;
4535
- };
4536
- error = new Error(payload.message);
4537
- error.stack = payload.stack;
4538
- resolveCollect?.();
4539
- break;
4540
- }
4541
- case 'ready':
4542
- case 'log':
4543
- // Ignore these messages during collection
4544
- break;
4545
- default:
4546
- // Log unexpected messages for debugging
4547
- logger.debug(`[List] Unexpected message: ${message.type}`);
4548
- }
4549
- },
4550
- );
4551
-
4552
- // Inject host options before navigation so the runner can access them
4553
- await page.addInitScript(
4554
- `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
4555
- );
4556
-
4557
- // Navigate to this project's runner page
4558
- await page.goto(`http://localhost:${server.port}/runner.html`, {
4559
- waitUntil: 'load',
4560
- });
4561
-
4562
- // Wait for collection to complete with the shared collect timeout.
4563
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
4564
- const timeoutPromise = new Promise<void>((resolve) => {
4565
- timeoutId = setTimeout(() => {
4566
- if (!collectCompleted) {
4567
- logger.warn(
4568
- color.yellow(
4569
- `[List] Browser test collection timed out after ${collectTimeoutMs}ms`,
4570
- ),
4571
- );
4572
- }
4573
- resolve();
4574
- }, collectTimeoutMs);
4575
- });
4576
-
4577
- await Promise.race([collectPromise, timeoutPromise]);
4578
-
4579
- // Clear timeout to prevent Node.js from waiting for it
4580
- if (timeoutId) {
4581
- clearTimeout(timeoutId);
4582
- }
4583
-
4584
- await page.close().catch(() => {});
4585
- return { results, error };
4586
- };
4587
-
4588
- // Collect every project concurrently — each navigates its own page against
4589
- // its own dev server and returns its own results.
4590
- const collected = await Promise.all(
4591
- [...runtime.projectServers.values()].map((server) =>
4592
- collectFromServer(server),
4593
- ),
4594
- );
4595
- const collectResults = collected.flatMap((entry) => entry.results);
4596
- const fatalError = collected.find((entry) => entry.error)?.error ?? null;
4597
-
4598
- // Cleanup
4599
- const cleanup = async () => {
4600
- try {
4601
- await browserContext.close();
4602
- } catch {
4603
- // ignore
4604
- }
4605
- await destroyBrowserRuntime(runtime);
4606
- };
4607
-
4608
- if (fatalError) {
4609
- await cleanup();
4610
- // Return error in the result format instead of throwing
4611
- const errorResult: ListCommandResult = {
4612
- testPath: '',
4613
- project: '',
4614
- tests: [],
4615
- errors: [
4616
- {
4617
- name: 'BrowserCollectError',
4618
- message: fatalError.message,
4619
- stack: fatalError.stack,
4620
- } as FormattedError,
4621
- ],
4622
- };
4623
- return {
4624
- list: [errorResult],
4625
- close: async () => {},
4626
- };
4627
- }
4628
-
4629
- return {
4630
- list: collectResults,
4631
- close: cleanup,
4632
- };
4633
- };