@rstest/browser 0.11.4 → 0.11.6

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 (40) hide show
  1. package/dist/browser-container/container-static/js/683.4f821ce8f1.js +194 -0
  2. package/dist/browser-container/container-static/js/index.568aa7cb35.js +1 -0
  3. package/dist/browser-container/container-static/js/lib-react.e24a1d366b.js +2 -0
  4. package/dist/browser-container/index.html +1 -1
  5. package/dist/browserExecutor.d.ts +7 -5
  6. package/dist/browserRsbuild.d.ts +119 -0
  7. package/dist/containerRpc.d.ts +52 -0
  8. package/dist/dispatchCapabilities.d.ts +1 -2
  9. package/dist/headedScheduler.d.ts +58 -0
  10. package/dist/headlessScheduler.d.ts +37 -0
  11. package/dist/hostController.d.ts +12 -47
  12. package/dist/hostPayloads.d.ts +30 -0
  13. package/dist/index.js +1738 -1793
  14. package/dist/protocol.d.ts +5 -0
  15. package/dist/schedulerSeam.d.ts +37 -0
  16. package/dist/watchRerunPlanner.d.ts +5 -0
  17. package/dist/watchRuntime.d.ts +21 -0
  18. package/dist/watchSignals.d.ts +22 -0
  19. package/package.json +5 -5
  20. package/src/browserExecutor.ts +90 -8
  21. package/src/browserRsbuild.ts +1927 -0
  22. package/src/client/entry.ts +3 -0
  23. package/src/containerRpc.ts +206 -0
  24. package/src/dispatchCapabilities.ts +1 -6
  25. package/src/headedScheduler.ts +664 -0
  26. package/src/headlessScheduler.ts +566 -0
  27. package/src/hostController.ts +855 -4163
  28. package/src/hostPayloads.ts +62 -0
  29. package/src/protocol.ts +6 -0
  30. package/src/schedulerSeam.ts +49 -0
  31. package/src/watchRerunPlanner.ts +18 -7
  32. package/src/watchRuntime.ts +83 -0
  33. package/src/watchSignals.ts +93 -0
  34. package/dist/browser-container/container-static/js/243.a8eed2b9e7.js +0 -27406
  35. package/dist/browser-container/container-static/js/243.a8eed2b9e7.js.LICENSE.txt +0 -1
  36. package/dist/browser-container/container-static/js/index.84aaafaf21.js +0 -3058
  37. package/dist/browser-container/container-static/js/lib-react.62b27a21db.js +0 -8454
  38. package/dist/browser-container/container-static/js/lib-react.62b27a21db.js.LICENSE.txt +0 -1
  39. package/dist/headlessLatestRerunScheduler.d.ts +0 -18
  40. package/src/headlessLatestRerunScheduler.ts +0 -76
