@rstest/browser 0.7.9

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.
@@ -0,0 +1,1761 @@
1
+ import { existsSync } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ color,
7
+ type FormattedError,
8
+ getSetupFiles,
9
+ getTestEntries,
10
+ isDebug,
11
+ type ListCommandResult,
12
+ logger,
13
+ type ProjectContext,
14
+ type Reporter,
15
+ type Rstest,
16
+ type RuntimeConfig,
17
+ rsbuild,
18
+ serializableConfig,
19
+ TEMP_RSTEST_OUTPUT_DIR,
20
+ type Test,
21
+ type TestFileResult,
22
+ type TestResult,
23
+ type UserConsoleLog,
24
+ } from '@rstest/core/browser';
25
+ import { type BirpcReturn, createBirpc } from 'birpc';
26
+ import openEditor from 'open-editor';
27
+ import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
28
+ import * as picomatch from 'picomatch';
29
+ import type { BrowserContext, ConsoleMessage, Page } from 'playwright';
30
+ import sirv from 'sirv';
31
+ import { type WebSocket, WebSocketServer } from 'ws';
32
+ import type {
33
+ BrowserHostConfig,
34
+ BrowserProjectRuntime,
35
+ TestFileInfo,
36
+ } from './protocol';
37
+
38
+ const { createRsbuild, rspack } = rsbuild;
39
+ type RsbuildDevServer = rsbuild.RsbuildDevServer;
40
+ type RsbuildInstance = rsbuild.RsbuildInstance;
41
+
42
+ const __dirname = dirname(fileURLToPath(import.meta.url));
43
+
44
+ // ============================================================================
45
+ // Type Definitions
46
+ // ============================================================================
47
+
48
+ type VirtualModulesPluginInstance = InstanceType<
49
+ (typeof rspack.experiments)['VirtualModulesPlugin']
50
+ >;
51
+
52
+ type PlaywrightModule = typeof import('playwright');
53
+ type BrowserType = PlaywrightModule['chromium'];
54
+ type BrowserInstance = Awaited<ReturnType<BrowserType['launch']>>;
55
+
56
+ type BrowserProjectEntries = {
57
+ project: ProjectContext;
58
+ setupFiles: string[];
59
+ testFiles: string[];
60
+ };
61
+
62
+ /** Payload for test file start event */
63
+ type TestFileStartPayload = {
64
+ testPath: string;
65
+ projectName: string;
66
+ };
67
+
68
+ /** Payload for log event */
69
+ type LogPayload = {
70
+ level: 'log' | 'warn' | 'error' | 'info' | 'debug';
71
+ content: string;
72
+ testPath: string;
73
+ type: 'stdout' | 'stderr';
74
+ trace?: string;
75
+ };
76
+
77
+ /** Payload for fatal error event */
78
+ type FatalPayload = {
79
+ message: string;
80
+ stack?: string;
81
+ };
82
+
83
+ /** RPC methods exposed by the host (server) to the container (client) */
84
+ type HostRpcMethods = {
85
+ rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
86
+ getTestFiles: () => Promise<TestFileInfo[]>;
87
+ // Test result callbacks from container
88
+ onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
89
+ onTestCaseResult: (payload: TestResult) => Promise<void>;
90
+ onTestFileComplete: (payload: TestFileResult) => Promise<void>;
91
+ onLog: (payload: LogPayload) => Promise<void>;
92
+ onFatal: (payload: FatalPayload) => Promise<void>;
93
+ // Snapshot file operations (for browser mode snapshot support)
94
+ resolveSnapshotPath: (testPath: string) => Promise<string>;
95
+ readSnapshotFile: (filepath: string) => Promise<string | null>;
96
+ saveSnapshotFile: (filepath: string, content: string) => Promise<void>;
97
+ removeSnapshotFile: (filepath: string) => Promise<void>;
98
+ };
99
+
100
+ /** RPC methods exposed by the container (client) to the host (server) */
101
+ type ContainerRpcMethods = {
102
+ onTestFileUpdate: (testFiles: TestFileInfo[]) => Promise<void>;
103
+ reloadTestFile: (testFile: string, testNamePattern?: string) => Promise<void>;
104
+ };
105
+
106
+ type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
107
+
108
+ // ============================================================================
109
+ // RPC Manager - Encapsulates WebSocket and birpc management
110
+ // ============================================================================
111
+
112
+ /**
113
+ * Manages the WebSocket connection and birpc communication with the container UI.
114
+ * Provides a clean interface for sending RPC calls and handling connections.
115
+ */
116
+ class ContainerRpcManager {
117
+ private wss: WebSocketServer;
118
+ private ws: WebSocket | null = null;
119
+ private rpc: ContainerRpc | null = null;
120
+ private methods: HostRpcMethods;
121
+
122
+ constructor(wss: WebSocketServer, methods: HostRpcMethods) {
123
+ this.wss = wss;
124
+ this.methods = methods;
125
+ this.setupConnectionHandler();
126
+ }
127
+
128
+ /** Update the RPC methods (used when starting a new test run) */
129
+ updateMethods(methods: HostRpcMethods): void {
130
+ this.methods = methods;
131
+ // Re-create birpc with new methods if already connected
132
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
133
+ this.attachWebSocket(this.ws);
134
+ }
135
+ }
136
+
137
+ private setupConnectionHandler(): void {
138
+ this.wss.on('connection', (ws: WebSocket) => {
139
+ logger.log(color.gray('[Browser UI] Container WebSocket connected'));
140
+ logger.log(
141
+ color.gray(
142
+ `[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`,
143
+ ),
144
+ );
145
+ this.attachWebSocket(ws);
146
+ });
147
+ }
148
+
149
+ private attachWebSocket(ws: WebSocket): void {
150
+ this.ws = ws;
151
+
152
+ this.rpc = createBirpc<ContainerRpcMethods, HostRpcMethods>(this.methods, {
153
+ post: (data) => {
154
+ if (ws.readyState === ws.OPEN) {
155
+ ws.send(JSON.stringify(data));
156
+ }
157
+ },
158
+ on: (fn) => {
159
+ ws.on('message', (message) => {
160
+ try {
161
+ const data = JSON.parse(message.toString());
162
+ fn(data);
163
+ } catch {
164
+ // ignore invalid messages
165
+ }
166
+ });
167
+ },
168
+ });
169
+
170
+ ws.on('close', () => {
171
+ // Only clear if this is still the active connection
172
+ // This prevents a race condition when a new connection is established
173
+ // before the old one's close event fires
174
+ if (this.ws === ws) {
175
+ this.ws = null;
176
+ this.rpc = null;
177
+ }
178
+ });
179
+ }
180
+
181
+ /** Check if a container is currently connected */
182
+ get isConnected(): boolean {
183
+ return this.ws !== null && this.ws.readyState === this.ws.OPEN;
184
+ }
185
+
186
+ /** Get the current WebSocket instance (for reuse in watch mode) */
187
+ get currentWebSocket(): WebSocket | null {
188
+ return this.ws;
189
+ }
190
+
191
+ /** Reattach an existing WebSocket (for watch mode reuse) */
192
+ reattach(ws: WebSocket): void {
193
+ this.attachWebSocket(ws);
194
+ }
195
+
196
+ /** Notify container of test file changes */
197
+ async notifyTestFileUpdate(files: TestFileInfo[]): Promise<void> {
198
+ await this.rpc?.onTestFileUpdate(files);
199
+ }
200
+
201
+ /** Request container to reload a specific test file */
202
+ async reloadTestFile(
203
+ testFile: string,
204
+ testNamePattern?: string,
205
+ ): Promise<void> {
206
+ logger.log(
207
+ color.gray(
208
+ `[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
209
+ ),
210
+ );
211
+ if (!this.rpc) {
212
+ logger.log(
213
+ color.yellow('[Browser UI] RPC not available, skipping reloadTestFile'),
214
+ );
215
+ return;
216
+ }
217
+ logger.log(color.gray(`[Browser UI] Calling reloadTestFile: ${testFile}`));
218
+ await this.rpc.reloadTestFile(testFile, testNamePattern);
219
+ }
220
+ }
221
+
222
+ // ============================================================================
223
+ // Browser Runtime - Core runtime state
224
+ // ============================================================================
225
+
226
+ type BrowserRuntime = {
227
+ rsbuildInstance: RsbuildInstance;
228
+ devServer: RsbuildDevServer;
229
+ browser: BrowserInstance;
230
+ port: number;
231
+ wsPort: number;
232
+ manifestPath: string;
233
+ tempDir: string;
234
+ manifestPlugin: VirtualModulesPluginInstance;
235
+ containerPage?: Page;
236
+ containerContext?: BrowserContext;
237
+ setContainerOptions: (options: BrowserHostConfig) => void;
238
+ wss: WebSocketServer;
239
+ rpcManager?: ContainerRpcManager;
240
+ };
241
+
242
+ // ============================================================================
243
+ // Watch Mode Context - Encapsulates all watch mode state
244
+ // ============================================================================
245
+
246
+ type WatchContext = {
247
+ runtime: BrowserRuntime | null;
248
+ lastTestFiles: TestFileInfo[];
249
+ hooksEnabled: boolean;
250
+ cleanupRegistered: boolean;
251
+ };
252
+
253
+ const watchContext: WatchContext = {
254
+ runtime: null,
255
+ lastTestFiles: [],
256
+ hooksEnabled: false,
257
+ cleanupRegistered: false,
258
+ };
259
+
260
+ // ============================================================================
261
+ // Utility Functions
262
+ // ============================================================================
263
+
264
+ const ensureProcessExitCode = (code: number): void => {
265
+ if (process.exitCode === undefined || process.exitCode === 0) {
266
+ process.exitCode = code;
267
+ }
268
+ };
269
+
270
+ /**
271
+ * Convert a single glob pattern to RegExp using picomatch
272
+ * Based on Storybook's implementation
273
+ */
274
+ const globToRegexp = (glob: string): RegExp => {
275
+ const regex = picomatch.makeRe(glob, {
276
+ fastpaths: false,
277
+ noglobstar: false,
278
+ bash: false,
279
+ });
280
+
281
+ if (!regex) {
282
+ throw new Error(`Invalid glob pattern: ${glob}`);
283
+ }
284
+
285
+ // picomatch generates regex starting with ^
286
+ // For patterns starting with ./, we need special handling
287
+ if (!glob.startsWith('./')) {
288
+ return regex;
289
+ }
290
+
291
+ // makeRe is sort of funny. If you pass it a directory starting with `./` it
292
+ // creates a matcher that expects files with no prefix (e.g. `src/file.js`)
293
+ // but if you pass it a directory that starts with `../` it expects files that
294
+ // start with `../`. Let's make it consistent.
295
+ // Globs starting `**` need special treatment due to the regex they produce
296
+ return new RegExp(
297
+ [
298
+ '^\\.',
299
+ glob.startsWith('./**') ? '' : '[\\\\/]',
300
+ regex.source.substring(1),
301
+ ].join(''),
302
+ );
303
+ };
304
+
305
+ /**
306
+ * Convert rstest include glob patterns to RegExp for import.meta.webpackContext
307
+ * Uses picomatch for robust glob-to-regexp conversion
308
+ */
309
+ const globPatternsToRegExp = (patterns: string[]): RegExp => {
310
+ const regexParts = patterns.map((pattern) => {
311
+ const regex = globToRegexp(pattern);
312
+ // Remove ^ anchor and $ anchor to allow combining patterns
313
+ let source = regex.source;
314
+ if (source.startsWith('^')) {
315
+ source = source.substring(1);
316
+ }
317
+ if (source.endsWith('$')) {
318
+ source = source.substring(0, source.length - 1);
319
+ }
320
+ return source;
321
+ });
322
+
323
+ return new RegExp(`(?:${regexParts.join('|')})$`);
324
+ };
325
+
326
+ /**
327
+ * Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
328
+ * This is used at compile time to filter out files during bundling
329
+ *
330
+ * Example:
331
+ * Input: ['**\/node_modules\/**', '**\/dist\/**']
332
+ * Output: /[\\/](node_modules|dist)[\\/]/
333
+ */
334
+ const excludePatternsToRegExp = (patterns: string[]): RegExp | null => {
335
+ const keywords: string[] = [];
336
+ for (const pattern of patterns) {
337
+ // Extract the core part between ** wildcards
338
+ // e.g., '**/node_modules/**' -> 'node_modules'
339
+ // e.g., '**/dist/**' -> 'dist'
340
+ // e.g., '**/.{idea,git,cache,output,temp}/**' -> extract each part
341
+ const match = pattern.match(
342
+ /\*\*\/\.?\{?([^/*{}]+(?:,[^/*{}]+)*)\}?\/?\*?\*?/,
343
+ );
344
+ if (match) {
345
+ // Handle {a,b,c} patterns
346
+ const parts = match[1]!.split(',');
347
+ for (const part of parts) {
348
+ // Clean up the part (remove leading dots for hidden dirs)
349
+ const cleaned = part.replace(/^\./, '');
350
+ if (cleaned && !keywords.includes(cleaned)) {
351
+ keywords.push(cleaned);
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ if (keywords.length === 0) {
358
+ return null;
359
+ }
360
+
361
+ // Create regex that matches paths containing these directory names
362
+ // Use [\\/] to match both forward and back slashes
363
+ return new RegExp(`[\\\\/](${keywords.join('|')})[\\\\/]`);
364
+ };
365
+
366
+ const getRuntimeConfigFromProject = (
367
+ project: ProjectContext,
368
+ ): RuntimeConfig => {
369
+ const {
370
+ testNamePattern,
371
+ testTimeout,
372
+ passWithNoTests,
373
+ retry,
374
+ globals,
375
+ clearMocks,
376
+ resetMocks,
377
+ restoreMocks,
378
+ unstubEnvs,
379
+ unstubGlobals,
380
+ maxConcurrency,
381
+ printConsoleTrace,
382
+ disableConsoleIntercept,
383
+ testEnvironment,
384
+ hookTimeout,
385
+ isolate,
386
+ coverage,
387
+ snapshotFormat,
388
+ env,
389
+ bail,
390
+ logHeapUsage,
391
+ chaiConfig,
392
+ includeTaskLocation,
393
+ } = project.normalizedConfig;
394
+
395
+ return {
396
+ env,
397
+ testNamePattern,
398
+ testTimeout,
399
+ hookTimeout,
400
+ passWithNoTests,
401
+ retry,
402
+ globals,
403
+ clearMocks,
404
+ resetMocks,
405
+ restoreMocks,
406
+ unstubEnvs,
407
+ unstubGlobals,
408
+ maxConcurrency,
409
+ printConsoleTrace,
410
+ disableConsoleIntercept,
411
+ testEnvironment,
412
+ isolate,
413
+ coverage,
414
+ snapshotFormat,
415
+ bail,
416
+ logHeapUsage,
417
+ chaiConfig,
418
+ includeTaskLocation,
419
+ };
420
+ };
421
+
422
+ const getBrowserProjects = (context: Rstest): ProjectContext[] => {
423
+ return context.projects.filter(
424
+ (project) => project.normalizedConfig.browser.enabled,
425
+ );
426
+ };
427
+
428
+ const collectProjectEntries = async (
429
+ context: Rstest,
430
+ ): Promise<BrowserProjectEntries[]> => {
431
+ const projectEntries: BrowserProjectEntries[] = [];
432
+
433
+ // Only collect entries for browser mode projects
434
+ const browserProjects = getBrowserProjects(context);
435
+
436
+ for (const project of browserProjects) {
437
+ const {
438
+ normalizedConfig: { include, exclude, includeSource, setupFiles },
439
+ } = project;
440
+
441
+ const tests = await getTestEntries({
442
+ include,
443
+ exclude: exclude.patterns,
444
+ includeSource,
445
+ rootPath: context.rootPath,
446
+ projectRoot: project.rootPath,
447
+ fileFilters: context.fileFilters || [],
448
+ });
449
+
450
+ const setup = getSetupFiles(setupFiles, project.rootPath);
451
+
452
+ projectEntries.push({
453
+ project,
454
+ setupFiles: Object.values(setup),
455
+ testFiles: Object.values(tests),
456
+ });
457
+ }
458
+
459
+ return projectEntries;
460
+ };
461
+
462
+ const resolveBrowserFile = (relativePath: string): string => {
463
+ // __dirname points to packages/browser/dist when running from built code
464
+ // or packages/browser/src when running from source
465
+ const candidates = [
466
+ // When running from built dist: look in ../src for source files
467
+ resolve(__dirname, '../src', relativePath),
468
+ // When running from source (dev mode)
469
+ resolve(__dirname, relativePath),
470
+ ];
471
+
472
+ for (const candidate of candidates) {
473
+ if (existsSync(candidate)) {
474
+ return candidate;
475
+ }
476
+ }
477
+
478
+ throw new Error(`Unable to resolve browser client file: ${relativePath}`);
479
+ };
480
+
481
+ const resolveContainerDist = (): string => {
482
+ // When running from built dist: browser-container is in the same dist folder
483
+ const distPath = resolve(__dirname, 'browser-container');
484
+ if (existsSync(distPath)) {
485
+ return distPath;
486
+ }
487
+
488
+ throw new Error(
489
+ `Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`,
490
+ );
491
+ };
492
+
493
+ // ============================================================================
494
+ // Manifest Generation
495
+ // ============================================================================
496
+
497
+ /**
498
+ * Format environment name to a valid JavaScript identifier.
499
+ * Replaces non-alphanumeric characters with underscores.
500
+ */
501
+ const toSafeVarName = (name: string): string => {
502
+ return name.replace(/[^a-zA-Z0-9_]/g, '_');
503
+ };
504
+
505
+ const generateManifestModule = ({
506
+ manifestPath,
507
+ entries,
508
+ }: {
509
+ manifestPath: string;
510
+ entries: BrowserProjectEntries[];
511
+ }): string => {
512
+ const manifestDirPosix = normalize(dirname(manifestPath));
513
+
514
+ const toRelativeImport = (filePath: string): string => {
515
+ const posixPath = normalize(filePath);
516
+ let relativePath = relative(manifestDirPosix, posixPath);
517
+ if (!relativePath.startsWith('.')) {
518
+ relativePath = `./${relativePath}`;
519
+ }
520
+ return relativePath;
521
+ };
522
+
523
+ const lines: string[] = [];
524
+
525
+ // 1. Export all projects configuration
526
+ lines.push('// All projects configuration');
527
+ lines.push('export const projects = [');
528
+ for (const { project } of entries) {
529
+ lines.push(' {');
530
+ lines.push(` name: ${JSON.stringify(project.name)},`);
531
+ lines.push(
532
+ ` environmentName: ${JSON.stringify(project.environmentName)},`,
533
+ );
534
+ lines.push(
535
+ ` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
536
+ );
537
+ lines.push(' },');
538
+ }
539
+ lines.push('];');
540
+ lines.push('');
541
+
542
+ // 2. Setup loaders for each project
543
+ lines.push('// Setup loaders for each project');
544
+ lines.push('export const projectSetupLoaders = {');
545
+ for (const { project, setupFiles } of entries) {
546
+ lines.push(` ${JSON.stringify(project.name)}: [`);
547
+ for (const filePath of setupFiles) {
548
+ const relativePath = toRelativeImport(filePath);
549
+ lines.push(` () => import(${JSON.stringify(relativePath)}),`);
550
+ }
551
+ lines.push(' ],');
552
+ }
553
+ lines.push('};');
554
+ lines.push('');
555
+
556
+ // 3. Test context for each project
557
+ lines.push('// Test context for each project');
558
+ for (const { project } of entries) {
559
+ const varName = `context_${toSafeVarName(project.environmentName)}`;
560
+ const projectRootPosix = normalize(project.rootPath);
561
+ const includeRegExp = globPatternsToRegExp(
562
+ project.normalizedConfig.include,
563
+ );
564
+ const excludePatterns = project.normalizedConfig.exclude.patterns;
565
+ const excludeRegExp = excludePatternsToRegExp(excludePatterns);
566
+
567
+ lines.push(
568
+ `const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
569
+ );
570
+ lines.push(' recursive: true,');
571
+ lines.push(` regExp: ${includeRegExp.toString()},`);
572
+ if (excludeRegExp) {
573
+ lines.push(` exclude: ${excludeRegExp.toString()},`);
574
+ }
575
+ lines.push(" mode: 'lazy',");
576
+ lines.push('});');
577
+ lines.push('');
578
+ }
579
+
580
+ // 4. Export test contexts object
581
+ lines.push('export const projectTestContexts = {');
582
+ for (const { project } of entries) {
583
+ const varName = `context_${toSafeVarName(project.environmentName)}`;
584
+ lines.push(` ${JSON.stringify(project.name)}: {`);
585
+ lines.push(` getTestKeys: () => ${varName}.keys(),`);
586
+ lines.push(` loadTest: (key) => ${varName}(key),`);
587
+ lines.push(
588
+ ` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
589
+ );
590
+ lines.push(' },');
591
+ }
592
+ lines.push('};');
593
+ lines.push('');
594
+
595
+ // 5. Backward compatibility exports (use first project as default)
596
+ lines.push('// Backward compatibility: export first project as default');
597
+ lines.push('export const projectConfig = projects[0];');
598
+ lines.push(
599
+ 'export const setupLoaders = projectSetupLoaders[projects[0].name] || [];',
600
+ );
601
+ lines.push('const _defaultCtx = projectTestContexts[projects[0].name];');
602
+ lines.push(
603
+ 'export const getTestKeys = () => _defaultCtx ? _defaultCtx.getTestKeys() : [];',
604
+ );
605
+ lines.push(
606
+ 'export const loadTest = (key) => _defaultCtx ? _defaultCtx.loadTest(key) : Promise.reject(new Error("No project found"));',
607
+ );
608
+
609
+ return `${lines.join('\n')}\n`;
610
+ };
611
+
612
+ const htmlTemplate = `<!DOCTYPE html>
613
+ <html lang="en">
614
+ <head>
615
+ <meta charset="UTF-8" />
616
+ <title>Rstest Browser Runner</title>
617
+ </head>
618
+ <body>
619
+ <script type="module" src="/static/js/runner.js"></script>
620
+ </body>
621
+ </html>
622
+ `;
623
+
624
+ // ============================================================================
625
+ // Browser Runtime Lifecycle
626
+ // ============================================================================
627
+
628
+ const destroyBrowserRuntime = async (
629
+ runtime: BrowserRuntime,
630
+ ): Promise<void> => {
631
+ try {
632
+ await runtime.browser?.close?.();
633
+ } catch {
634
+ // ignore
635
+ }
636
+ try {
637
+ await runtime.devServer?.close?.();
638
+ } catch {
639
+ // ignore
640
+ }
641
+ try {
642
+ runtime.wss?.close();
643
+ } catch {
644
+ // ignore
645
+ }
646
+ await fs
647
+ .rm(runtime.tempDir, { recursive: true, force: true })
648
+ .catch(() => {});
649
+ };
650
+
651
+ const registerWatchCleanup = (): void => {
652
+ if (watchContext.cleanupRegistered) {
653
+ return;
654
+ }
655
+
656
+ const cleanup = async () => {
657
+ if (!watchContext.runtime) {
658
+ return;
659
+ }
660
+ await destroyBrowserRuntime(watchContext.runtime);
661
+ watchContext.runtime = null;
662
+ };
663
+
664
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
665
+ process.once(signal, () => {
666
+ void cleanup();
667
+ });
668
+ }
669
+
670
+ process.once('exit', () => {
671
+ void cleanup();
672
+ });
673
+
674
+ watchContext.cleanupRegistered = true;
675
+ };
676
+
677
+ const createBrowserRuntime = async ({
678
+ context,
679
+ manifestPath,
680
+ manifestSource,
681
+ tempDir,
682
+ isWatchMode,
683
+ onTriggerRerun,
684
+ containerDistPath,
685
+ containerDevServer,
686
+ forceHeadless,
687
+ }: {
688
+ context: Rstest;
689
+ manifestPath: string;
690
+ manifestSource: string;
691
+ tempDir: string;
692
+ isWatchMode: boolean;
693
+ onTriggerRerun?: () => Promise<void>;
694
+ containerDistPath?: string;
695
+ containerDevServer?: string;
696
+ /** Force headless mode regardless of user config (used for list command) */
697
+ forceHeadless?: boolean;
698
+ }): Promise<BrowserRuntime> => {
699
+ const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
700
+ [manifestPath]: manifestSource,
701
+ });
702
+
703
+ const optionsPlaceholder = '__RSTEST_OPTIONS_PLACEHOLDER__';
704
+ const containerHtmlTemplate = containerDistPath
705
+ ? await fs.readFile(join(containerDistPath, 'container.html'), 'utf-8')
706
+ : null;
707
+
708
+ let injectedContainerHtml: string | null = null;
709
+ let serializedOptions = 'null';
710
+
711
+ const setContainerOptions = (options: BrowserHostConfig): void => {
712
+ serializedOptions = JSON.stringify(options).replace(/</g, '\\u003c');
713
+ if (containerHtmlTemplate) {
714
+ injectedContainerHtml = containerHtmlTemplate.replace(
715
+ optionsPlaceholder,
716
+ serializedOptions,
717
+ );
718
+ }
719
+ };
720
+
721
+ // Get user Rsbuild config from the first browser project
722
+ const browserProjects = getBrowserProjects(context);
723
+ const firstProject = browserProjects[0];
724
+ const userPlugins = firstProject?.normalizedConfig.plugins || [];
725
+ const userRsbuildConfig = firstProject?.normalizedConfig ?? {};
726
+
727
+ // Rstest internal aliases that must not be overridden by user config
728
+ const browserRuntimePath = fileURLToPath(
729
+ import.meta.resolve('@rstest/core/browser-runtime'),
730
+ );
731
+
732
+ const rstestInternalAliases = {
733
+ '@rstest/browser-manifest': manifestPath,
734
+ // User test code: import { describe, it } from '@rstest/core'
735
+ '@rstest/core': resolveBrowserFile('client/public.ts'),
736
+ // Browser runtime APIs for entry.ts and public.ts
737
+ // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
738
+ '@rstest/core/browser-runtime': browserRuntimePath,
739
+ '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'),
740
+ };
741
+
742
+ const rsbuildInstance = await createRsbuild({
743
+ callerName: 'rstest-browser',
744
+ rsbuildConfig: {
745
+ root: context.rootPath,
746
+ mode: 'development',
747
+ plugins: userPlugins,
748
+ server: {
749
+ printUrls: false,
750
+ port: context.normalizedConfig.browser.port,
751
+ strictPort: context.normalizedConfig.browser.port !== undefined,
752
+ },
753
+ dev: {
754
+ client: {
755
+ logLevel: 'error',
756
+ },
757
+ },
758
+ environments: {
759
+ web: {},
760
+ },
761
+ },
762
+ });
763
+
764
+ // Add plugin to merge user Rsbuild config with rstest required config
765
+ rsbuildInstance.addPlugins([
766
+ {
767
+ name: 'rstest:browser-user-config',
768
+ setup(api) {
769
+ api.modifyEnvironmentConfig((config, { mergeEnvironmentConfig }) => {
770
+ // Merge order: current config -> userConfig -> rstest required config (highest priority)
771
+ const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
772
+ source: {
773
+ entry: {
774
+ runner: resolveBrowserFile('client/entry.ts'),
775
+ },
776
+ },
777
+ resolve: {
778
+ alias: rstestInternalAliases,
779
+ },
780
+ output: {
781
+ target: 'web',
782
+ // Enable source map for inline snapshot support
783
+ sourceMap: {
784
+ js: 'source-map',
785
+ },
786
+ },
787
+ tools: {
788
+ rspack: (rspackConfig) => {
789
+ rspackConfig.mode = 'development';
790
+ rspackConfig.lazyCompilation = {
791
+ imports: true,
792
+ entries: false,
793
+ };
794
+ rspackConfig.plugins = rspackConfig.plugins || [];
795
+ rspackConfig.plugins.push(virtualManifestPlugin);
796
+
797
+ // Extract and merge sourcemaps from pre-built @rstest/core files
798
+ // This preserves the sourcemap chain for inline snapshot support
799
+ // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
800
+ const browserRuntimeDir = dirname(browserRuntimePath);
801
+ rspackConfig.module = rspackConfig.module || {};
802
+ rspackConfig.module.rules = rspackConfig.module.rules || [];
803
+ rspackConfig.module.rules.unshift({
804
+ test: /\.js$/,
805
+ include: browserRuntimeDir,
806
+ extractSourceMap: true,
807
+ });
808
+
809
+ if (isDebug()) {
810
+ logger.log(
811
+ `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
812
+ );
813
+ }
814
+ },
815
+ },
816
+ });
817
+
818
+ return merged;
819
+ });
820
+ },
821
+ },
822
+ ]);
823
+
824
+ // Register watch plugin if in watch mode
825
+ if (isWatchMode && onTriggerRerun) {
826
+ rsbuildInstance.addPlugins([
827
+ {
828
+ name: 'rstest:browser-watch',
829
+ setup(api) {
830
+ api.onBeforeDevCompile(() => {
831
+ if (!watchContext.hooksEnabled) {
832
+ return;
833
+ }
834
+ logger.log(color.cyan('\nFile changed, re-running tests...\n'));
835
+ });
836
+
837
+ api.onAfterDevCompile(async () => {
838
+ if (!watchContext.hooksEnabled) {
839
+ return;
840
+ }
841
+ await onTriggerRerun();
842
+ });
843
+ },
844
+ },
845
+ ]);
846
+ }
847
+
848
+ const devServer = await rsbuildInstance.createDevServer({
849
+ getPortSilently: true,
850
+ });
851
+
852
+ // Serve prebuilt container assets (SPA) via sirv
853
+ const serveContainer = containerDistPath
854
+ ? sirv(containerDistPath, {
855
+ dev: false,
856
+ single: 'container.html',
857
+ })
858
+ : null;
859
+
860
+ const containerDevBase = containerDevServer
861
+ ? new URL(containerDevServer)
862
+ : null;
863
+
864
+ const respondWithDevServerHtml = async (
865
+ url: URL,
866
+ res: ServerResponse,
867
+ ): Promise<boolean> => {
868
+ if (!containerDevBase) {
869
+ return false;
870
+ }
871
+
872
+ try {
873
+ const target = new URL(url.pathname + url.search, containerDevBase);
874
+ const response = await fetch(target);
875
+ if (!response.ok) {
876
+ return false;
877
+ }
878
+
879
+ let html = await response.text();
880
+ html = html.replace(optionsPlaceholder, serializedOptions);
881
+
882
+ res.statusCode = response.status;
883
+ response.headers.forEach((value, key) => {
884
+ if (key.toLowerCase() === 'content-length') {
885
+ return;
886
+ }
887
+ res.setHeader(key, value);
888
+ });
889
+ res.setHeader('Content-Type', 'text/html');
890
+ res.end(html);
891
+ return true;
892
+ } catch (error) {
893
+ logger.log(
894
+ color.yellow(
895
+ `[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
896
+ ),
897
+ );
898
+ return false;
899
+ }
900
+ };
901
+
902
+ const proxyDevServerAsset = async (
903
+ req: IncomingMessage,
904
+ res: ServerResponse,
905
+ ): Promise<boolean> => {
906
+ if (!containerDevBase || !req.url) {
907
+ return false;
908
+ }
909
+
910
+ try {
911
+ const target = new URL(req.url, containerDevBase);
912
+ const response = await fetch(target);
913
+ if (!response.ok) {
914
+ return false;
915
+ }
916
+
917
+ const buffer = Buffer.from(await response.arrayBuffer());
918
+ res.statusCode = response.status;
919
+ response.headers.forEach((value, key) => {
920
+ if (key.toLowerCase() === 'content-length') {
921
+ return;
922
+ }
923
+ res.setHeader(key, value);
924
+ });
925
+ res.end(buffer);
926
+ return true;
927
+ } catch (error) {
928
+ logger.log(
929
+ color.yellow(
930
+ `[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
931
+ ),
932
+ );
933
+ return false;
934
+ }
935
+ };
936
+
937
+ devServer.middlewares.use(async (req, res, next) => {
938
+ if (!req.url) {
939
+ next();
940
+ return;
941
+ }
942
+ const url = new URL(req.url, 'http://localhost');
943
+ if (url.pathname === '/__open-in-editor') {
944
+ const file = url.searchParams.get('file');
945
+ if (!file) {
946
+ res.statusCode = 400;
947
+ res.end('Missing file');
948
+ return;
949
+ }
950
+ try {
951
+ await openEditor([{ file }]);
952
+ res.statusCode = 204;
953
+ res.end();
954
+ } catch (error) {
955
+ logger.log(
956
+ color.yellow(`[Browser UI] Failed to open editor: ${String(error)}`),
957
+ );
958
+ res.statusCode = 500;
959
+ res.end('Failed to open editor');
960
+ }
961
+ return;
962
+ }
963
+ if (url.pathname === '/' || url.pathname === '/container.html') {
964
+ if (await respondWithDevServerHtml(url, res)) {
965
+ return;
966
+ }
967
+
968
+ const html =
969
+ injectedContainerHtml ||
970
+ containerHtmlTemplate?.replace(optionsPlaceholder, 'null');
971
+
972
+ if (html) {
973
+ res.setHeader('Content-Type', 'text/html');
974
+ res.end(html);
975
+ return;
976
+ }
977
+
978
+ res.statusCode = 502;
979
+ res.end('Container UI is not available.');
980
+ return;
981
+ }
982
+ if (url.pathname.startsWith('/container-static/')) {
983
+ if (await proxyDevServerAsset(req, res)) {
984
+ return;
985
+ }
986
+
987
+ if (serveContainer) {
988
+ serveContainer(req, res, next);
989
+ return;
990
+ }
991
+
992
+ res.statusCode = 502;
993
+ res.end('Container assets are not available.');
994
+ return;
995
+ }
996
+ if (url.pathname === '/runner.html') {
997
+ res.setHeader('Content-Type', 'text/html');
998
+ res.end(htmlTemplate);
999
+ return;
1000
+ }
1001
+ next();
1002
+ });
1003
+
1004
+ const { port } = await devServer.listen();
1005
+
1006
+ // Create WebSocket server on a different port
1007
+ const wsPort = port + 1;
1008
+ const wss = new WebSocketServer({ port: wsPort });
1009
+ logger.log(
1010
+ color.gray(`[Browser UI] WebSocket server started on port ${wsPort}`),
1011
+ );
1012
+
1013
+ let browserLauncher: BrowserType;
1014
+ const browserName = context.normalizedConfig.browser.browser;
1015
+ try {
1016
+ const playwright = await import('playwright');
1017
+ browserLauncher = playwright[browserName];
1018
+ } catch (_error) {
1019
+ wss.close();
1020
+ await devServer.close();
1021
+ throw _error;
1022
+ }
1023
+
1024
+ let browser: BrowserInstance;
1025
+ try {
1026
+ browser = await browserLauncher.launch({
1027
+ headless: forceHeadless ?? context.normalizedConfig.browser.headless,
1028
+ // Chromium-specific args (ignored by other browsers)
1029
+ args:
1030
+ browserName === 'chromium'
1031
+ ? [
1032
+ '--disable-popup-blocking',
1033
+ '--no-first-run',
1034
+ '--no-default-browser-check',
1035
+ ]
1036
+ : undefined,
1037
+ });
1038
+ } catch (_error) {
1039
+ wss.close();
1040
+ await devServer.close();
1041
+ throw _error;
1042
+ }
1043
+
1044
+ return {
1045
+ rsbuildInstance,
1046
+ devServer,
1047
+ browser,
1048
+ port,
1049
+ wsPort,
1050
+ manifestPath,
1051
+ tempDir,
1052
+ manifestPlugin: virtualManifestPlugin,
1053
+ setContainerOptions,
1054
+ wss,
1055
+ };
1056
+ };
1057
+
1058
+ // ============================================================================
1059
+ // Main Entry Point
1060
+ // ============================================================================
1061
+
1062
+ export const runBrowserController = async (context: Rstest): Promise<void> => {
1063
+ const buildStart = Date.now();
1064
+ const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
1065
+ let containerDevServer: string | undefined;
1066
+ let containerDistPath: string | undefined;
1067
+
1068
+ if (containerDevServerEnv) {
1069
+ try {
1070
+ containerDevServer = new URL(containerDevServerEnv).toString();
1071
+ logger.log(
1072
+ color.gray(
1073
+ `[Browser UI] Using dev server for container: ${containerDevServer}`,
1074
+ ),
1075
+ );
1076
+ } catch (error) {
1077
+ logger.error(
1078
+ color.red(
1079
+ `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${String(error)}`,
1080
+ ),
1081
+ );
1082
+ ensureProcessExitCode(1);
1083
+ return;
1084
+ }
1085
+ }
1086
+
1087
+ if (!containerDevServer) {
1088
+ try {
1089
+ containerDistPath = resolveContainerDist();
1090
+ } catch (error) {
1091
+ logger.error(color.red(String(error)));
1092
+ ensureProcessExitCode(1);
1093
+ return;
1094
+ }
1095
+ }
1096
+
1097
+ const projectEntries = await collectProjectEntries(context);
1098
+ const totalTests = projectEntries.reduce(
1099
+ (total, item) => total + item.testFiles.length,
1100
+ 0,
1101
+ );
1102
+
1103
+ if (totalTests === 0) {
1104
+ const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
1105
+ logger.log(
1106
+ color[code ? 'red' : 'yellow'](
1107
+ `No test files found, exiting with code ${code}.`,
1108
+ ),
1109
+ );
1110
+ if (code !== 0) {
1111
+ ensureProcessExitCode(code);
1112
+ }
1113
+ return;
1114
+ }
1115
+
1116
+ const isWatchMode = context.command === 'watch';
1117
+ const tempDir =
1118
+ isWatchMode && watchContext.runtime
1119
+ ? watchContext.runtime.tempDir
1120
+ : isWatchMode
1121
+ ? join(context.rootPath, TEMP_RSTEST_OUTPUT_DIR, 'browser', 'watch')
1122
+ : join(
1123
+ context.rootPath,
1124
+ TEMP_RSTEST_OUTPUT_DIR,
1125
+ 'browser',
1126
+ Date.now().toString(),
1127
+ );
1128
+ const manifestPath = join(tempDir, 'manifest.ts');
1129
+
1130
+ const manifestSource = generateManifestModule({
1131
+ manifestPath,
1132
+ entries: projectEntries,
1133
+ });
1134
+
1135
+ // Track initial test files for watch mode
1136
+ if (isWatchMode) {
1137
+ watchContext.lastTestFiles = projectEntries.flatMap((entry) =>
1138
+ entry.testFiles.map((testPath) => ({
1139
+ testPath,
1140
+ projectName: entry.project.name,
1141
+ })),
1142
+ );
1143
+ }
1144
+
1145
+ let runtime = isWatchMode ? watchContext.runtime : null;
1146
+
1147
+ // Define rerun callback for watch mode (will be populated later)
1148
+ let triggerRerun: (() => Promise<void>) | undefined;
1149
+
1150
+ if (!runtime) {
1151
+ try {
1152
+ runtime = await createBrowserRuntime({
1153
+ context,
1154
+ manifestPath,
1155
+ manifestSource,
1156
+ tempDir,
1157
+ isWatchMode,
1158
+ onTriggerRerun: isWatchMode
1159
+ ? async () => {
1160
+ await triggerRerun?.();
1161
+ }
1162
+ : undefined,
1163
+ containerDistPath,
1164
+ containerDevServer,
1165
+ });
1166
+ } catch (error) {
1167
+ logger.error(error instanceof Error ? error : new Error(String(error)));
1168
+ ensureProcessExitCode(1);
1169
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
1170
+ return;
1171
+ }
1172
+
1173
+ if (isWatchMode) {
1174
+ watchContext.runtime = runtime;
1175
+ registerWatchCleanup();
1176
+ }
1177
+ }
1178
+
1179
+ const { browser, port, wsPort, wss } = runtime;
1180
+ const buildTime = Date.now() - buildStart;
1181
+
1182
+ // Collect all test files from project entries with project info
1183
+ // Normalize paths to posix format for cross-platform compatibility
1184
+ const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
1185
+ entry.testFiles.map((testPath) => ({
1186
+ testPath: normalize(testPath),
1187
+ projectName: entry.project.name,
1188
+ })),
1189
+ );
1190
+
1191
+ // Only include browser mode projects in runtime configs
1192
+ // Normalize projectRoot to posix format for cross-platform compatibility
1193
+ const browserProjectsForRuntime = getBrowserProjects(context);
1194
+ const projectRuntimeConfigs: BrowserProjectRuntime[] =
1195
+ browserProjectsForRuntime.map((project: ProjectContext) => ({
1196
+ name: project.name,
1197
+ environmentName: project.environmentName,
1198
+ projectRoot: normalize(project.rootPath),
1199
+ runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
1200
+ }));
1201
+
1202
+ // Get max testTimeout from all browser projects for RPC timeout
1203
+ const maxTestTimeoutForRpc = Math.max(
1204
+ ...browserProjectsForRuntime.map(
1205
+ (p) => p.normalizedConfig.testTimeout ?? 5000,
1206
+ ),
1207
+ );
1208
+
1209
+ const hostOptions: BrowserHostConfig = {
1210
+ rootPath: normalize(context.rootPath),
1211
+ projects: projectRuntimeConfigs,
1212
+ snapshot: {
1213
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
1214
+ },
1215
+ runnerUrl: `http://localhost:${port}`,
1216
+ wsPort,
1217
+ debug: isDebug(),
1218
+ rpcTimeout: maxTestTimeoutForRpc,
1219
+ };
1220
+
1221
+ runtime.setContainerOptions(hostOptions);
1222
+
1223
+ // Track test results from iframes
1224
+ const reporterResults: TestFileResult[] = [];
1225
+ const caseResults: TestResult[] = [];
1226
+ let completedTests = 0;
1227
+ let fatalError: Error | null = null;
1228
+
1229
+ // Promise that resolves when all tests complete
1230
+ let resolveAllTests: (() => void) | undefined;
1231
+ const allTestsPromise = new Promise<void>((resolve) => {
1232
+ resolveAllTests = resolve;
1233
+ });
1234
+
1235
+ // Open a container page for user to view (reuse in watch mode)
1236
+ let containerContext: BrowserContext;
1237
+ let containerPage: Page;
1238
+ let isNewPage = false;
1239
+
1240
+ if (isWatchMode && runtime.containerPage && runtime.containerContext) {
1241
+ containerContext = runtime.containerContext;
1242
+ containerPage = runtime.containerPage;
1243
+ logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
1244
+ } else {
1245
+ isNewPage = true;
1246
+ containerContext = await browser.newContext({
1247
+ viewport: null,
1248
+ });
1249
+ containerPage = await containerContext.newPage();
1250
+
1251
+ // Prevent popup windows from being created
1252
+ containerPage.on('popup', async (popup: Page) => {
1253
+ await popup.close().catch(() => {});
1254
+ });
1255
+
1256
+ containerContext.on('page', async (page: Page) => {
1257
+ if (page !== containerPage) {
1258
+ await page.close().catch(() => {});
1259
+ }
1260
+ });
1261
+
1262
+ if (isWatchMode) {
1263
+ runtime.containerPage = containerPage;
1264
+ runtime.containerContext = containerContext;
1265
+ }
1266
+
1267
+ // Forward browser console to terminal
1268
+ containerPage.on('console', (msg: ConsoleMessage) => {
1269
+ const text = msg.text();
1270
+ if (text.includes('[Container]') || text.includes('[Runner]')) {
1271
+ logger.log(color.gray(`[Browser Console] ${text}`));
1272
+ }
1273
+ });
1274
+ }
1275
+
1276
+ // Create RPC methods that can access test state variables
1277
+ const createRpcMethods = (): HostRpcMethods => ({
1278
+ async rerunTest(testFile: string, testNamePattern?: string) {
1279
+ logger.log(
1280
+ color.cyan(
1281
+ `\nRe-running test: ${testFile}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
1282
+ ),
1283
+ );
1284
+ await rpcManager.reloadTestFile(testFile, testNamePattern);
1285
+ },
1286
+ async getTestFiles() {
1287
+ return allTestFiles;
1288
+ },
1289
+ async onTestFileStart(payload: TestFileStartPayload) {
1290
+ await Promise.all(
1291
+ context.reporters.map((reporter) =>
1292
+ (reporter as Reporter).onTestFileStart?.({
1293
+ testPath: payload.testPath,
1294
+ tests: [],
1295
+ }),
1296
+ ),
1297
+ );
1298
+ },
1299
+ async onTestCaseResult(payload: TestResult) {
1300
+ caseResults.push(payload);
1301
+ await Promise.all(
1302
+ context.reporters.map((reporter) =>
1303
+ (reporter as Reporter).onTestCaseResult?.(payload),
1304
+ ),
1305
+ );
1306
+ },
1307
+ async onTestFileComplete(payload: TestFileResult) {
1308
+ reporterResults.push(payload);
1309
+ if (payload.snapshotResult) {
1310
+ context.snapshotManager.add(payload.snapshotResult);
1311
+ }
1312
+ await Promise.all(
1313
+ context.reporters.map((reporter) =>
1314
+ (reporter as Reporter).onTestFileResult?.(payload),
1315
+ ),
1316
+ );
1317
+
1318
+ completedTests++;
1319
+ if (completedTests >= allTestFiles.length && resolveAllTests) {
1320
+ resolveAllTests();
1321
+ }
1322
+ },
1323
+ async onLog(payload: LogPayload) {
1324
+ const log: UserConsoleLog = {
1325
+ content: payload.content,
1326
+ name: payload.level,
1327
+ testPath: payload.testPath,
1328
+ type: payload.type,
1329
+ trace: payload.trace,
1330
+ };
1331
+
1332
+ // Check onConsoleLog filter
1333
+ const shouldLog =
1334
+ context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
1335
+
1336
+ if (shouldLog) {
1337
+ await Promise.all(
1338
+ context.reporters.map((reporter) =>
1339
+ (reporter as Reporter).onUserConsoleLog?.(log),
1340
+ ),
1341
+ );
1342
+ }
1343
+ },
1344
+ async onFatal(payload: FatalPayload) {
1345
+ fatalError = new Error(payload.message);
1346
+ fatalError.stack = payload.stack;
1347
+ if (resolveAllTests) {
1348
+ resolveAllTests();
1349
+ }
1350
+ },
1351
+ // Snapshot file operations
1352
+ async resolveSnapshotPath(testPath: string) {
1353
+ const snapExtension = '.snap';
1354
+ const resolver =
1355
+ context.normalizedConfig.resolveSnapshotPath ||
1356
+ // test/index.ts -> test/__snapshots__/index.ts.snap
1357
+ (() =>
1358
+ join(
1359
+ dirname(testPath),
1360
+ '__snapshots__',
1361
+ `${basename(testPath)}${snapExtension}`,
1362
+ ));
1363
+ return resolver(testPath, snapExtension);
1364
+ },
1365
+ async readSnapshotFile(filepath: string) {
1366
+ try {
1367
+ return await fs.readFile(filepath, 'utf-8');
1368
+ } catch {
1369
+ return null;
1370
+ }
1371
+ },
1372
+ async saveSnapshotFile(filepath: string, content: string) {
1373
+ const dir = dirname(filepath);
1374
+ await fs.mkdir(dir, { recursive: true });
1375
+ await fs.writeFile(filepath, content, 'utf-8');
1376
+ },
1377
+ async removeSnapshotFile(filepath: string) {
1378
+ try {
1379
+ await fs.unlink(filepath);
1380
+ } catch {
1381
+ // ignore if file doesn't exist
1382
+ }
1383
+ },
1384
+ });
1385
+
1386
+ // Setup RPC manager
1387
+ let rpcManager: ContainerRpcManager;
1388
+
1389
+ if (isWatchMode && runtime.rpcManager) {
1390
+ rpcManager = runtime.rpcManager;
1391
+ // Update methods with new test state (caseResults, completedTests, etc.)
1392
+ rpcManager.updateMethods(createRpcMethods());
1393
+ // Reattach if we have an existing WebSocket
1394
+ const existingWs = rpcManager.currentWebSocket;
1395
+ if (existingWs) {
1396
+ rpcManager.reattach(existingWs);
1397
+ }
1398
+ } else {
1399
+ rpcManager = new ContainerRpcManager(wss, createRpcMethods());
1400
+
1401
+ if (isWatchMode) {
1402
+ runtime.rpcManager = rpcManager;
1403
+ }
1404
+ }
1405
+
1406
+ // Only navigate on first creation
1407
+ if (isNewPage) {
1408
+ await containerPage.goto(`http://localhost:${port}/container.html`, {
1409
+ waitUntil: 'load',
1410
+ });
1411
+
1412
+ logger.log(
1413
+ color.cyan(
1414
+ `\nContainer page opened at http://localhost:${port}/container.html\n`,
1415
+ ),
1416
+ );
1417
+ }
1418
+
1419
+ // Wait for all tests to complete
1420
+ // Calculate total timeout based on config: max testTimeout * file count + buffer
1421
+ const maxTestTimeout = Math.max(
1422
+ ...browserProjectsForRuntime.map(
1423
+ (p) => p.normalizedConfig.testTimeout ?? 5000,
1424
+ ),
1425
+ );
1426
+ const totalTimeoutMs = maxTestTimeout * allTestFiles.length + 30_000;
1427
+
1428
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
1429
+ const testTimeout = new Promise<void>((resolve) => {
1430
+ timeoutId = setTimeout(() => {
1431
+ logger.log(
1432
+ color.yellow(
1433
+ `\nTest execution timeout after ${totalTimeoutMs / 1000}s. ` +
1434
+ `Completed: ${completedTests}/${allTestFiles.length}\n`,
1435
+ ),
1436
+ );
1437
+ resolve();
1438
+ }, totalTimeoutMs);
1439
+ });
1440
+
1441
+ const testStart = Date.now();
1442
+ await Promise.race([allTestsPromise, testTimeout]);
1443
+
1444
+ if (timeoutId) {
1445
+ clearTimeout(timeoutId);
1446
+ }
1447
+
1448
+ const testTime = Date.now() - testStart;
1449
+
1450
+ // Define rerun logic for watch mode
1451
+ if (isWatchMode) {
1452
+ triggerRerun = async () => {
1453
+ const newProjectEntries = await collectProjectEntries(context);
1454
+ // Normalize paths to posix format for cross-platform compatibility
1455
+ const currentTestFiles: TestFileInfo[] = newProjectEntries.flatMap(
1456
+ (entry) =>
1457
+ entry.testFiles.map((testPath) => ({
1458
+ testPath: normalize(testPath),
1459
+ projectName: entry.project.name,
1460
+ })),
1461
+ );
1462
+
1463
+ // Compare test files by serializing to JSON for deep comparison
1464
+ const serialize = (files: TestFileInfo[]) =>
1465
+ JSON.stringify(
1466
+ files.map((f) => `${f.projectName}:${f.testPath}`).sort(),
1467
+ );
1468
+
1469
+ const filesChanged =
1470
+ serialize(currentTestFiles) !== serialize(watchContext.lastTestFiles);
1471
+
1472
+ if (filesChanged) {
1473
+ watchContext.lastTestFiles = currentTestFiles;
1474
+ await rpcManager.notifyTestFileUpdate(currentTestFiles);
1475
+ }
1476
+
1477
+ logger.log(color.cyan('Tests will be re-executed automatically\n'));
1478
+ };
1479
+ }
1480
+
1481
+ if (!isWatchMode) {
1482
+ await destroyBrowserRuntime(runtime);
1483
+ }
1484
+
1485
+ if (fatalError) {
1486
+ logger.error(
1487
+ color.red(`Browser test run failed: ${(fatalError as Error).message}`),
1488
+ );
1489
+ ensureProcessExitCode(1);
1490
+ return;
1491
+ }
1492
+
1493
+ const duration = {
1494
+ totalTime: buildTime + testTime,
1495
+ buildTime,
1496
+ testTime,
1497
+ };
1498
+
1499
+ context.updateReporterResultState(reporterResults, caseResults);
1500
+
1501
+ const isFailure = reporterResults.some(
1502
+ (result: TestFileResult) => result.status === 'fail',
1503
+ );
1504
+ if (isFailure) {
1505
+ ensureProcessExitCode(1);
1506
+ }
1507
+
1508
+ for (const reporter of context.reporters) {
1509
+ await reporter.onTestRunEnd?.({
1510
+ results: context.reporterResults.results,
1511
+ testResults: context.reporterResults.testResults,
1512
+ duration,
1513
+ snapshotSummary: context.snapshotManager.summary,
1514
+ getSourcemap: async () => null,
1515
+ });
1516
+ }
1517
+
1518
+ // Enable watch hooks AFTER initial test run to avoid duplicate runs
1519
+ if (isWatchMode && triggerRerun) {
1520
+ watchContext.hooksEnabled = true;
1521
+ logger.log(
1522
+ color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'),
1523
+ );
1524
+ }
1525
+ };
1526
+
1527
+ // ============================================================================
1528
+ // List Browser Tests
1529
+ // ============================================================================
1530
+
1531
+ /**
1532
+ * Result from collecting browser tests.
1533
+ * This is the return type for listBrowserTests, designed for future extraction
1534
+ * to a separate browser package.
1535
+ */
1536
+ export type ListBrowserTestsResult = {
1537
+ list: ListCommandResult[];
1538
+ close: () => Promise<void>;
1539
+ };
1540
+
1541
+ /**
1542
+ * Collect test metadata from browser mode projects without running them.
1543
+ * This function creates a headless browser runtime, loads test files,
1544
+ * and collects their test structure (describe/test declarations).
1545
+ */
1546
+ export const listBrowserTests = async (
1547
+ context: Rstest,
1548
+ ): Promise<ListBrowserTestsResult> => {
1549
+ const projectEntries = await collectProjectEntries(context);
1550
+ const totalTests = projectEntries.reduce(
1551
+ (total, item) => total + item.testFiles.length,
1552
+ 0,
1553
+ );
1554
+
1555
+ if (totalTests === 0) {
1556
+ return {
1557
+ list: [],
1558
+ close: async () => {},
1559
+ };
1560
+ }
1561
+
1562
+ const tempDir = join(
1563
+ context.rootPath,
1564
+ TEMP_RSTEST_OUTPUT_DIR,
1565
+ 'browser',
1566
+ `list-${Date.now()}`,
1567
+ );
1568
+ const manifestPath = join(tempDir, 'manifest.ts');
1569
+
1570
+ const manifestSource = generateManifestModule({
1571
+ manifestPath,
1572
+ entries: projectEntries,
1573
+ });
1574
+
1575
+ // Create a simplified browser runtime for collect mode
1576
+ let runtime: BrowserRuntime;
1577
+ try {
1578
+ runtime = await createBrowserRuntime({
1579
+ context,
1580
+ manifestPath,
1581
+ manifestSource,
1582
+ tempDir,
1583
+ isWatchMode: false,
1584
+ containerDistPath: undefined,
1585
+ containerDevServer: undefined,
1586
+ forceHeadless: true, // Always use headless for list command
1587
+ });
1588
+ } catch (error) {
1589
+ logger.error(
1590
+ color.red(
1591
+ 'Failed to load Playwright. Please install "playwright" to use browser mode.',
1592
+ ),
1593
+ error,
1594
+ );
1595
+ throw error;
1596
+ }
1597
+
1598
+ const { browser, port } = runtime;
1599
+
1600
+ // Get browser projects for runtime config
1601
+ // Normalize projectRoot to posix format for cross-platform compatibility
1602
+ const browserProjects = getBrowserProjects(context);
1603
+ const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
1604
+ (project: ProjectContext) => ({
1605
+ name: project.name,
1606
+ environmentName: project.environmentName,
1607
+ projectRoot: normalize(project.rootPath),
1608
+ runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
1609
+ }),
1610
+ );
1611
+
1612
+ // Get max testTimeout from all browser projects for RPC timeout
1613
+ const maxTestTimeoutForRpc = Math.max(
1614
+ ...browserProjects.map((p) => p.normalizedConfig.testTimeout ?? 5000),
1615
+ );
1616
+
1617
+ const hostOptions: BrowserHostConfig = {
1618
+ rootPath: normalize(context.rootPath),
1619
+ projects: projectRuntimeConfigs,
1620
+ snapshot: {
1621
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
1622
+ },
1623
+ mode: 'collect', // Use collect mode
1624
+ debug: isDebug(),
1625
+ rpcTimeout: maxTestTimeoutForRpc,
1626
+ };
1627
+
1628
+ runtime.setContainerOptions(hostOptions);
1629
+
1630
+ // Collect results
1631
+ const collectResults: ListCommandResult[] = [];
1632
+ let fatalError: Error | null = null;
1633
+ let collectCompleted = false;
1634
+
1635
+ // Promise that resolves when collection is complete
1636
+ let resolveCollect: (() => void) | undefined;
1637
+ const collectPromise = new Promise<void>((resolve) => {
1638
+ resolveCollect = resolve;
1639
+ });
1640
+
1641
+ // Create a headless page to run collection
1642
+ const browserContext = await browser.newContext({ viewport: null });
1643
+ const page = await browserContext.newPage();
1644
+
1645
+ // Expose dispatch function for browser client to send messages
1646
+ await page.exposeFunction(
1647
+ '__rstest_dispatch__',
1648
+ (message: { type: string; payload?: unknown }) => {
1649
+ switch (message.type) {
1650
+ case 'collect-result': {
1651
+ const payload = message.payload as {
1652
+ testPath: string;
1653
+ project: string;
1654
+ tests: Test[];
1655
+ };
1656
+ collectResults.push({
1657
+ testPath: payload.testPath,
1658
+ project: payload.project,
1659
+ tests: payload.tests,
1660
+ });
1661
+ break;
1662
+ }
1663
+ case 'collect-complete':
1664
+ collectCompleted = true;
1665
+ resolveCollect?.();
1666
+ break;
1667
+ case 'fatal': {
1668
+ const payload = message.payload as {
1669
+ message: string;
1670
+ stack?: string;
1671
+ };
1672
+ fatalError = new Error(payload.message);
1673
+ fatalError.stack = payload.stack;
1674
+ resolveCollect?.();
1675
+ break;
1676
+ }
1677
+ case 'ready':
1678
+ case 'log':
1679
+ // Ignore these messages during collection
1680
+ break;
1681
+ default:
1682
+ // Log unexpected messages for debugging
1683
+ logger.debug(`[List] Unexpected message: ${message.type}`);
1684
+ }
1685
+ },
1686
+ );
1687
+
1688
+ // Inject host options before navigation so the runner can access them
1689
+ const serializedOptions = JSON.stringify(hostOptions).replace(
1690
+ /</g,
1691
+ '\\u003c',
1692
+ );
1693
+ await page.addInitScript(
1694
+ `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
1695
+ );
1696
+
1697
+ // Navigate to runner page
1698
+ await page.goto(`http://localhost:${port}/runner.html`, {
1699
+ waitUntil: 'load',
1700
+ });
1701
+
1702
+ // Wait for collection to complete with timeout
1703
+ const timeoutMs = 30000;
1704
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
1705
+ const timeoutPromise = new Promise<void>((resolve) => {
1706
+ timeoutId = setTimeout(() => {
1707
+ if (!collectCompleted) {
1708
+ logger.warn(
1709
+ color.yellow(
1710
+ `[List] Browser test collection timed out after ${timeoutMs}ms`,
1711
+ ),
1712
+ );
1713
+ }
1714
+ resolve();
1715
+ }, timeoutMs);
1716
+ });
1717
+
1718
+ await Promise.race([collectPromise, timeoutPromise]);
1719
+
1720
+ // Clear timeout to prevent Node.js from waiting for it
1721
+ if (timeoutId) {
1722
+ clearTimeout(timeoutId);
1723
+ }
1724
+
1725
+ // Cleanup
1726
+ const cleanup = async () => {
1727
+ try {
1728
+ await page.close();
1729
+ await browserContext.close();
1730
+ } catch {
1731
+ // ignore
1732
+ }
1733
+ await destroyBrowserRuntime(runtime);
1734
+ };
1735
+
1736
+ if (fatalError) {
1737
+ await cleanup();
1738
+ // Return error in the result format instead of throwing
1739
+ const errorResult: ListCommandResult = {
1740
+ testPath: '',
1741
+ project: '',
1742
+ tests: [],
1743
+ errors: [
1744
+ {
1745
+ name: 'BrowserCollectError',
1746
+ message: (fatalError as Error).message,
1747
+ stack: (fatalError as Error).stack,
1748
+ } as FormattedError,
1749
+ ],
1750
+ };
1751
+ return {
1752
+ list: [errorResult],
1753
+ close: async () => {},
1754
+ };
1755
+ }
1756
+
1757
+ return {
1758
+ list: collectResults,
1759
+ close: cleanup,
1760
+ };
1761
+ };