@@ -0,0 +1,1927 @@
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
+ color,
12
+ type EntryHashSnapshot,
13
+ getSetupFiles,
14
+ getTestEntries,
15
+ importMetaRstestDefine,
16
+ initModifyRstestConfigHooks,
17
+ isDebug,
18
+ logger,
19
+ loadCoverageProvider,
20
+ pluginMockRuntime,
21
+ type ProjectContext,
22
+ resolveProjectBuildCache,
23
+ resolveShardedEntries,
24
+ RSTEST_ENV_SYMBOL_KEY,
25
+ type RstestContext,
26
+ rsbuild,
27
+ type WatchInvalidationState,
28
+ } from '@rstest/core/internal/browser';
29
+ import openEditor from 'open-editor';
30
+ import { dirname, join, normalize, relative, resolve } from 'pathe';
31
+ import picomatch from 'picomatch';
32
+ import sirv from 'sirv';
33
+ import { WebSocketServer } from 'ws';
34
+ import { validateBrowserConfig } from './configValidation';
35
+ import type { ContainerRpcManager } from './containerRpc';
36
+ import type {
37
+ BrowserDispatchHandler,
38
+ BrowserHostConfig,
39
+ BrowserProjectRuntime,
40
+ BrowserViewport,
41
+ TestFileInfo,
42
+ } from './protocol';
43
+ import type {
44
+ BrowserProvider,
45
+ BrowserProviderBrowser,
46
+ BrowserProviderContext,
47
+ BrowserProviderPage,
48
+ } from './providers';
49
+ import { getBrowserProviderImplementation } from './providers';
50
+ import { resolveBrowserViewportPreset } from './viewportPresets';
51
+ import { collectWatchTestFiles } from './watchRerunPlanner';
52
+
53
+ const { createRsbuild, rspack } = rsbuild;
54
+ type RsbuildDevServer = rsbuild.RsbuildDevServer;
55
+ type RsbuildInstance = rsbuild.RsbuildInstance;
56
+ type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
57
+ Pick<rsbuild.RsbuildConfig, 'root'>;
58
+
59
+ const __dirname = dirname(fileURLToPath(import.meta.url));
60
+ const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
61
+
62
+ export const serializeForInlineScript = (value: unknown): string => {
63
+ return JSON.stringify(value)
64
+ .replace(/</g, '\\u003c')
65
+ .replace(/\u2028/g, '\\u2028')
66
+ .replace(/\u2029/g, '\\u2029');
67
+ };
68
+
69
+ // ============================================================================
70
+ // Type Definitions
71
+
72
+ type BrowserProjectEntries = {
73
+ project: ProjectContext;
74
+ setupFiles: string[];
75
+ testFiles: string[];
76
+ };
77
+
78
+ export type BrowserProviderProject = {
79
+ rootPath: string;
80
+ provider: BrowserProvider;
81
+ };
82
+
83
+ type BrowserLaunchOptions = {
84
+ provider: BrowserProvider;
85
+ browser: ProjectContext['normalizedConfig']['browser']['browser'];
86
+ headless: ProjectContext['normalizedConfig']['browser']['headless'];
87
+ port: ProjectContext['normalizedConfig']['browser']['port'];
88
+ strictPort: ProjectContext['normalizedConfig']['browser']['strictPort'];
89
+ providerOptions: Record<string, unknown>;
90
+ };
91
+
92
+ const getBrowserProviderOptions = (
93
+ project: ProjectContext,
94
+ ): Record<string, unknown> => {
95
+ const browserConfig = project.normalizedConfig.browser as {
96
+ providerOptions?: Record<string, unknown>;
97
+ };
98
+
99
+ return browserConfig.providerOptions ?? {};
100
+ };
101
+
102
+ export type BrowserProjectServer = {
103
+ projectName: string;
104
+ environmentName: string;
105
+ rsbuildInstance: RsbuildInstance;
106
+ devServer: RsbuildDevServer;
107
+ port: number;
108
+ manifestPath: string;
109
+ };
110
+
111
+ // Watch diff/rerun state. Lives on the BrowserRuntime (one per set of
112
+ // per-project compilers, surviving controller re-entry that reuses the
113
+ // runtime) instead of module scope, so its lifetime always matches the
114
+ // compilers whose baselines it holds.
115
+ type BrowserWatchState = {
116
+ lastTestFiles: TestFileInfo[];
117
+ hooksEnabled: boolean;
118
+ // Diff baselines keyed per project: sibling projects have isolated
119
+ // compilers, so a shared flat baseline would let one project's compile
120
+ // clobber another's (missed reruns) and collide on compiler-local chunk
121
+ // keys.
122
+ invalidation: Map<string, WatchInvalidationState>;
123
+ // Affected files accumulated per project until a rerun drains them, so a
124
+ // compile finishing while another project's rerun is being planned cannot
125
+ // drop pending work.
126
+ pendingAffectedTestFiles: Map<string, Set<string>>;
127
+ // Per-project compile start times and the accumulated compile duration of
128
+ // the pending rerun, so the rerun's finalize reports the real buildTime.
129
+ compileStartTimes: Map<string, number>;
130
+ pendingBuildTimeMs: number;
131
+ };
132
+
133
+ const createBrowserWatchState = (): BrowserWatchState => ({
134
+ lastTestFiles: [],
135
+ hooksEnabled: false,
136
+ invalidation: new Map(),
137
+ pendingAffectedTestFiles: new Map(),
138
+ compileStartTimes: new Map(),
139
+ pendingBuildTimeMs: 0,
140
+ });
141
+
142
+ export const drainPendingBuildTime = (
143
+ watchState: BrowserWatchState,
144
+ ): number => {
145
+ const buildTime = watchState.pendingBuildTimeMs;
146
+ watchState.pendingBuildTimeMs = 0;
147
+ return buildTime;
148
+ };
149
+
150
+ export const drainPendingAffectedTestFiles = (
151
+ watchState: BrowserWatchState,
152
+ ): string[] => {
153
+ const affected = new Set<string>();
154
+ for (const files of watchState.pendingAffectedTestFiles.values()) {
155
+ for (const file of files) {
156
+ affected.add(file);
157
+ }
158
+ }
159
+ watchState.pendingAffectedTestFiles.clear();
160
+ return Array.from(affected);
161
+ };
162
+
163
+ export type BrowserRuntime = {
164
+ // Per-project servers, keyed by project name.
165
+ projectServers: Map<string, BrowserProjectServer>;
166
+ // The server that hosts the container UI HTML (headed mode). The WebSocket
167
+ // server below is shared and reachable from any origin.
168
+ containerServer: BrowserProjectServer;
169
+ browser: BrowserProviderBrowser;
170
+ browserLaunchOptions: BrowserLaunchOptions;
171
+ wsPort: number;
172
+ tempDir: string;
173
+ containerPage?: BrowserProviderPage;
174
+ containerContext?: BrowserProviderContext;
175
+ setContainerOptions: (options: BrowserHostConfig) => void;
176
+ // Reserved extension seam for host-side dispatch capabilities.
177
+ dispatchHandlers: Map<string, BrowserDispatchHandler>;
178
+ wss: WebSocketServer;
179
+ rpcManager?: ContainerRpcManager;
180
+ projectEntries: BrowserProjectEntries[];
181
+ watchState: BrowserWatchState;
182
+ };
183
+
184
+ const resolveViewport = (
185
+ viewport: BrowserViewport | undefined,
186
+ ): { width: number; height: number } | null => {
187
+ if (!viewport) {
188
+ return null;
189
+ }
190
+
191
+ if (typeof viewport === 'string') {
192
+ return resolveBrowserViewportPreset(viewport);
193
+ }
194
+
195
+ if (
196
+ typeof viewport.width === 'number' &&
197
+ Number.isFinite(viewport.width) &&
198
+ viewport.width > 0 &&
199
+ typeof viewport.height === 'number' &&
200
+ Number.isFinite(viewport.height) &&
201
+ viewport.height > 0
202
+ ) {
203
+ return {
204
+ width: viewport.width,
205
+ height: viewport.height,
206
+ };
207
+ }
208
+
209
+ return null;
210
+ };
211
+
212
+ export const mapViewportByProject = (
213
+ projects: BrowserProjectRuntime[],
214
+ ): Map<string, { width: number; height: number }> => {
215
+ const map = new Map<string, { width: number; height: number }>();
216
+ for (const project of projects) {
217
+ const viewport = resolveViewport(project.viewport);
218
+ if (viewport) {
219
+ map.set(project.name, viewport);
220
+ }
221
+ }
222
+ return map;
223
+ };
224
+
225
+ const castArray = <T>(arr?: T | T[]): T[] => {
226
+ if (arr === undefined) {
227
+ return [];
228
+ }
229
+ return Array.isArray(arr) ? arr : [arr];
230
+ };
231
+
232
+ const applyDefaultWatchOptions = (
233
+ rspackConfig: Rspack.Configuration,
234
+ isWatchMode: boolean,
235
+ ) => {
236
+ rspackConfig.watchOptions ??= {};
237
+
238
+ if (!isWatchMode) {
239
+ rspackConfig.watchOptions.ignored = '**/**';
240
+ return;
241
+ }
242
+
243
+ rspackConfig.watchOptions.ignored = castArray(
244
+ rspackConfig.watchOptions.ignored || [],
245
+ ) as string[];
246
+
247
+ if (rspackConfig.watchOptions.ignored.length === 0) {
248
+ rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
249
+ }
250
+
251
+ if (rspackConfig.output?.path) {
252
+ rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
253
+ }
254
+ };
255
+
256
+ type LazyCompilationModule = {
257
+ nameForCondition?: () => string | null | undefined;
258
+ };
259
+
260
+ type BrowserLazyCompilationConfig = {
261
+ imports: true;
262
+ entries: false;
263
+ test?: (module: LazyCompilationModule) => boolean;
264
+ };
265
+
266
+ /**
267
+ * Resolve the actual port the dev server is listening on.
268
+ *
269
+ * Rsbuild's `devServer.listen()` may return `0` when configured with
270
+ * `server.port: 0` because its internal `getPort` never reads back the
271
+ * OS-assigned ephemeral port. This helper falls back to
272
+ * `httpServer.address()` to obtain the real bound port.
273
+ */
274
+ const resolveListenPort = (
275
+ listenPort: number,
276
+ httpServer: {
277
+ address: () => ReturnType<import('node:net').Server['address']>;
278
+ } | null,
279
+ ): number => {
280
+ if (listenPort) {
281
+ return listenPort;
282
+ }
283
+ const addr = httpServer?.address();
284
+ if (addr && typeof addr === 'object') {
285
+ return addr.port;
286
+ }
287
+ return listenPort;
288
+ };
289
+
290
+ const createBrowserLazyCompilationConfig = (
291
+ setupFiles: string[],
292
+ ): BrowserLazyCompilationConfig => {
293
+ const eagerSetupFiles = new Set(
294
+ setupFiles.map((filePath) => normalize(filePath)),
295
+ );
296
+
297
+ if (eagerSetupFiles.size === 0) {
298
+ return {
299
+ imports: true,
300
+ entries: false,
301
+ };
302
+ }
303
+
304
+ return {
305
+ imports: true,
306
+ entries: false,
307
+ test(module: LazyCompilationModule) {
308
+ const filePath = module.nameForCondition?.();
309
+ return !filePath || !eagerSetupFiles.has(normalize(filePath));
310
+ },
311
+ };
312
+ };
313
+
314
+ /**
315
+ * HMR — and the lazyCompilation transport it carries — is wired only for headed
316
+ * watch, the sole path that reuses a persistent page and applies module updates
317
+ * in place. Headless always loads each test file in a fresh page (pulling the
318
+ * latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
319
+ * so pushing HMR updates there is dead weight that only races factory
320
+ * registration for chunk-split node_modules (rspack#11922) and lets
321
+ * lazyCompilation's accept-chain walk abort the next spec when no boundary
322
+ * exists (#1472). Disabling HMR does not make watch rebuilds any less
323
+ * incremental — HMR is only the client push transport.
324
+ */
325
+ const shouldEnableBrowserHmr = (
326
+ isWatchMode: boolean,
327
+ isHeadless: boolean,
328
+ ): boolean => isWatchMode && !isHeadless;
329
+
330
+ const createBrowserRsbuildDevConfig = (
331
+ enableHmr: boolean,
332
+ ): {
333
+ writeToDisk: boolean;
334
+ hmr: boolean;
335
+ client: {
336
+ logLevel: 'error';
337
+ };
338
+ } => {
339
+ return {
340
+ writeToDisk: isDebug(),
341
+ // `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
342
+ // path that reuses a page. See that helper for why fresh-page runs (headless,
343
+ // or any one-shot) must not receive HMR pushes.
344
+ hmr: enableHmr,
345
+ client: {
346
+ logLevel: 'error' as const,
347
+ },
348
+ };
349
+ };
350
+
351
+ /**
352
+ * Convert a single glob pattern to RegExp using picomatch
353
+ * Based on Storybook's implementation
354
+ */
355
+ const globToRegexp = (glob: string): RegExp => {
356
+ const regex = picomatch.makeRe(glob, {
357
+ fastpaths: false,
358
+ noglobstar: false,
359
+ bash: false,
360
+ dot: true,
361
+ });
362
+
363
+ if (!regex) {
364
+ throw new Error(`Invalid glob pattern: ${glob}`);
365
+ }
366
+
367
+ // picomatch generates regex starting with ^
368
+ // For patterns starting with ./, we need special handling
369
+ if (!glob.startsWith('./')) {
370
+ return regex;
371
+ }
372
+
373
+ // makeRe is sort of funny. If you pass it a directory starting with `./` it
374
+ // creates a matcher that expects files with no prefix (e.g. `src/file.js`)
375
+ // but if you pass it a directory that starts with `../` it expects files that
376
+ // start with `../`. Let's make it consistent.
377
+ // Globs starting `**` need special treatment due to the regex they produce
378
+ return new RegExp(
379
+ [
380
+ '^\\.',
381
+ glob.startsWith('./**') ? '' : '[\\\\/]',
382
+ regex.source.substring(1),
383
+ ].join(''),
384
+ );
385
+ };
386
+
387
+ /**
388
+ * Convert rstest include glob patterns to RegExp for import.meta.webpackContext
389
+ * Uses picomatch for robust glob-to-regexp conversion
390
+ */
391
+ const globPatternsToRegExp = (patterns: string[]): RegExp => {
392
+ const regexParts = patterns.map((pattern) => {
393
+ const regex = globToRegexp(pattern);
394
+ // Remove ^ anchor and $ anchor to allow combining patterns
395
+ let source = regex.source;
396
+ if (source.startsWith('^')) {
397
+ source = source.substring(1);
398
+ }
399
+ if (source.endsWith('$')) {
400
+ source = source.substring(0, source.length - 1);
401
+ }
402
+ return source;
403
+ });
404
+
405
+ return new RegExp(`(?:${regexParts.join('|')})$`);
406
+ };
407
+
408
+ const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
409
+ const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
410
+ const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
411
+
412
+ const escapeRegExp = (value: string): string =>
413
+ value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
414
+
415
+ const normalizePathForRegExp = (value: string): string =>
416
+ normalize(value).replaceAll('\\', '/');
417
+
418
+ const normalizeExcludePatternForRegExp = (value: string): string =>
419
+ value.startsWith('./')
420
+ ? `./${normalizePathForRegExp(value.substring(2))}`
421
+ : normalizePathForRegExp(value);
422
+
423
+ const isAbsolutePatternForRegExp = (value: string): boolean =>
424
+ value.startsWith('/') || /^[A-Za-z]:\//.test(value);
425
+
426
+ const isEscapedRegExpCharacter = (source: string, index: number): boolean => {
427
+ let backslashCount = 0;
428
+ for (
429
+ let current = index - 1;
430
+ current >= 0 && source[current] === '\\';
431
+ current--
432
+ ) {
433
+ backslashCount++;
434
+ }
435
+ return backslashCount % 2 === 1;
436
+ };
437
+
438
+ const replacePathSeparatorsInRegExpSource = (source: string): string => {
439
+ let result = '';
440
+ let inCharacterClass = false;
441
+
442
+ for (let index = 0; index < source.length; index++) {
443
+ const character = source[index];
444
+ const isEscaped = isEscapedRegExpCharacter(source, index);
445
+
446
+ if (character === '[' && !isEscaped) {
447
+ inCharacterClass = true;
448
+ }
449
+
450
+ if (!inCharacterClass && character === '\\' && source[index + 1] === '/') {
451
+ result += PATH_SEPARATOR_SOURCE;
452
+ index++;
453
+ continue;
454
+ }
455
+
456
+ result += character;
457
+
458
+ if (character === ']' && !isEscaped) {
459
+ inCharacterClass = false;
460
+ }
461
+ }
462
+
463
+ return result;
464
+ };
465
+
466
+ type BrowserContextExcludeSource = {
467
+ relative: string;
468
+ absolute: string;
469
+ isAbsolute: boolean;
470
+ };
471
+
472
+ const createRelativeContextExcludeSource = (
473
+ source: string,
474
+ normalizedPattern: string,
475
+ ): string => {
476
+ if (normalizedPattern.startsWith('./')) {
477
+ return source;
478
+ }
479
+
480
+ return normalizedPattern.startsWith('**/')
481
+ ? `(?:(?:${source})|\\.(?:${source}))`
482
+ : `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
483
+ };
484
+
485
+ const createProjectAbsoluteExcludeSource = (
486
+ source: string,
487
+ normalizedPattern: string,
488
+ ): string =>
489
+ normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/')
490
+ ? source
491
+ : `${PATH_SEPARATOR_SOURCE}(?:${source})`;
492
+
493
+ /**
494
+ * Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
495
+ * This is used at compile time to filter out files during bundling
496
+ *
497
+ * Example:
498
+ * Input: ['**\/node_modules\/**', '**\/dist\/**']
499
+ * Output: a regexp matching node_modules or dist path segments.
500
+ */
501
+ const excludePatternsToRegExpSources = (
502
+ patterns: string[],
503
+ ): BrowserContextExcludeSource[] | null => {
504
+ const sources = patterns.map((pattern) => {
505
+ const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
506
+ const regex = globToRegexp(normalizedPattern);
507
+ let source = regex.source;
508
+ if (source.startsWith('^')) {
509
+ source = source.substring(1);
510
+ }
511
+ if (source.endsWith('$')) {
512
+ source = source.substring(0, source.length - 1);
513
+ }
514
+
515
+ source = replacePathSeparatorsInRegExpSource(source);
516
+ const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
517
+ const absolute = normalizedPattern.startsWith('./')
518
+ ? source.substring(2)
519
+ : source;
520
+
521
+ return {
522
+ relative: isAbsolute
523
+ ? source
524
+ : createRelativeContextExcludeSource(source, normalizedPattern),
525
+ absolute: isAbsolute
526
+ ? absolute
527
+ : createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
528
+ isAbsolute,
529
+ };
530
+ });
531
+
532
+ if (sources.length === 0) {
533
+ return null;
534
+ }
535
+
536
+ return sources;
537
+ };
538
+
539
+ export const createBrowserContextExcludeRegExp = (
540
+ patterns: string[],
541
+ projectRoot: string,
542
+ ): RegExp | null => {
543
+ const excludeSources = excludePatternsToRegExpSources(patterns);
544
+ if (!excludeSources) {
545
+ return null;
546
+ }
547
+
548
+ const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(
549
+ /[\\/]$/,
550
+ '',
551
+ );
552
+ const projectRootSource = normalizedProjectRoot
553
+ .split('/')
554
+ .map(escapeRegExp)
555
+ .join(PATH_SEPARATOR_SOURCE);
556
+ const relativeExcludeSources = excludeSources.filter(
557
+ (source) => !source.isAbsolute,
558
+ );
559
+ const absoluteExcludeSources = excludeSources.filter(
560
+ (source) => source.isAbsolute,
561
+ );
562
+ const sourceBranches: string[] = [];
563
+
564
+ if (relativeExcludeSources.length > 0) {
565
+ const relativePatternSource = `(?:${relativeExcludeSources
566
+ .map((source) => source.relative)
567
+ .join('|')})`;
568
+ const absolutePatternSource = `(?:${relativeExcludeSources
569
+ .map((source) => source.absolute)
570
+ .join('|')})`;
571
+ const relativeSource = `(?:${relativePatternSource})`;
572
+ const absoluteSource = normalizedProjectRoot
573
+ ? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})`
574
+ : `(?:${absolutePatternSource})`;
575
+
576
+ sourceBranches.push(
577
+ `(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`,
578
+ absoluteSource,
579
+ );
580
+ }
581
+
582
+ if (absoluteExcludeSources.length > 0) {
583
+ sourceBranches.push(
584
+ `(?:${absoluteExcludeSources
585
+ .map((source) => source.relative)
586
+ .join('|')})`,
587
+ );
588
+ }
589
+
590
+ return new RegExp(`^(?:${sourceBranches.join('|')})$`);
591
+ };
592
+
593
+ type StatsModule = {
594
+ nameForCondition?: string;
595
+ children?: StatsModule[];
596
+ };
597
+
598
+ type StatsChunk = {
599
+ id?: string | number;
600
+ names?: string[];
601
+ hash?: string;
602
+ files?: string[];
603
+ modules?: StatsModule[];
604
+ };
605
+
606
+ /**
607
+ * Find test file path from chunk modules by matching against known entry files.
608
+ */
609
+ const findTestFileInModules = (
610
+ modules: StatsModule[] | undefined,
611
+ entryTestFiles: Set<string>,
612
+ ): string | null => {
613
+ if (!modules) return null;
614
+
615
+ for (const m of modules) {
616
+ if (m.nameForCondition) {
617
+ const normalizedPath = normalize(m.nameForCondition);
618
+ if (entryTestFiles.has(normalizedPath)) {
619
+ return normalizedPath;
620
+ }
621
+ }
622
+ if (m.children) {
623
+ const found = findTestFileInModules(m.children, entryTestFiles);
624
+ if (found) return found;
625
+ }
626
+ }
627
+ return null;
628
+ };
629
+
630
+ /**
631
+ * Get a stable identifier for a chunk.
632
+ * Prefers chunk.id or chunk.names[0] over file paths for stability.
633
+ */
634
+ const getChunkKey = (chunk: StatsChunk): string | null => {
635
+ if (chunk.id != null) {
636
+ return String(chunk.id);
637
+ }
638
+ if (chunk.names && chunk.names.length > 0) {
639
+ return chunk.names[0]!;
640
+ }
641
+ if (chunk.files && chunk.files.length > 0) {
642
+ return chunk.files[0]!;
643
+ }
644
+ return null;
645
+ };
646
+
647
+ /**
648
+ * Fold one project compile's chunks into per-entry hash snapshots and apply
649
+ * the shared watch-invalidation policy against that project's baseline.
650
+ * Chunks are attributed to a test/setup file by scanning their modules; the
651
+ * chunk.id/names key is only the hash-record key, never a cross-project one.
652
+ */
653
+ const getAffectedTestFiles = ({
654
+ chunks,
655
+ entryTestFiles,
656
+ setupFiles,
657
+ state,
658
+ }: {
659
+ chunks: StatsChunk[] | undefined;
660
+ entryTestFiles: Set<string>;
661
+ setupFiles: Set<string>;
662
+ state: WatchInvalidationState;
663
+ }): string[] => {
664
+ const entryHashes: EntryHashSnapshot = new Map();
665
+ const setupHashes: EntryHashSnapshot = new Map();
666
+
667
+ const recordChunk = (
668
+ snapshot: EntryHashSnapshot,
669
+ entryPath: string,
670
+ chunkKey: string,
671
+ hash: string,
672
+ ) => {
673
+ const record = snapshot.get(entryPath) ?? {};
674
+ record[chunkKey] = hash;
675
+ snapshot.set(entryPath, record);
676
+ };
677
+
678
+ for (const chunk of chunks || []) {
679
+ if (!chunk.hash) continue;
680
+
681
+ const chunkKey = getChunkKey(chunk);
682
+ if (!chunkKey) continue;
683
+
684
+ const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
685
+ if (testFile) {
686
+ recordChunk(entryHashes, testFile, chunkKey, chunk.hash);
687
+ continue;
688
+ }
689
+
690
+ const setupFile = findTestFileInModules(chunk.modules, setupFiles);
691
+ if (setupFile) {
692
+ recordChunk(setupHashes, setupFile, chunkKey, chunk.hash);
693
+ }
694
+ }
695
+
696
+ // Headed watch compiles chunks on demand (lazyCompilation), so an entry's
697
+ // first appearance in stats means "just loaded", not "just added": its first
698
+ // sighting establishes the baseline instead of marking a change. Genuinely
699
+ // new and deleted test files are owned by the test-file-set diff in
700
+ // `planWatchRerun` / `collectDeletedTestPaths`.
701
+ const seedFirstSeen = (
702
+ baseline: EntryHashSnapshot | undefined,
703
+ current: EntryHashSnapshot,
704
+ ) => {
705
+ if (!baseline) return;
706
+ for (const [entryPath, record] of current) {
707
+ if (!baseline.has(entryPath)) {
708
+ baseline.set(entryPath, record);
709
+ }
710
+ }
711
+ };
712
+ seedFirstSeen(state.entryHashes, entryHashes);
713
+ seedFirstSeen(state.setupHashes, setupHashes);
714
+
715
+ const outcome = applyWatchInvalidation(state, { entryHashes, setupHashes });
716
+
717
+ if (outcome.rerunAll) {
718
+ logger.debug(
719
+ '[Watch] Setup file changed, re-running all test files of the project',
720
+ );
721
+ return Array.from(entryTestFiles);
722
+ }
723
+
724
+ for (const affected of outcome.affectedPaths) {
725
+ logger.debug(`[Watch] Chunk hash changed for test: ${affected}`);
726
+ }
727
+
728
+ return outcome.affectedPaths;
729
+ };
730
+
731
+ export const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
732
+ context.projects.filter(
733
+ (project) => project.normalizedConfig.browser.enabled,
734
+ );
735
+
736
+ const getBrowserRsbuildEnvironmentConfig = (
737
+ project: ProjectContext,
738
+ ): RsbuildEnvironmentConfig => ({
739
+ plugins: project.normalizedConfig.plugins,
740
+ root: project.rootPath,
741
+ });
742
+
743
+ // Max testTimeout across browser projects, used as the host->client RPC timeout.
744
+
745
+ const getBrowserLaunchOptions = (
746
+ project: ProjectContext,
747
+ ): BrowserLaunchOptions => ({
748
+ provider: project.normalizedConfig.browser.provider,
749
+ browser: project.normalizedConfig.browser.browser,
750
+ headless: project.normalizedConfig.browser.headless,
751
+ port: project.normalizedConfig.browser.port,
752
+ strictPort: project.normalizedConfig.browser.strictPort,
753
+ providerOptions: getBrowserProviderOptions(project),
754
+ });
755
+
756
+ const ensureConsistentBrowserLaunchOptions = (
757
+ projects: ProjectContext[],
758
+ ): BrowserLaunchOptions => {
759
+ if (projects.length === 0) {
760
+ throw new Error('No browser-enabled projects found.');
761
+ }
762
+
763
+ const firstProject = projects[0]!;
764
+ const firstOptions = getBrowserLaunchOptions(firstProject);
765
+
766
+ for (const project of projects.slice(1)) {
767
+ const options = getBrowserLaunchOptions(project);
768
+ // Each browser project now runs on its own rsbuild dev server, so ports may
769
+ // differ per project. Only the shared single Playwright browser forces
770
+ // provider/browser/headless/providerOptions to match across projects.
771
+ if (
772
+ options.provider !== firstOptions.provider ||
773
+ options.browser !== firstOptions.browser ||
774
+ options.headless !== firstOptions.headless ||
775
+ !isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
776
+ ) {
777
+ throw new Error(
778
+ `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
779
+ 'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
780
+ );
781
+ }
782
+ }
783
+
784
+ return firstOptions;
785
+ };
786
+
787
+ export const collectProjectEntries = async (
788
+ context: RstestContext,
789
+ // The explicit browser-project subset the executor was constructed with. Falls
790
+ // back to re-deriving from `context` for internal callers (e.g. the watch
791
+ // plugin) that do not carry the plan's project list.
792
+ browserProjects: ProjectContext[] = getBrowserProjects(context),
793
+ ): Promise<BrowserProjectEntries[]> => {
794
+ return Promise.all(
795
+ browserProjects.map(async (project) => {
796
+ const {
797
+ normalizedConfig: { include, exclude, includeSource, setupFiles },
798
+ } = project;
799
+
800
+ const tests = await getTestEntries({
801
+ include,
802
+ exclude: exclude.patterns,
803
+ includeSource,
804
+ rootPath: context.rootPath,
805
+ projectRoot: project.rootPath,
806
+ fileFilters: context.fileFilters || [],
807
+ fileFilterMode: context.fileFilterMode,
808
+ });
809
+
810
+ const setup = getSetupFiles(setupFiles, project.rootPath);
811
+
812
+ return {
813
+ project,
814
+ setupFiles: Object.values(setup),
815
+ testFiles: Object.values(tests),
816
+ };
817
+ }),
818
+ );
819
+ };
820
+
821
+ const resolveBrowserFile = (relativePath: string): string => {
822
+ // __dirname points to packages/browser/dist when running from built code
823
+ // or packages/browser/src when running from source
824
+ const candidates = [
825
+ // When running from built dist: look in ../src for source files
826
+ resolve(__dirname, '../src', relativePath),
827
+ // When running from source (dev mode)
828
+ resolve(__dirname, relativePath),
829
+ ];
830
+
831
+ for (const candidate of candidates) {
832
+ if (existsSync(candidate)) {
833
+ return candidate;
834
+ }
835
+ }
836
+
837
+ throw new Error(`Unable to resolve browser client file: ${relativePath}`);
838
+ };
839
+
840
+ export const resolveContainerDist = (): string => {
841
+ // When running from built dist: browser-container is in the same dist folder
842
+ const distPath = resolve(__dirname, 'browser-container');
843
+ if (existsSync(distPath)) {
844
+ return distPath;
845
+ }
846
+
847
+ throw new Error(
848
+ `Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`,
849
+ );
850
+ };
851
+
852
+ // ============================================================================
853
+ // Manifest Generation
854
+ // ============================================================================
855
+
856
+ /**
857
+ * Format environment name to a valid JavaScript identifier.
858
+ * Replaces non-alphanumeric characters with underscores.
859
+ */
860
+ const toSafeVarName = (name: string): string => {
861
+ return name.replace(/[^a-zA-Z0-9_]/g, '_');
862
+ };
863
+
864
+ // Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
865
+ // `./<path-relative-to-project-root>` with forward slashes. The runtime derives
866
+ // the same key from the target test file, so the non-watch import map below must
867
+ // key by the identical form for `loadTest(key)` to resolve.
868
+ export const toContextKey = (
869
+ filePath: string,
870
+ projectRootPosix: string,
871
+ ): string => {
872
+ const posixPath = normalize(filePath);
873
+ // Only strip the root at a path boundary: a bare `startsWith` would mangle a
874
+ // sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
875
+ const withinRoot =
876
+ posixPath === projectRootPosix ||
877
+ posixPath.startsWith(`${projectRootPosix}/`);
878
+ if (!withinRoot) {
879
+ // Test file outside the project root: use the absolute path as the key so
880
+ // the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
881
+ // key would be re-rooted under projectRoot and point at a nonexistent file.
882
+ return posixPath;
883
+ }
884
+ const rel = posixPath.slice(projectRootPosix.length);
885
+ return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
886
+ };
887
+
888
+ const generateManifestModule = ({
889
+ manifestPath,
890
+ entries,
891
+ isWatchMode,
892
+ }: {
893
+ manifestPath: string;
894
+ entries: BrowserProjectEntries[];
895
+ isWatchMode: boolean;
896
+ }): string => {
897
+ const manifestDirPosix = normalize(dirname(manifestPath));
898
+
899
+ const toRelativeImport = (filePath: string): string => {
900
+ const posixPath = normalize(filePath);
901
+ let relativePath = relative(manifestDirPosix, posixPath);
902
+ if (!relativePath.startsWith('.')) {
903
+ relativePath = `./${relativePath}`;
904
+ }
905
+ return relativePath;
906
+ };
907
+
908
+ const lines: string[] = [];
909
+
910
+ // 1. Export all projects configuration
911
+ lines.push('// All projects configuration');
912
+ lines.push('export const projects = [');
913
+ for (const { project } of entries) {
914
+ lines.push(' {');
915
+ lines.push(` name: ${JSON.stringify(project.name)},`);
916
+ lines.push(
917
+ ` environmentName: ${JSON.stringify(project.environmentName)},`,
918
+ );
919
+ lines.push(
920
+ ` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
921
+ );
922
+ lines.push(' },');
923
+ }
924
+ lines.push('];');
925
+ lines.push('');
926
+
927
+ // 2. Setup loaders for each project
928
+ lines.push('// Setup loaders for each project');
929
+ lines.push('export const projectSetupLoaders = {');
930
+ for (const { project, setupFiles } of entries) {
931
+ lines.push(` ${JSON.stringify(project.name)}: [`);
932
+ for (const filePath of setupFiles) {
933
+ const relativePath = toRelativeImport(filePath);
934
+ lines.push(` () => import(${JSON.stringify(relativePath)}),`);
935
+ }
936
+ lines.push(' ],');
937
+ }
938
+ lines.push('};');
939
+ lines.push('');
940
+
941
+ // 3. Test context for each project. Both branches expose the same shape as a
942
+ // webpackContext (callable by key, plus `keys()`), consumed in section 4.
943
+ lines.push('// Test context for each project');
944
+ for (const { project, testFiles } of entries) {
945
+ const varName = `context_${toSafeVarName(project.environmentName)}`;
946
+ const projectRootPosix = normalize(project.rootPath);
947
+
948
+ if (isWatchMode) {
949
+ // Watch mode keeps the include-glob context so newly added files are
950
+ // picked up on rebuild via `keys()` without regenerating the manifest.
951
+ // `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
952
+ // works with or without lazyCompilation; headed watch additionally layers
953
+ // lazyCompilation on top to keep the initial build cheap.
954
+ const includeRegExp = globPatternsToRegExp(
955
+ project.normalizedConfig.include,
956
+ );
957
+ const excludeRegExp = createBrowserContextExcludeRegExp(
958
+ project.normalizedConfig.exclude.patterns,
959
+ projectRootPosix,
960
+ );
961
+ const { includeSource } = project.normalizedConfig;
962
+ const emitContext = (contextVarName: string, regExp: RegExp): void => {
963
+ lines.push(
964
+ `const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
965
+ );
966
+ lines.push(' recursive: true,');
967
+ lines.push(` regExp: ${regExp.toString()},`);
968
+ if (excludeRegExp) {
969
+ lines.push(` exclude: ${excludeRegExp.toString()},`);
970
+ }
971
+ lines.push(" mode: 'lazy',");
972
+ lines.push('});');
973
+ };
974
+
975
+ if (includeSource.length === 0) {
976
+ emitContext(varName, includeRegExp);
977
+ } else {
978
+ // In-source test files (`includeSource`) carry an
979
+ // `if (import.meta.rstest)` block. The include context can't see them,
980
+ // so a second context over the `includeSource` globs backs
981
+ // host-scheduled loads, while `keys()` only unions the entry-probed
982
+ // in-source files (the probe does not apply inside the bundle, so raw
983
+ // source-context keys would execute never-probed files and fail with
984
+ // "No test suites found"). The probed list can go stale until a
985
+ // manifest refresh; scheduled-by-path loading never does.
986
+ emitContext(`${varName}_include`, includeRegExp);
987
+ emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
988
+ const probedKeys = testFiles.map((filePath) =>
989
+ toContextKey(filePath, projectRootPosix),
990
+ );
991
+ lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
992
+ lines.push(
993
+ `const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
994
+ );
995
+ lines.push(`const ${varName} = Object.assign(`);
996
+ lines.push(
997
+ ` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
998
+ );
999
+ lines.push(' {');
1000
+ lines.push(
1001
+ ` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
1002
+ );
1003
+ lines.push(' },');
1004
+ lines.push(');');
1005
+ }
1006
+ } else {
1007
+ // One-shot runs: the file set is fixed and already filtered, so emit an
1008
+ // explicit lazy-import map (one chunk per literal `import()`, like the
1009
+ // setup loaders above). The eager, non-lazyCompilation build then compiles
1010
+ // only the run set instead of every included test file.
1011
+ lines.push(`const ${varName}_modules = {`);
1012
+ for (const filePath of testFiles) {
1013
+ const key = toContextKey(filePath, projectRootPosix);
1014
+ const importPath = toRelativeImport(filePath);
1015
+ lines.push(
1016
+ ` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
1017
+ );
1018
+ }
1019
+ lines.push('};');
1020
+ lines.push(
1021
+ `const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
1022
+ );
1023
+ lines.push(` keys: () => Object.keys(${varName}_modules),`);
1024
+ lines.push('});');
1025
+ }
1026
+ lines.push('');
1027
+ }
1028
+
1029
+ // 4. Export test contexts object
1030
+ lines.push('export const projectTestContexts = {');
1031
+ for (const { project } of entries) {
1032
+ const varName = `context_${toSafeVarName(project.environmentName)}`;
1033
+ lines.push(` ${JSON.stringify(project.name)}: {`);
1034
+ lines.push(` getTestKeys: () => ${varName}.keys(),`);
1035
+ lines.push(` loadTest: (key) => ${varName}(key),`);
1036
+ lines.push(
1037
+ ` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
1038
+ );
1039
+ lines.push(' },');
1040
+ }
1041
+ lines.push('};');
1042
+ lines.push('');
1043
+
1044
+ // 5. Backward compatibility exports (use first project as default)
1045
+ lines.push('// Backward compatibility: export first project as default');
1046
+ lines.push('export const projectConfig = projects[0];');
1047
+ lines.push(
1048
+ 'export const setupLoaders = projectSetupLoaders[projects[0].name] || [];',
1049
+ );
1050
+ lines.push('const _defaultCtx = projectTestContexts[projects[0].name];');
1051
+ lines.push(
1052
+ 'export const getTestKeys = () => _defaultCtx ? _defaultCtx.getTestKeys() : [];',
1053
+ );
1054
+ lines.push(
1055
+ 'export const loadTest = (key) => _defaultCtx ? _defaultCtx.loadTest(key) : Promise.reject(new Error("No project found"));',
1056
+ );
1057
+
1058
+ return `${lines.join('\n')}\n`;
1059
+ };
1060
+
1061
+ const htmlTemplate = `<!DOCTYPE html>
1062
+ <html lang="en">
1063
+ <head>
1064
+ <meta charset="UTF-8" />
1065
+ <title>Rstest Browser Runner</title>
1066
+ </head>
1067
+ <body>
1068
+ <script type="module" src="/static/js/runner.js"></script>
1069
+ </body>
1070
+ </html>
1071
+ `;
1072
+
1073
+ // Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
1074
+ // Rsbuild suppresses the removed-file log if all removed paths include "virtual":
1075
+ // https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
1076
+ const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1077
+
1078
+ // ============================================================================
1079
+ // Browser Runtime Lifecycle
1080
+ // ============================================================================
1081
+
1082
+ const closeAllProjectServers = (
1083
+ servers: Iterable<BrowserProjectServer>,
1084
+ ): Promise<unknown> =>
1085
+ Promise.allSettled([...servers].map((server) => server.devServer.close()));
1086
+
1087
+ // Copy a proxied fetch Response's status + headers onto the Node response,
1088
+ // dropping content-length (the body is re-sent, so the original length may not
1089
+ // match).
1090
+ const copyProxyResponseHeaders = (
1091
+ response: Response,
1092
+ res: ServerResponse,
1093
+ ): void => {
1094
+ res.statusCode = response.status;
1095
+ response.headers.forEach((value, key) => {
1096
+ if (key.toLowerCase() === 'content-length') {
1097
+ return;
1098
+ }
1099
+ res.setHeader(key, value);
1100
+ });
1101
+ };
1102
+
1103
+ export const destroyBrowserRuntime = async (
1104
+ runtime: BrowserRuntime,
1105
+ ): Promise<void> => {
1106
+ try {
1107
+ await runtime.browser?.close?.();
1108
+ } catch {
1109
+ // ignore
1110
+ }
1111
+ await closeAllProjectServers(runtime.projectServers.values());
1112
+ try {
1113
+ runtime.wss?.close();
1114
+ } catch {
1115
+ // ignore
1116
+ }
1117
+ await fs
1118
+ .rm(runtime.tempDir, { recursive: true, force: true })
1119
+ .catch(() => {});
1120
+ };
1121
+
1122
+ export const createBrowserRuntime = async ({
1123
+ context,
1124
+ projectEntries: initialProjectEntries,
1125
+ browserProjects,
1126
+ shardedEntries,
1127
+ freezeShardedEntries,
1128
+ tempDir,
1129
+ isWatchMode,
1130
+ onTriggerRerun,
1131
+ containerDistPath,
1132
+ containerDevServer,
1133
+ forceHeadless,
1134
+ skipProviderLaunch,
1135
+ appliedModifyRstestConfigEnvironments,
1136
+ }: {
1137
+ context: RstestContext;
1138
+ projectEntries: BrowserProjectEntries[];
1139
+ /**
1140
+ * The explicit browser-project subset (plan output). Drives launch-option
1141
+ * consistency and the container origin (`browserProjects[0]`).
1142
+ */
1143
+ browserProjects: ProjectContext[];
1144
+ shardedEntries?: Map<string, { entries: Record<string, string> }>;
1145
+ freezeShardedEntries?: boolean;
1146
+ tempDir: string;
1147
+ isWatchMode: boolean;
1148
+ onTriggerRerun?: () => Promise<void>;
1149
+ containerDistPath?: string;
1150
+ containerDevServer?: string;
1151
+ /** Force headless mode regardless of user config (used for list command) */
1152
+ forceHeadless?: boolean;
1153
+ skipProviderLaunch?: boolean;
1154
+ appliedModifyRstestConfigEnvironments?: Set<string>;
1155
+ }): Promise<BrowserRuntime> => {
1156
+ // ---- Shared singletons (created once, wired into every project server) ----
1157
+ const containerHtmlTemplate = containerDistPath
1158
+ ? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
1159
+ : null;
1160
+
1161
+ let injectedContainerHtml: string | null = null;
1162
+ let serializedOptions = 'null';
1163
+ // Reserved extension seam for future browser-side capabilities.
1164
+ const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
1165
+
1166
+ const setContainerOptions = (options: BrowserHostConfig): void => {
1167
+ serializedOptions = serializeForInlineScript(options);
1168
+ if (containerHtmlTemplate) {
1169
+ injectedContainerHtml = containerHtmlTemplate.replace(
1170
+ OPTIONS_PLACEHOLDER,
1171
+ serializedOptions,
1172
+ );
1173
+ }
1174
+ };
1175
+
1176
+ let browserLaunchOptions =
1177
+ ensureConsistentBrowserLaunchOptions(browserProjects);
1178
+ let projectEntries = initialProjectEntries;
1179
+ // Created with the runtime so the per-project watch plugins and the
1180
+ // controller's rerun closures share one state whose lifetime matches the
1181
+ // compilers holding the diffed chunks.
1182
+ const watchState = createBrowserWatchState();
1183
+ const manifestModules: Array<{
1184
+ manifestPath: string;
1185
+ project: ProjectContext;
1186
+ modules: Record<string, string>;
1187
+ }> = [];
1188
+
1189
+ const createRuntimeWithoutProvider = (): BrowserRuntime => {
1190
+ const firstProject = browserProjects[0]!;
1191
+ return {
1192
+ projectServers: new Map(),
1193
+ containerServer: {
1194
+ projectName: firstProject.name,
1195
+ environmentName: firstProject.environmentName,
1196
+ rsbuildInstance: undefined as unknown as RsbuildInstance,
1197
+ devServer: {
1198
+ close: async () => undefined,
1199
+ } as RsbuildDevServer,
1200
+ port: 0,
1201
+ manifestPath: '',
1202
+ },
1203
+ browser: undefined as unknown as BrowserProviderBrowser,
1204
+ browserLaunchOptions,
1205
+ wsPort: 0,
1206
+ tempDir,
1207
+ setContainerOptions,
1208
+ dispatchHandlers,
1209
+ wss: undefined as unknown as WebSocketServer,
1210
+ projectEntries,
1211
+ watchState,
1212
+ };
1213
+ };
1214
+
1215
+ const getProjectEntry = (project: ProjectContext) =>
1216
+ projectEntries.find(
1217
+ (item) => item.project.environmentName === project.environmentName,
1218
+ );
1219
+
1220
+ const refreshManifestModule = (manifestModule: {
1221
+ manifestPath: string;
1222
+ project: ProjectContext;
1223
+ modules: Record<string, string>;
1224
+ }): void => {
1225
+ const entry = getProjectEntry(manifestModule.project);
1226
+ manifestModule.modules[manifestModule.manifestPath] =
1227
+ generateManifestModule({
1228
+ manifestPath: manifestModule.manifestPath,
1229
+ entries: [
1230
+ {
1231
+ project: manifestModule.project,
1232
+ testFiles: entry?.testFiles ?? [],
1233
+ setupFiles: entry?.setupFiles ?? [],
1234
+ },
1235
+ ],
1236
+ isWatchMode,
1237
+ });
1238
+ };
1239
+
1240
+ const refreshProjectEntries = async (): Promise<void> => {
1241
+ validateBrowserConfig(context);
1242
+ browserLaunchOptions =
1243
+ ensureConsistentBrowserLaunchOptions(browserProjects);
1244
+ const updatedShardedEntries = freezeShardedEntries
1245
+ ? shardedEntries
1246
+ : context.normalizedConfig.shard
1247
+ ? await resolveShardedEntries(context, { silent: true })
1248
+ : shardedEntries;
1249
+ projectEntries = await resolveProjectEntries(
1250
+ context,
1251
+ updatedShardedEntries,
1252
+ browserProjects,
1253
+ );
1254
+ for (const manifestModule of manifestModules) {
1255
+ refreshManifestModule(manifestModule);
1256
+ }
1257
+ };
1258
+
1259
+ // Rstest internal aliases that must not be overridden by user config
1260
+ const browserRuntimePath = fileURLToPath(
1261
+ import.meta.resolve('@rstest/core/internal/browser-runtime'),
1262
+ );
1263
+
1264
+ // Shared by every project — only the per-project `@rstest/browser-manifest`
1265
+ // alias varies (one virtual manifest per server).
1266
+ const staticRstestAliases = {
1267
+ // User test code `import { describe, it } from '@rstest/core'` is NOT
1268
+ // aliased: `applyWebMockRspackConfig` keeps the request external against
1269
+ // `globalThis['@rstest/core']` (node parity), which also keeps the mock
1270
+ // hoister's provider-import ordering correct for `rs.hoisted` callbacks.
1271
+ // User test code: import { page } from '@rstest/browser'
1272
+ '@rstest/browser': resolveBrowserFile('browser.ts'),
1273
+ // Browser runtime APIs for entry.ts
1274
+ // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
1275
+ '@rstest/core/internal/browser-runtime': browserRuntimePath,
1276
+ };
1277
+
1278
+ // rspack `define` replaces `process.env` / `import.meta.env` with this literal
1279
+ // expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
1280
+ // text, so the owned key can never drift from the runtime
1281
+ // `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
1282
+ const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
1283
+ RSTEST_ENV_SYMBOL_KEY,
1284
+ )})]`;
1285
+
1286
+ // Serve prebuilt container assets (SPA) via sirv (container origin only)
1287
+ const serveContainer = containerDistPath
1288
+ ? sirv(containerDistPath, {
1289
+ dev: false,
1290
+ single: 'index.html',
1291
+ })
1292
+ : null;
1293
+
1294
+ const containerDevBase = containerDevServer
1295
+ ? new URL(containerDevServer)
1296
+ : null;
1297
+
1298
+ const respondWithDevServerHtml = async (
1299
+ url: URL,
1300
+ res: ServerResponse,
1301
+ ): Promise<boolean> => {
1302
+ if (!containerDevBase) {
1303
+ return false;
1304
+ }
1305
+
1306
+ try {
1307
+ const target = new URL(url.pathname + url.search, containerDevBase);
1308
+ const response = await fetch(target);
1309
+ if (!response.ok) {
1310
+ return false;
1311
+ }
1312
+
1313
+ let html = await response.text();
1314
+ html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1315
+
1316
+ copyProxyResponseHeaders(response, res);
1317
+ res.setHeader('Content-Type', 'text/html');
1318
+ res.end(html);
1319
+ return true;
1320
+ } catch (error) {
1321
+ logger.debug(
1322
+ `[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
1323
+ );
1324
+ return false;
1325
+ }
1326
+ };
1327
+
1328
+ const proxyDevServerAsset = async (
1329
+ req: IncomingMessage,
1330
+ res: ServerResponse,
1331
+ ): Promise<boolean> => {
1332
+ if (!containerDevBase || !req.url) {
1333
+ return false;
1334
+ }
1335
+
1336
+ try {
1337
+ const target = new URL(req.url, containerDevBase);
1338
+ const response = await fetch(target);
1339
+ if (!response.ok) {
1340
+ return false;
1341
+ }
1342
+
1343
+ const buffer = Buffer.from(await response.arrayBuffer());
1344
+ copyProxyResponseHeaders(response, res);
1345
+ res.end(buffer);
1346
+ return true;
1347
+ } catch (error) {
1348
+ logger.debug(
1349
+ `[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
1350
+ );
1351
+ return false;
1352
+ }
1353
+ };
1354
+
1355
+ const serveContainerRoute = async (
1356
+ req: IncomingMessage,
1357
+ res: ServerResponse,
1358
+ next: () => void,
1359
+ ): Promise<void> => {
1360
+ if (!req.url) {
1361
+ next();
1362
+ return;
1363
+ }
1364
+
1365
+ const url = new URL(req.url, 'http://localhost');
1366
+ if (url.pathname === '/') {
1367
+ if (await respondWithDevServerHtml(url, res)) {
1368
+ return;
1369
+ }
1370
+
1371
+ const html =
1372
+ injectedContainerHtml ||
1373
+ containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1374
+
1375
+ if (html) {
1376
+ res.setHeader('Content-Type', 'text/html');
1377
+ res.end(html);
1378
+ return;
1379
+ }
1380
+
1381
+ res.statusCode = 502;
1382
+ res.end('Container UI is not available.');
1383
+ return;
1384
+ }
1385
+
1386
+ if (url.pathname.startsWith('/container-static/')) {
1387
+ if (await proxyDevServerAsset(req, res)) {
1388
+ return;
1389
+ }
1390
+
1391
+ if (serveContainer) {
1392
+ serveContainer(req, res, next);
1393
+ return;
1394
+ }
1395
+
1396
+ res.statusCode = 502;
1397
+ res.end('Container assets are not available.');
1398
+ return;
1399
+ }
1400
+
1401
+ next();
1402
+ };
1403
+
1404
+ // ---- Build one isolated rsbuild instance + dev server per project ----
1405
+ const buildProjectServer = async (
1406
+ project: ProjectContext,
1407
+ isContainerServer: boolean,
1408
+ ): Promise<BrowserProjectServer> => {
1409
+ const manifestPath = join(
1410
+ tempDir,
1411
+ toSafeVarName(project.environmentName),
1412
+ VIRTUAL_MANIFEST_FILENAME,
1413
+ );
1414
+ const entry = getProjectEntry(project);
1415
+ const virtualManifestModules = {
1416
+ [manifestPath]: generateManifestModule({
1417
+ manifestPath,
1418
+ entries: [
1419
+ {
1420
+ project,
1421
+ testFiles: entry?.testFiles ?? [],
1422
+ setupFiles: entry?.setupFiles ?? [],
1423
+ },
1424
+ ],
1425
+ isWatchMode,
1426
+ }),
1427
+ };
1428
+ const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
1429
+ virtualManifestModules,
1430
+ );
1431
+ manifestModules.push({
1432
+ manifestPath,
1433
+ project,
1434
+ modules: virtualManifestModules,
1435
+ });
1436
+
1437
+ const rstestInternalAliases = {
1438
+ '@rstest/browser-manifest': manifestPath,
1439
+ ...staticRstestAliases,
1440
+ };
1441
+
1442
+ const isHeadless =
1443
+ forceHeadless || project.normalizedConfig.browser.headless;
1444
+ const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
1445
+
1446
+ const rsbuildInstance = await createRsbuild({
1447
+ callerName: 'rstest',
1448
+ rsbuildConfig: {
1449
+ root: context.rootPath,
1450
+ mode: 'development',
1451
+ server: {
1452
+ printUrls: false,
1453
+ // Each project gets its own dev server. Honor an explicitly
1454
+ // configured port; otherwise keep the historical 4000 default for the
1455
+ // container server and let the OS assign free ports for the rest, so
1456
+ // multiple projects never collide on one port.
1457
+ port:
1458
+ project.normalizedConfig.browser.port ??
1459
+ (isContainerServer ? 4000 : 0),
1460
+ strictPort: project.normalizedConfig.browser.strictPort,
1461
+ // User plugins may emit index.html; register before Rsbuild's HTML
1462
+ // completion middleware so `/` remains owned by the Browser UI.
1463
+ setup: isContainerServer
1464
+ ? ({ server }) => {
1465
+ server.middlewares.use(serveContainerRoute);
1466
+ }
1467
+ : undefined,
1468
+ },
1469
+ dev: createBrowserRsbuildDevConfig(enableHmr),
1470
+ environments: {
1471
+ [project.environmentName]:
1472
+ getBrowserRsbuildEnvironmentConfig(project),
1473
+ },
1474
+ },
1475
+ });
1476
+
1477
+ initModifyRstestConfigHooks(
1478
+ context,
1479
+ rsbuildInstance,
1480
+ [project],
1481
+ [project],
1482
+ {
1483
+ getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
1484
+ onModifyRstestConfigApplied: refreshProjectEntries,
1485
+ appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
1486
+ },
1487
+ );
1488
+
1489
+ // Add plugin to merge user Rsbuild config with rstest required config
1490
+ rsbuildInstance.addPlugins([
1491
+ // Same mock runtime as the node build (importActual doppelganger rule +
1492
+ // mock webpack runtime module); order-insensitive and self-contained.
1493
+ pluginMockRuntime,
1494
+ {
1495
+ name: 'rstest:browser-user-config',
1496
+ setup(api) {
1497
+ // Internal extension entry: register host dispatch handlers without
1498
+ // coupling scheduling to individual capability implementations.
1499
+ (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1500
+ 'rstest:browser',
1501
+ {
1502
+ registerDispatchHandler: (
1503
+ namespace: string,
1504
+ handler: BrowserDispatchHandler,
1505
+ ) => {
1506
+ dispatchHandlers.set(namespace, handler);
1507
+ },
1508
+ },
1509
+ );
1510
+
1511
+ api.modifyEnvironmentConfig({
1512
+ handler: (config, { mergeEnvironmentConfig, name }) => {
1513
+ if (name !== project.environmentName) {
1514
+ return config;
1515
+ }
1516
+
1517
+ const userRsbuildConfig = project.normalizedConfig;
1518
+ const buildCache = resolveProjectBuildCache({
1519
+ context,
1520
+ project,
1521
+ });
1522
+ const setupFiles = Object.values(
1523
+ getSetupFiles(
1524
+ project.normalizedConfig.setupFiles,
1525
+ project.rootPath,
1526
+ ),
1527
+ );
1528
+ // Merge order: current config -> userConfig -> rstest required config (highest priority)
1529
+ const merged = mergeEnvironmentConfig(
1530
+ config,
1531
+ {
1532
+ ...userRsbuildConfig,
1533
+ performance: buildCache
1534
+ ? {
1535
+ ...userRsbuildConfig.performance,
1536
+ buildCache,
1537
+ }
1538
+ : userRsbuildConfig.performance,
1539
+ },
1540
+ {
1541
+ resolve: {
1542
+ alias: rstestInternalAliases,
1543
+ },
1544
+ source: {
1545
+ define: {
1546
+ 'process.env': rstestEnvDefine,
1547
+ 'import.meta.env': rstestEnvDefine,
1548
+ // In-source `if (import.meta.rstest)` blocks read the
1549
+ // per-file runtime API the client entry publishes on
1550
+ // `globalThis` (node parity: `global['@rstest/core']`).
1551
+ 'import.meta.rstest': importMetaRstestDefine('web'),
1552
+ },
1553
+ },
1554
+ output: {
1555
+ target: 'web',
1556
+ // Enable source map for inline snapshot support
1557
+ sourceMap: {
1558
+ js: 'source-map',
1559
+ },
1560
+ // Every project server compiles the same asset names
1561
+ // (`static/js/runner.js`, ...). With `dev.writeToDisk`
1562
+ // (debug mode) the middleware serves from disk, so a
1563
+ // shared dist dir would be last-writer-wins and one
1564
+ // project's server would deliver another project's
1565
+ // bundle — keep each project's output isolated, inside
1566
+ // the run's temp dir so teardown removes it.
1567
+ distPath: {
1568
+ root: join(
1569
+ tempDir,
1570
+ 'server',
1571
+ toSafeVarName(project.environmentName),
1572
+ ),
1573
+ },
1574
+ },
1575
+ tools: {
1576
+ swc: (swcConfig) => {
1577
+ // Fixture dependency discovery reads callback parameters
1578
+ // through Function#toString(). Playwright's supported
1579
+ // browsers all support parameter destructuring, so keep
1580
+ // that syntax intact in the browser test bundle.
1581
+ swcConfig.env ??= {};
1582
+ swcConfig.env.exclude = Array.from(
1583
+ new Set([
1584
+ ...(swcConfig.env.exclude ?? []),
1585
+ 'transform-parameters',
1586
+ ]),
1587
+ );
1588
+ },
1589
+ rspack: (rspackConfig) => {
1590
+ rspackConfig.mode = 'development';
1591
+ // Web parameterization of the node mock transform:
1592
+ // RstestPlugin (hoist + path injection), the
1593
+ // `@rstest/core` global external, and
1594
+ // `exportsPresence: 'warn'`.
1595
+ applyWebMockRspackConfig(rspackConfig, {
1596
+ rspack,
1597
+ rootPath: project.rootPath,
1598
+ });
1599
+ // lazyCompilation's only delivery transport is the HMR
1600
+ // runtime, so it follows the same gate as HMR (see
1601
+ // `shouldEnableBrowserHmr`): headed watch only, everything
1602
+ // else compiles eagerly.
1603
+ rspackConfig.lazyCompilation = enableHmr
1604
+ ? createBrowserLazyCompilationConfig(setupFiles)
1605
+ : false;
1606
+ rspackConfig.plugins = rspackConfig.plugins || [];
1607
+ rspackConfig.plugins.push(virtualManifestPlugin);
1608
+
1609
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
1610
+
1611
+ // Extract and merge sourcemaps from pre-built @rstest/core files
1612
+ // This preserves the sourcemap chain for inline snapshot support
1613
+ // See: https://rspack.rs/config/module-rules#rulesextractsourcemap
1614
+ const browserRuntimeDir = dirname(browserRuntimePath);
1615
+ rspackConfig.module = rspackConfig.module || {};
1616
+ rspackConfig.module.rules =
1617
+ rspackConfig.module.rules || [];
1618
+ rspackConfig.module.rules.unshift({
1619
+ test: /\.js$/,
1620
+ include: browserRuntimeDir,
1621
+ extractSourceMap: true,
1622
+ });
1623
+
1624
+ if (isDebug()) {
1625
+ logger.log(
1626
+ `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1627
+ );
1628
+ }
1629
+ },
1630
+ },
1631
+ },
1632
+ );
1633
+
1634
+ // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1635
+ // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
1636
+ // This must be done after mergeEnvironmentConfig to ensure highest priority.
1637
+ merged.source = merged.source || {};
1638
+ merged.source.entry = {
1639
+ runner: resolveBrowserFile('client/entry.ts'),
1640
+ };
1641
+
1642
+ return merged;
1643
+ },
1644
+ // Execute after all other plugins to ensure rstest's entry config has the highest priority
1645
+ order: 'post',
1646
+ });
1647
+ },
1648
+ },
1649
+ ]);
1650
+
1651
+ // Register watch plugin if in watch mode
1652
+ if (isWatchMode && onTriggerRerun) {
1653
+ rsbuildInstance.addPlugins([
1654
+ {
1655
+ name: 'rstest:browser-watch',
1656
+ setup(api) {
1657
+ api.onBeforeDevCompile(() => {
1658
+ watchState.compileStartTimes.set(project.name, Date.now());
1659
+ if (!watchState.hooksEnabled) {
1660
+ return;
1661
+ }
1662
+ logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1663
+ });
1664
+
1665
+ api.onAfterDevCompile(async ({ stats }) => {
1666
+ const compileStart = watchState.compileStartTimes.get(
1667
+ project.name,
1668
+ );
1669
+ if (compileStart !== undefined) {
1670
+ watchState.compileStartTimes.delete(project.name);
1671
+ // Only change-triggered compiles feed the pending rerun's
1672
+ // build phase (the initial build is the initial run's
1673
+ // buildTime). Parallel project compiles overlap; the longest
1674
+ // one bounds the rerun's build phase.
1675
+ if (watchState.hooksEnabled) {
1676
+ watchState.pendingBuildTimeMs = Math.max(
1677
+ watchState.pendingBuildTimeMs,
1678
+ Date.now() - compileStart,
1679
+ );
1680
+ }
1681
+ }
1682
+ // Collect hashes even during initial build to establish baseline
1683
+ if (stats) {
1684
+ // This compiler only ever holds this project's entries; the
1685
+ // diff baseline is keyed per project accordingly.
1686
+ const [projectEntry] = await collectProjectEntries(context, [
1687
+ project,
1688
+ ]);
1689
+ const entryTestFiles = new Set<string>(
1690
+ collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
1691
+ (file) => file.testPath,
1692
+ ),
1693
+ );
1694
+ const setupFiles = new Set<string>(
1695
+ (projectEntry?.setupFiles ?? []).map((file) =>
1696
+ normalize(file),
1697
+ ),
1698
+ );
1699
+
1700
+ let state = watchState.invalidation.get(project.name);
1701
+ if (!state) {
1702
+ state = {};
1703
+ watchState.invalidation.set(project.name, state);
1704
+ }
1705
+
1706
+ const statsJson = stats.toJson({ all: true });
1707
+ const affected = getAffectedTestFiles({
1708
+ chunks: statsJson.chunks,
1709
+ entryTestFiles,
1710
+ setupFiles,
1711
+ state,
1712
+ });
1713
+
1714
+ if (affected.length > 0) {
1715
+ const pending =
1716
+ watchState.pendingAffectedTestFiles.get(project.name) ??
1717
+ new Set<string>();
1718
+ for (const file of affected) {
1719
+ pending.add(file);
1720
+ }
1721
+ watchState.pendingAffectedTestFiles.set(
1722
+ project.name,
1723
+ pending,
1724
+ );
1725
+ logger.debug(
1726
+ `[Watch] Affected test files: ${affected.join(', ')}`,
1727
+ );
1728
+ }
1729
+ }
1730
+
1731
+ if (!watchState.hooksEnabled) {
1732
+ return;
1733
+ }
1734
+
1735
+ await onTriggerRerun();
1736
+ });
1737
+ },
1738
+ },
1739
+ ]);
1740
+ }
1741
+
1742
+ if (skipProviderLaunch) {
1743
+ await rsbuildInstance.initConfigs({ action: 'dev' });
1744
+ return {
1745
+ projectName: project.name,
1746
+ environmentName: project.environmentName,
1747
+ rsbuildInstance,
1748
+ devServer: {
1749
+ close: async () => undefined,
1750
+ } as RsbuildDevServer,
1751
+ port: 0,
1752
+ manifestPath,
1753
+ };
1754
+ }
1755
+
1756
+ // Register coverage plugin if this project enables coverage
1757
+ const coverage = project.normalizedConfig.coverage;
1758
+ if (coverage?.enabled && context.command !== 'list') {
1759
+ const { pluginCoverage } = await loadCoverageProvider(
1760
+ coverage,
1761
+ context.rootPath,
1762
+ );
1763
+ rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
1764
+ }
1765
+
1766
+ const devServer = await rsbuildInstance.createDevServer({
1767
+ getPortSilently: true,
1768
+ });
1769
+
1770
+ if (isDebug()) {
1771
+ await rsbuildInstance.inspectConfig({
1772
+ writeToDisk: true,
1773
+ // The server's own distPath is isolated per project inside the run's
1774
+ // temp dir (removed at teardown); keep the debug artifacts at the
1775
+ // project's stable dist root so they survive the run and stay where
1776
+ // the docs point users to.
1777
+ outputPath: resolve(
1778
+ context.rootPath,
1779
+ context.normalizedConfig.output.distPath.root,
1780
+ '.rsbuild',
1781
+ ),
1782
+ extraConfigs: {
1783
+ rstest: {
1784
+ ...context.normalizedConfig,
1785
+ projects: [project.normalizedConfig],
1786
+ },
1787
+ },
1788
+ });
1789
+ }
1790
+
1791
+ devServer.middlewares.use(
1792
+ async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
1793
+ if (!req.url) {
1794
+ next();
1795
+ return;
1796
+ }
1797
+ const url = new URL(req.url, 'http://localhost');
1798
+ if (url.pathname === '/__open-in-editor') {
1799
+ const file = url.searchParams.get('file');
1800
+ if (!file) {
1801
+ res.statusCode = 400;
1802
+ res.end('Missing file');
1803
+ return;
1804
+ }
1805
+ try {
1806
+ await openEditor([{ file }]);
1807
+ res.statusCode = 204;
1808
+ res.end();
1809
+ } catch (error) {
1810
+ logger.debug(
1811
+ `[Browser UI] Failed to open editor: ${String(error)}`,
1812
+ );
1813
+ res.statusCode = 500;
1814
+ res.end('Failed to open editor');
1815
+ }
1816
+ return;
1817
+ }
1818
+ if (url.pathname === '/runner.html') {
1819
+ res.setHeader('Content-Type', 'text/html');
1820
+ res.end(htmlTemplate);
1821
+ return;
1822
+ }
1823
+ next();
1824
+ },
1825
+ );
1826
+
1827
+ const { port: listenPort } = await devServer.listen();
1828
+ const port = resolveListenPort(listenPort, devServer.httpServer);
1829
+
1830
+ return {
1831
+ projectName: project.name,
1832
+ environmentName: project.environmentName,
1833
+ rsbuildInstance,
1834
+ devServer,
1835
+ port,
1836
+ manifestPath,
1837
+ };
1838
+ };
1839
+
1840
+ // Build each project's server sequentially. Servers must bind ports one at a
1841
+ // time: projects may share a configured port and rely on strictPort:false
1842
+ // bumping to the next free one, which races under concurrent listen().
1843
+ const projectServers = new Map<string, BrowserProjectServer>();
1844
+ try {
1845
+ for (const [index, project] of browserProjects.entries()) {
1846
+ const server = await buildProjectServer(project, index === 0);
1847
+ projectServers.set(server.projectName, server);
1848
+ }
1849
+ } catch (error) {
1850
+ await closeAllProjectServers(projectServers.values());
1851
+ throw error;
1852
+ }
1853
+
1854
+ if (skipProviderLaunch) {
1855
+ return createRuntimeWithoutProvider();
1856
+ }
1857
+
1858
+ // browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
1859
+ // otherwise) and index 0 is the designated container origin.
1860
+ const containerServer = projectServers.get(browserProjects[0]!.name)!;
1861
+
1862
+ // Create WebSocket server on an available port
1863
+ // Using port: 0 lets the OS assign an available port, avoiding conflicts
1864
+ // when the fixed port (e.g., container port + 1) is already in use
1865
+ const wss = new WebSocketServer({ port: 0 });
1866
+ await new Promise<void>((resolve, reject) => {
1867
+ wss.once('listening', resolve);
1868
+ wss.once('error', reject);
1869
+ });
1870
+ const wsPort = (wss.address() as AddressInfo).port;
1871
+ logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
1872
+
1873
+ const browserName = browserLaunchOptions.browser ?? 'chromium';
1874
+ try {
1875
+ const providerImplementation = getBrowserProviderImplementation(
1876
+ browserLaunchOptions.provider,
1877
+ );
1878
+ const runtime = await providerImplementation.launchRuntime({
1879
+ browserName,
1880
+ headless: forceHeadless ?? browserLaunchOptions.headless,
1881
+ providerOptions: browserLaunchOptions.providerOptions,
1882
+ });
1883
+ return {
1884
+ projectServers,
1885
+ containerServer,
1886
+ browser: runtime.browser,
1887
+ browserLaunchOptions,
1888
+ wsPort,
1889
+ tempDir,
1890
+ setContainerOptions,
1891
+ dispatchHandlers,
1892
+ wss,
1893
+ projectEntries,
1894
+ watchState,
1895
+ };
1896
+ } catch (error) {
1897
+ wss.close();
1898
+ await closeAllProjectServers(projectServers.values());
1899
+ throw error;
1900
+ }
1901
+ };
1902
+
1903
+ export async function resolveProjectEntries(
1904
+ context: RstestContext,
1905
+ shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
1906
+ browserProjects: ProjectContext[],
1907
+ ): Promise<BrowserProjectEntries[]> {
1908
+ if (shardedEntries) {
1909
+ const projectEntries: BrowserProjectEntries[] = [];
1910
+ for (const project of browserProjects) {
1911
+ const entryInfo = shardedEntries.get(project.environmentName);
1912
+ if (entryInfo && Object.keys(entryInfo.entries).length > 0) {
1913
+ const setup = getSetupFiles(
1914
+ project.normalizedConfig.setupFiles,
1915
+ project.rootPath,
1916
+ );
1917
+ projectEntries.push({
1918
+ project,
1919
+ setupFiles: Object.values(setup),
1920
+ testFiles: Object.values(entryInfo.entries),
1921
+ });
1922
+ }
1923
+ }
1924
+ return projectEntries;
1925
+ }
1926
+ return collectProjectEntries(context, browserProjects);
1927
+ }