@rstest/browser 0.11.6 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/browser-container/container-static/css/index.43daaf6c93.css +1 -0
  2. package/dist/browser-container/container-static/js/260.3739fe815c.js +194 -0
  3. package/dist/browser-container/container-static/js/index.c0f87bfb3e.js +1 -0
  4. package/dist/browser-container/index.html +1 -1
  5. package/dist/browserRsbuild.d.ts +24 -4
  6. package/dist/browserV8Coverage.d.ts +15 -0
  7. package/dist/client/240~1.js +150 -0
  8. package/dist/{augmentExpect.d.ts → client/client/augmentExpect.d.ts} +1 -1
  9. package/dist/client/{browserRpc.d.ts → client/browserRpc.d.ts} +1 -1
  10. package/dist/client/{dispatchTransport.d.ts → client/dispatchTransport.d.ts} +8 -4
  11. package/dist/client/client/index.d.ts +2 -0
  12. package/dist/client/client/runIdentity.d.ts +12 -0
  13. package/dist/client/client/runner.d.ts +4 -0
  14. package/dist/client/client/snapshot.d.ts +27 -0
  15. package/dist/client/client/sourceMapSupport.d.ts +36 -0
  16. package/dist/{browser.js → client/index.js} +3 -102
  17. package/dist/client/protocol.d.ts +225 -0
  18. package/dist/client/rpcProtocol.d.ts +138 -0
  19. package/dist/client/runner.js +675 -0
  20. package/dist/client/sourceMap/sourceMapLoader.d.ts +25 -0
  21. package/dist/containerRpc.d.ts +27 -19
  22. package/dist/headedRunRegistry.d.ts +51 -0
  23. package/dist/headedScheduler.d.ts +10 -10
  24. package/dist/headlessScheduler.d.ts +5 -4
  25. package/dist/headlessTransport.d.ts +4 -2
  26. package/dist/hostPayloads.d.ts +1 -7
  27. package/dist/index.js +840 -330
  28. package/dist/protocol.d.ts +48 -8
  29. package/dist/providers/index.d.ts +21 -0
  30. package/dist/providers/playwright/v8Coverage.d.ts +2 -0
  31. package/dist/rpcProtocol.d.ts +1 -2
  32. package/dist/schedulerSeam.d.ts +5 -0
  33. package/dist/sourceMap/sourceMapLoader.d.ts +12 -1
  34. package/dist/watchRerunPlanner.d.ts +20 -9
  35. package/dist/watchSignals.d.ts +8 -1
  36. package/package.json +10 -10
  37. package/dist/33.js +0 -7
  38. package/dist/browser-container/container-static/css/index.0521ff95b1.css +0 -1
  39. package/dist/browser-container/container-static/js/683.4f821ce8f1.js +0 -194
  40. package/dist/browser-container/container-static/js/index.568aa7cb35.js +0 -1
  41. package/dist/browser.d.ts +0 -2
  42. package/src/augmentExpect.ts +0 -62
  43. package/src/browser.ts +0 -3
  44. package/src/browserExecutor.ts +0 -222
  45. package/src/browserRpcRegistry.ts +0 -57
  46. package/src/browserRsbuild.ts +0 -1927
  47. package/src/client/api.ts +0 -213
  48. package/src/client/browserRpc.ts +0 -86
  49. package/src/client/dispatchTransport.ts +0 -240
  50. package/src/client/entry.ts +0 -807
  51. package/src/client/locator.ts +0 -448
  52. package/src/client/snapshot.ts +0 -113
  53. package/src/client/sourceMapSupport.ts +0 -159
  54. package/src/concurrency.ts +0 -65
  55. package/src/configValidation.ts +0 -263
  56. package/src/containerRpc.ts +0 -206
  57. package/src/dispatchCapabilities.ts +0 -177
  58. package/src/dispatchRouter.ts +0 -82
  59. package/src/env.d.ts +0 -54
  60. package/src/headedScheduler.ts +0 -664
  61. package/src/headedSerialTaskQueue.ts +0 -23
  62. package/src/headlessScheduler.ts +0 -566
  63. package/src/headlessTransport.ts +0 -28
  64. package/src/hostController.ts +0 -1365
  65. package/src/hostPayloads.ts +0 -62
  66. package/src/index.ts +0 -51
  67. package/src/manifest.d.ts +0 -41
  68. package/src/protocol.ts +0 -225
  69. package/src/providers/index.ts +0 -126
  70. package/src/providers/playwright/compileLocator.ts +0 -130
  71. package/src/providers/playwright/dispatchBrowserRpc.ts +0 -372
  72. package/src/providers/playwright/expectUtils.ts +0 -57
  73. package/src/providers/playwright/implementation.ts +0 -35
  74. package/src/providers/playwright/index.ts +0 -1
  75. package/src/providers/playwright/runtime.ts +0 -159
  76. package/src/providers/playwright/textMatcher.ts +0 -10
  77. package/src/rpcProtocol.ts +0 -218
  78. package/src/runSession.ts +0 -110
  79. package/src/schedulerSeam.ts +0 -49
  80. package/src/sessionRegistry.ts +0 -89
  81. package/src/sourceMap/sourceMapLoader.ts +0 -96
  82. package/src/viewportPresets.ts +0 -69
  83. package/src/watchRerunPlanner.ts +0 -88
  84. package/src/watchRuntime.ts +0 -83
  85. package/src/watchSignals.ts +0 -93
  86. /package/dist/{rslib-runtime.js → 612~0.js} +0 -0
  87. /package/dist/client/{api.d.ts → client/api.d.ts} +0 -0
  88. /package/dist/client/{locator.d.ts → client/locator.d.ts} +0 -0
@@ -1,1365 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import {
3
- type BrowserTestRunOptions,
4
- type BrowserTestRunResult,
5
- buildBrowserCoverageMap,
6
- type ExecutorCycleOutcome,
7
- type ExecutorInvalidationCallback,
8
- type ListBrowserTestsOptions,
9
- color,
10
- createCoverageProvider,
11
- createRunnerEventSink,
12
- createSilentConsoleController,
13
- DEFAULT_TEST_TIMEOUT,
14
- type FormattedError,
15
- getPrettyConsoleName,
16
- hasUserRstestConfigPlugins,
17
- isDebug,
18
- isTTY,
19
- type ListCommandResult,
20
- logger,
21
- logWatchReadyMessage,
22
- projectRuntimeConfig,
23
- PhaseTracker,
24
- type ProjectContext,
25
- type RunnerEventSink,
26
- type RstestContext,
27
- resolveSnapshotPathDefault,
28
- serializableConfig,
29
- type Test,
30
- type TestFileResult,
31
- type TestResult,
32
- type UserConsoleLog,
33
- } from '@rstest/core/internal/browser';
34
- import { dirname, join, normalize } from 'pathe';
35
- import {
36
- createHostDispatchRouter,
37
- type HostDispatchRouterOptions,
38
- } from './dispatchCapabilities';
39
- import {
40
- collectProjectEntries,
41
- createBrowserRuntime,
42
- destroyBrowserRuntime,
43
- drainPendingBuildTime,
44
- getBrowserProjects,
45
- type BrowserProjectServer,
46
- type BrowserProviderProject,
47
- resolveContainerDist,
48
- resolveProjectEntries,
49
- serializeForInlineScript,
50
- type BrowserRuntime,
51
- } from './browserRsbuild';
52
- import { createHeadedScheduler } from './headedScheduler';
53
- import { createHeadlessScheduler } from './headlessScheduler';
54
- import type {
55
- BrowserDispatchRequest,
56
- BrowserHostConfig,
57
- BrowserProjectRuntime,
58
- BrowserRpcRequest,
59
- SnapshotRpcRequest,
60
- TestFileInfo,
61
- } from './protocol';
62
- import {
63
- DISPATCH_MESSAGE_TYPE,
64
- DISPATCH_NAMESPACE_BROWSER,
65
- validateBrowserRpcRequest,
66
- } from './protocol';
67
- import {
68
- type BrowserProvider,
69
- type BrowserProviderImplementation,
70
- getBrowserProviderImplementation,
71
- } from './providers';
72
- import {
73
- type FatalPayload,
74
- getFileTaskId,
75
- type LogPayload,
76
- type TestCaseStartPayload,
77
- type TestFileReadyPayload,
78
- type TestFileStartPayload,
79
- type TestSuiteResultPayload,
80
- type TestSuiteStartPayload,
81
- toError,
82
- } from './hostPayloads';
83
- import {
84
- loadSourceMapWithCache,
85
- normalizeJavaScriptUrl,
86
- type SourceMapPayload,
87
- } from './sourceMap/sourceMapLoader';
88
- import { collectWatchTestFiles } from './watchRerunPlanner';
89
- import type {
90
- BrowserWatchSession,
91
- DispatchPageResolver,
92
- } from './schedulerSeam';
93
- import { registerWatchCleanup, watchContext } from './watchRuntime';
94
- import { createWatchSignals } from './watchSignals';
95
-
96
- /**
97
- * Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
98
- * mode. Browser host runs every test file inside the same Node process, so
99
- * without an override every file would emit events under the same `pid` and
100
- * share a single track labelled `worker <hostPid>`. Giving each file its own
101
- * synthetic `pid` makes the track title surface the file path instead,
102
- * matching node mode's default `isolate: true` behavior. The 1_000_000_000
103
- * base keeps each synthetic `pid` well clear of real OS `pid` values in
104
- * mixed-mode traces.
105
- */
106
- let nextBrowserFilePid = 1_000_000_000;
107
-
108
- // ============================================================================
109
- // Utility Functions
110
- // ============================================================================
111
-
112
- /**
113
- * The exit code a launch that found no test files at all must leave behind.
114
- *
115
- * Core's `reportNoTestFiles` owns the message and the no-test reporter
116
- * lifecycle for such a launch, but in watch mode its report deliberately leaves
117
- * the exit code alone — a rerun matching nothing is not a failure. Such a launch
118
- * opened no session, so no later cycle can raise the code either. That makes
119
- * this the host's only write to `process.exitCode`: a boot failure looks like a
120
- * second launch-path exception but is not one, because it rides the outcome out
121
- * of `failWithError` and core raises the code from there. One-shot runs keep
122
- * going through the cycle, and a caller that passed `allowEmptyRun` — today only
123
- * the config-hook discovery boot — reads the outcome instead of the process.
124
- */
125
- const resolveEmptyLaunchExitCode = (
126
- current: number | string | undefined,
127
- {
128
- allowEmptyRun,
129
- isWatchMode,
130
- passWithNoTests,
131
- }: {
132
- allowEmptyRun: boolean;
133
- isWatchMode: boolean;
134
- passWithNoTests: boolean;
135
- },
136
- ): number | string | undefined => {
137
- if (allowEmptyRun || !isWatchMode || passWithNoTests) {
138
- return current;
139
- }
140
- // Never downgrade: a code already raised by an earlier failure stands.
141
- return current === undefined || current === 0 ? 1 : current;
142
- };
143
-
144
- const getMaxTestTimeoutForRpc = (projects: ProjectContext[]): number =>
145
- Math.max(
146
- ...projects.map(
147
- (p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
148
- ),
149
- );
150
-
151
- const resolveProviderForTestPath = ({
152
- testPath,
153
- browserProjects,
154
- }: {
155
- testPath: string;
156
- browserProjects: BrowserProviderProject[];
157
- }): BrowserProvider => {
158
- const normalizedTestPath = normalize(testPath);
159
- const sortedProjects = [...browserProjects].sort(
160
- (a, b) => b.rootPath.length - a.rootPath.length,
161
- );
162
-
163
- for (const project of sortedProjects) {
164
- if (normalizedTestPath.startsWith(project.rootPath)) {
165
- return project.provider;
166
- }
167
- }
168
-
169
- throw new Error(
170
- `Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
171
- `Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
172
- );
173
- };
174
-
175
- // ============================================================================
176
-
177
- export type BrowserControllerOptions = BrowserTestRunOptions & {
178
- /**
179
- * Watch only: core's watch-cycle driver (see `TestExecutor.onInvalidate`).
180
- * Its promise settles when the cycle it queued has finalized, which only an
181
- * explicit request may wait for (see `signalInvalidation`).
182
- */
183
- onInvalidate?: ExecutorInvalidationCallback;
184
- };
185
-
186
- export type BrowserControllerResult = BrowserTestRunResult & {
187
- watchSession?: BrowserWatchSession;
188
- };
189
-
190
- export const runBrowserController = async (
191
- context: RstestContext,
192
- options?: BrowserControllerOptions,
193
- ): Promise<BrowserControllerResult | void> => {
194
- const {
195
- allowEmptyRun = false,
196
- filesOnly = false,
197
- onTraceEvents,
198
- env,
199
- onInvalidate,
200
- } = options ?? {};
201
- const buildStart = Date.now();
202
- // Watch mode changes what this controller *owns*, never who finalizes: core's
203
- // `finalizeRunCycle` reduces every cycle on both commands. What watch adds is
204
- // a persistent runtime (reused across controller re-entry), the rerun
205
- // triggers, and HMR — so the initial run returns a live watch session instead
206
- // of a deferred `close`.
207
- const isWatchMode = context.command === 'watch';
208
-
209
- // Per-file PhaseTrackers, populated only when `--trace` is on (caller
210
- // passes `onTraceEvents`). The browser host shares one Node process across
211
- // every test file, so each tracker is assigned a synthetic per-file pid
212
- // (`nextBrowserFilePid`) that lets Perfetto render each file as its own
213
- // process track with the file path as the title. Keyed by project + path so
214
- // concurrent projects running the same file keep separate trackers.
215
- const phaseTrackers = onTraceEvents
216
- ? new Map<string, PhaseTracker>()
217
- : undefined;
218
- const trackerKey = (project: string, testPath: string) =>
219
- `${project}\u0000${testPath}`;
220
- // Explicit projects input (plan output) replaces re-deriving `browser.enabled`
221
- // projects from `context`, whose `projects` array is mutated during planning.
222
- // Falls back to re-derivation only when the caller passes no list at all —
223
- // an explicit empty subset must stay empty, not widen to every project.
224
- const browserProjects = options?.projects ?? getBrowserProjects(context);
225
- const useHeadlessDirect = browserProjects.every(
226
- (project) => project.normalizedConfig.browser.headless,
227
- );
228
-
229
- const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
230
-
231
- const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
232
-
233
- const resolveBrowserSourcemap = async (sourcePath: string) => {
234
- if (!isHttpLikeFile(sourcePath)) {
235
- return {
236
- handled: false,
237
- sourcemap: null,
238
- };
239
- }
240
-
241
- const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
242
- if (!normalizedUrl) {
243
- return {
244
- handled: true,
245
- sourcemap: null,
246
- };
247
- }
248
-
249
- if (browserSourceMapCache.has(normalizedUrl)) {
250
- return {
251
- handled: true,
252
- sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
253
- };
254
- }
255
-
256
- return {
257
- handled: true,
258
- sourcemap: await loadSourceMapWithCache({
259
- jsUrl: normalizedUrl,
260
- cache: browserSourceMapCache,
261
- }),
262
- };
263
- };
264
-
265
- const getBrowserSourcemap = async (
266
- sourcePath: string,
267
- ): Promise<SourceMapPayload | null> => {
268
- const result = await resolveBrowserSourcemap(sourcePath);
269
- return result.handled ? result.sourcemap : null;
270
- };
271
-
272
- /**
273
- * Build an error BrowserTestRunResult and call onTestRunEnd if needed.
274
- * Used for early-exit error paths to ensure errors reach the summary report.
275
- */
276
- const buildErrorResult = (
277
- error: Error,
278
- close?: () => Promise<void>,
279
- ): BrowserTestRunResult => {
280
- const elapsed = Math.max(0, Date.now() - buildStart);
281
- return {
282
- results: [],
283
- testResults: [],
284
- duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
285
- hasFailure: true,
286
- unhandledErrors: [error],
287
- getSourcemap: getBrowserSourcemap,
288
- resolveSourcemap: resolveBrowserSourcemap,
289
- close,
290
- };
291
- };
292
-
293
- const failWithError = async (
294
- error: unknown,
295
- cleanup?: () => Promise<void>,
296
- ): Promise<BrowserTestRunResult> => {
297
- // The error rides the returned result into the cycle outcome, and core's
298
- // `finalizeRunCycle` raises the exit code from it — on both commands.
299
- const normalizedError = toError(error);
300
-
301
- if (cleanup && !isWatchMode) {
302
- return buildErrorResult(normalizedError, cleanup);
303
- }
304
-
305
- try {
306
- return buildErrorResult(normalizedError);
307
- } finally {
308
- await cleanup?.();
309
- }
310
- };
311
-
312
- const coverageConfig = browserProjects.find(
313
- (project) => project.normalizedConfig.coverage?.enabled,
314
- )?.normalizedConfig.coverage;
315
- const coverageProvider = coverageConfig?.enabled
316
- ? await createCoverageProvider(coverageConfig, context.rootPath)
317
- : null;
318
-
319
- const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
320
- let containerDevServer: string | undefined;
321
- let containerDistPath: string | undefined;
322
-
323
- if (!useHeadlessDirect) {
324
- if (containerDevServerEnv) {
325
- try {
326
- containerDevServer = new URL(containerDevServerEnv).toString();
327
- logger.debug(
328
- `[Browser UI] Using dev server for container: ${containerDevServer}`,
329
- );
330
- } catch (error) {
331
- const originalError = toError(error);
332
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
333
- return failWithError(originalError);
334
- }
335
- }
336
-
337
- if (!containerDevServer) {
338
- try {
339
- containerDistPath = resolveContainerDist();
340
- } catch (error) {
341
- return failWithError(error);
342
- }
343
- }
344
- }
345
-
346
- let projectEntries = await resolveProjectEntries(
347
- context,
348
- options?.shardedEntries,
349
- browserProjects,
350
- );
351
- let totalTests = projectEntries.reduce(
352
- (total, item) => total + item.testFiles.length,
353
- 0,
354
- );
355
- const shouldInitializeEmptyBrowserHooks =
356
- totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
357
-
358
- const createEmptyRunResult = (): BrowserTestRunResult => {
359
- const elapsed = Math.max(0, Date.now() - buildStart);
360
- return {
361
- results: [],
362
- testResults: [],
363
- duration: {
364
- totalTime: elapsed,
365
- buildTime: elapsed,
366
- testTime: 0,
367
- },
368
- hasFailure: false,
369
- getSourcemap: getBrowserSourcemap,
370
- resolveSourcemap: resolveBrowserSourcemap,
371
- };
372
- };
373
-
374
- const writeEmptyLaunchExitCode = (): void => {
375
- process.exitCode = resolveEmptyLaunchExitCode(process.exitCode, {
376
- allowEmptyRun,
377
- isWatchMode,
378
- passWithNoTests: context.normalizedConfig.passWithNoTests,
379
- });
380
- };
381
-
382
- if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
383
- writeEmptyLaunchExitCode();
384
- return allowEmptyRun ? createEmptyRunResult() : undefined;
385
- }
386
-
387
- const enableCliShortcuts = isWatchMode && isTTY('stdin');
388
- const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
389
- const tempDir =
390
- isWatchMode && watchContext.runtime
391
- ? watchContext.runtime.tempDir
392
- : isWatchMode
393
- ? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch')
394
- : join(
395
- context.rootPath,
396
- browserTempOutputRoot,
397
- 'browser',
398
- Date.now().toString(),
399
- );
400
-
401
- let runtime = isWatchMode ? watchContext.runtime : null;
402
-
403
- const watchSignals = createWatchSignals(onInvalidate);
404
-
405
- if (!runtime) {
406
- try {
407
- runtime = await createBrowserRuntime({
408
- context,
409
- projectEntries,
410
- browserProjects,
411
- shardedEntries: options?.shardedEntries,
412
- freezeShardedEntries: options?.freezeShardedEntries,
413
- tempDir,
414
- isWatchMode,
415
- onTriggerRerun: isWatchMode
416
- ? async () => {
417
- await watchSignals.runDispatchRerun();
418
- }
419
- : undefined,
420
- containerDistPath,
421
- containerDevServer,
422
- skipProviderLaunch: filesOnly,
423
- appliedModifyRstestConfigEnvironments:
424
- options?.appliedModifyRstestConfigEnvironments,
425
- });
426
- } catch (error) {
427
- return failWithError(error, async () => {
428
- await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
429
- });
430
- }
431
-
432
- // `filesOnly` is the config-hook discovery boot, which destroys its runtime
433
- // through the returned `close`. Caching it here would leave the real watch
434
- // session re-entering on a destroyed runtime.
435
- if (isWatchMode && !filesOnly) {
436
- watchContext.runtime = runtime;
437
- registerWatchCleanup(context.embedded);
438
- }
439
- }
440
-
441
- const watchState = runtime.watchState;
442
-
443
- // Track initial test files for watch mode (from this controller's freshly
444
- // collected entries, before adopting the runtime's entry snapshot below).
445
- if (isWatchMode) {
446
- watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
447
- }
448
-
449
- // Mark files as pending-affected so the next trigger reruns them through the
450
- // normal plan/cycle pipeline (used by explicit rerun requests; omitted paths
451
- // = all current files). Returns the number of seeded files so a path-scoped
452
- // request matching no browser test file can skip the rerun entirely (mixed
453
- // watch 'u' with node-only snapshot updates must produce no browser cycle).
454
- const seedPendingRerun = (testPaths?: string[]): number => {
455
- const wanted = testPaths
456
- ? new Set(testPaths.map((testPath) => normalize(testPath)))
457
- : null;
458
- let seeded = 0;
459
- for (const file of watchState.lastTestFiles) {
460
- if (wanted && !wanted.has(file.testPath)) {
461
- continue;
462
- }
463
- const pending =
464
- watchState.pendingAffectedTestFiles.get(file.projectName) ??
465
- new Set<string>();
466
- pending.add(file.testPath);
467
- watchState.pendingAffectedTestFiles.set(file.projectName, pending);
468
- seeded += 1;
469
- }
470
- return seeded;
471
- };
472
-
473
- /**
474
- * Fold one watch rerun into the `ExecutorCycleOutcome` core's
475
- * `finalizeRunCycle` reduces. `context.reporterResults` is the cross-cycle
476
- * accumulator, so filtering it by the rerun's paths is what makes the outcome
477
- * cycle-scoped. `buildTime` is the drained duration of the change-triggered
478
- * compile(s) (zero for a shortcut-driven rerun, which compiles nothing).
479
- */
480
- const buildRerunOutcome = ({
481
- rerunTestPaths,
482
- testTime,
483
- unhandledErrors,
484
- }: {
485
- rerunTestPaths: string[];
486
- testTime: number;
487
- unhandledErrors?: Error[];
488
- }): ExecutorCycleOutcome => {
489
- const rerunPathSet = new Set(rerunTestPaths);
490
- const rerunResults = context.reporterResults.results.filter((result) =>
491
- rerunPathSet.has(result.testPath),
492
- );
493
- // Watch coverage is per-cycle on both transports: only the files this
494
- // rerun executed are reported.
495
- const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
496
-
497
- return {
498
- results: rerunResults,
499
- testResults: context.reporterResults.testResults.filter((result) =>
500
- rerunPathSet.has(result.testPath),
501
- ),
502
- errors: unhandledErrors ?? [],
503
- testPaths: rerunTestPaths,
504
- duration: {
505
- buildTime: drainPendingBuildTime(watchState),
506
- testTime,
507
- },
508
- coverage: coverageMap?.files().length
509
- ? { map: coverageMap.toJSON() }
510
- : undefined,
511
- resolveSourcemap: resolveBrowserSourcemap,
512
- };
513
- };
514
-
515
- /**
516
- * The watch session both transports hand back. Only `execute` differs — the
517
- * cycle's timing, its fatal-error capture window, and the error-to-outcome
518
- * precedence are one contract with core, so they live in one place.
519
- *
520
- * `execute`'s synchronous prefix runs before `runCycle` ever suspends, which
521
- * is what lets the headed transport claim its cycle scope inside it.
522
- */
523
- const createWatchSession = (
524
- execute: (testPaths: string[]) => Promise<void>,
525
- ): BrowserWatchSession => ({
526
- runCycle: async (testPaths) => {
527
- const rerunStartTime = Date.now();
528
- const fatalErrorBeforeRun = fatalErrorRef.current;
529
- let rerunError: Error | undefined;
530
-
531
- try {
532
- await execute(testPaths);
533
- } catch (error) {
534
- // Surfaced through the outcome rather than thrown: core finalizes this
535
- // cycle either way, and its results belong in the report even when the
536
- // run that produced them ended badly.
537
- rerunError = toError(error);
538
- }
539
-
540
- const rerunFatalError =
541
- fatalErrorRef.current && fatalErrorRef.current !== fatalErrorBeforeRun
542
- ? fatalErrorRef.current
543
- : undefined;
544
- return buildRerunOutcome({
545
- rerunTestPaths: testPaths,
546
- testTime: Math.max(0, Date.now() - rerunStartTime),
547
- unhandledErrors: rerunError
548
- ? [rerunError]
549
- : rerunFatalError
550
- ? [rerunFatalError]
551
- : undefined,
552
- });
553
- },
554
- requestRerun: async (testPaths) => {
555
- const seeded = seedPendingRerun(testPaths);
556
- if (testPaths && seeded === 0) {
557
- return;
558
- }
559
- await watchSignals.runDispatchRerun();
560
- await watchSignals.awaitSignalledCycle();
561
- },
562
- });
563
-
564
- projectEntries = runtime.projectEntries;
565
- totalTests = projectEntries.reduce(
566
- (total, item) => total + item.testFiles.length,
567
- 0,
568
- );
569
-
570
- const buildTime = Date.now() - buildStart;
571
-
572
- if (filesOnly) {
573
- return {
574
- results: [],
575
- testResults: [],
576
- duration: {
577
- totalTime: buildTime,
578
- buildTime,
579
- testTime: 0,
580
- },
581
- hasFailure: false,
582
- getSourcemap: getBrowserSourcemap,
583
- resolveSourcemap: resolveBrowserSourcemap,
584
- close: () => destroyBrowserRuntime(runtime),
585
- };
586
- }
587
-
588
- if (totalTests === 0) {
589
- writeEmptyLaunchExitCode();
590
- await destroyBrowserRuntime(runtime);
591
- return allowEmptyRun ? createEmptyRunResult() : undefined;
592
- }
593
-
594
- const { browser, browserLaunchOptions, wsPort } = runtime;
595
-
596
- // Collect all test files from project entries with project info
597
- // Normalize paths to posix format for cross-platform compatibility
598
- const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
599
- entry.testFiles.map((testPath) => ({
600
- testPath: normalize(testPath),
601
- projectName: entry.project.name,
602
- })),
603
- );
604
-
605
- // Only include browser mode projects in runtime configs
606
- // Normalize projectRoot to posix format for cross-platform compatibility
607
- const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
608
- (project: ProjectContext) => ({
609
- name: project.name,
610
- environmentName: project.environmentName,
611
- projectRoot: normalize(project.rootPath),
612
- runtimeConfig: serializableConfig(
613
- // `env` is the post-globalSetup change-set from the core pre-cycle
614
- // stage; the projection layers it between the static base and the
615
- // user `test.env` config.
616
- projectRuntimeConfig(project, { envMode: 'static', envOverlay: env }),
617
- ),
618
- viewport: project.normalizedConfig.browser.viewport,
619
- }),
620
- );
621
-
622
- const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
623
-
624
- const projectRunnerUrls = Object.fromEntries(
625
- [...runtime.projectServers].map(([name, server]) => [
626
- name,
627
- `http://localhost:${server.port}`,
628
- ]),
629
- );
630
-
631
- const hostOptions: BrowserHostConfig = {
632
- rootPath: normalize(context.rootPath),
633
- projects: projectRuntimeConfigs,
634
- snapshot: {
635
- updateSnapshot:
636
- options?.updateSnapshot ??
637
- context.snapshotManager.options.updateSnapshot,
638
- },
639
- // Container origin (fallback). Per-project runner origins below.
640
- runnerUrl: `http://localhost:${runtime.containerServer.port}`,
641
- projectRunnerUrls,
642
- wsPort,
643
- debug: isDebug(),
644
- rpcTimeout: maxTestTimeoutForRpc,
645
- };
646
-
647
- const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
648
- (project) => ({
649
- rootPath: normalize(project.rootPath),
650
- provider: project.normalizedConfig.browser.provider,
651
- }),
652
- );
653
- const implementationByProvider = new Map<
654
- BrowserProvider,
655
- BrowserProviderImplementation
656
- >();
657
- for (const browserProject of browserProviderProjects) {
658
- if (!implementationByProvider.has(browserProject.provider)) {
659
- implementationByProvider.set(
660
- browserProject.provider,
661
- getBrowserProviderImplementation(browserProject.provider),
662
- );
663
- }
664
- }
665
-
666
- let resolveDispatchPages: DispatchPageResolver = () => ({});
667
- const setDispatchPageResolver = (resolver: DispatchPageResolver): void => {
668
- resolveDispatchPages = resolver;
669
- };
670
-
671
- const dispatchBrowserRpcRequest = async ({
672
- request,
673
- target,
674
- }: {
675
- request: BrowserRpcRequest;
676
- target?: BrowserDispatchRequest['target'];
677
- }): Promise<unknown> => {
678
- const timeoutFallbackMs = maxTestTimeoutForRpc;
679
- const provider = resolveProviderForTestPath({
680
- testPath: request.testPath,
681
- browserProjects: browserProviderProjects,
682
- });
683
- const implementation = implementationByProvider.get(provider);
684
- if (!implementation) {
685
- throw new Error(`Browser provider implementation not found: ${provider}`);
686
- }
687
-
688
- const { runnerPage, containerPage } = resolveDispatchPages(target);
689
-
690
- if (target?.sessionId && !runnerPage) {
691
- throw new Error(
692
- `Runner page session not found for browser dispatch: ${target.sessionId}`,
693
- );
694
- }
695
-
696
- if (!runnerPage && !containerPage) {
697
- throw new Error('Browser container page is not initialized');
698
- }
699
-
700
- try {
701
- return await implementation.dispatchRpc({
702
- containerPage: runnerPage ? undefined : containerPage,
703
- runnerPage,
704
- request,
705
- timeoutFallbackMs,
706
- });
707
- } catch (error) {
708
- // birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
709
- if (error instanceof Error) {
710
- throw error.message;
711
- }
712
- throw String(error);
713
- }
714
- };
715
-
716
- runtime.dispatchHandlers.set(
717
- DISPATCH_NAMESPACE_BROWSER,
718
- async (dispatchRequest) => {
719
- const request = validateBrowserRpcRequest(dispatchRequest.args);
720
- return dispatchBrowserRpcRequest({
721
- request,
722
- target: dispatchRequest.target,
723
- });
724
- },
725
- );
726
-
727
- runtime.setContainerOptions(hostOptions);
728
-
729
- // Track test results from browser runners
730
- const reporterResults: TestFileResult[] = [];
731
- const caseResults: TestResult[] = [];
732
- const fatalErrorRef = { current: null as Error | null };
733
-
734
- // Runner lifecycle events flow through the shared RunnerEventSink (the same
735
- // pump the node pool uses), so browser mode feeds stateManager and fans out to
736
- // reporters via one implementation. One sink is bound per browser project up
737
- // front from the executor's own project plan (`browserProjects`) — never from
738
- // `context.projects`, which planning mutates to also contain node projects. The
739
- // previous lazy resolver fell back to `context.projects[0]`, so a browser event
740
- // could be attributed to a *node* project's config; binding per browser project
741
- // here removes that fallback and keeps per-project `onConsoleLog` filtering and
742
- // `resolveSnapshotPath` correct across browser projects that share a relative
743
- // test path.
744
- const runnerSinks = new Map<string, RunnerEventSink>(
745
- browserProjects.map((project) => [
746
- project.name,
747
- createRunnerEventSink(context, project.normalizedConfig),
748
- ]),
749
- );
750
- // Every per-file wire payload carries its owning project name, so routing
751
- // never derives a project from a test path — concurrent projects can run the
752
- // same file, and a path-keyed lookup would attribute events to the wrong one.
753
- // The client resolves its project from the host's own manifest, so a miss is
754
- // a protocol bug; fail loudly rather than route through another project's
755
- // config.
756
- const sinkForProjectName = (projectName: string): RunnerEventSink => {
757
- const sink = runnerSinks.get(projectName);
758
- if (!sink) {
759
- throw new Error(`No runner event sink for project "${projectName}"`);
760
- }
761
- return sink;
762
- };
763
-
764
- // Silent-console buffering runs through the shared controller — the same
765
- // engine the node worker uses — so `silent: 'passed-only'` buffers logs and
766
- // replays only the failing tasks'. Intercepted replays route through the
767
- // owning project's sink, so they honor per-project `onConsoleLog` and
768
- // `disableConsoleIntercept` (the browser host previously flushed straight to
769
- // reporters, bypassing both). `writeOriginalLog` is a host-side no-op: page
770
- // logs have no host "original stream" — the page console and headed terminal
771
- // forwarding already show them, so re-emitting here would double-print.
772
- const silentConsoleController = createSilentConsoleController({
773
- runtimeConfig: {
774
- silent: context.normalizedConfig.silent,
775
- disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
776
- },
777
- emitInterceptedLog: (log) =>
778
- sinkForProjectName(log.project).onConsoleLog(log),
779
- writeOriginalLog: () => {},
780
- });
781
-
782
- const snapshotRpcMethods = {
783
- async resolveSnapshotPath(testPath: string): Promise<string> {
784
- return resolveSnapshotPathDefault(
785
- testPath,
786
- context.normalizedConfig.resolveSnapshotPath,
787
- );
788
- },
789
- async readSnapshotFile(filepath: string): Promise<string | null> {
790
- try {
791
- return await fs.readFile(filepath, 'utf-8');
792
- } catch {
793
- return null;
794
- }
795
- },
796
- async saveSnapshotFile(filepath: string, content: string): Promise<void> {
797
- const dir = dirname(filepath);
798
- await fs.mkdir(dir, { recursive: true });
799
- await fs.writeFile(filepath, content, 'utf-8');
800
- },
801
- async removeSnapshotFile(filepath: string): Promise<void> {
802
- try {
803
- await fs.unlink(filepath);
804
- } catch {
805
- // ignore if file doesn't exist
806
- }
807
- },
808
- };
809
-
810
- const handleTestFileStart = async (
811
- payload: TestFileStartPayload,
812
- ): Promise<void> => {
813
- if (phaseTrackers) {
814
- const tracker = new PhaseTracker({
815
- trace: {
816
- testPath: payload.testPath,
817
- project: payload.projectName,
818
- },
819
- pid: nextBrowserFilePid++,
820
- });
821
- tracker.transition('prepare');
822
- phaseTrackers.set(
823
- trackerKey(payload.projectName, payload.testPath),
824
- tracker,
825
- );
826
- }
827
- // The client sends `{ testPath, projectName }`; the sink adapter builds the
828
- // `TestFileInfo` the reporters and stateManager expect.
829
- await sinkForProjectName(payload.projectName).onTestFileStart({
830
- testId: getFileTaskId(payload.testPath),
831
- testPath: payload.testPath,
832
- project: payload.projectName,
833
- tests: [],
834
- });
835
- };
836
-
837
- const handleTestFileReady = async (
838
- payload: TestFileReadyPayload,
839
- ): Promise<void> => {
840
- phaseTrackers
841
- ?.get(trackerKey(payload.project, payload.testPath))
842
- ?.transition('tests');
843
- await sinkForProjectName(payload.project).onTestFileReady(payload);
844
- };
845
-
846
- const handleTestSuiteStart = async (
847
- payload: TestSuiteStartPayload,
848
- ): Promise<void> => {
849
- phaseTrackers
850
- ?.get(trackerKey(payload.project, payload.testPath))
851
- ?.recordSuiteStart(payload);
852
- await sinkForProjectName(payload.project).onTestSuiteStart(payload);
853
- };
854
-
855
- const handleTestSuiteResult = async (
856
- payload: TestSuiteResultPayload,
857
- ): Promise<void> => {
858
- phaseTrackers
859
- ?.get(trackerKey(payload.project, payload.testPath))
860
- ?.recordSuiteResult(payload);
861
- await sinkForProjectName(payload.project).onTestSuiteResult(payload);
862
-
863
- if (context.normalizedConfig.silent === 'passed-only') {
864
- silentConsoleController.flushBufferedLogsForTask({
865
- taskId: payload.testId,
866
- status: payload.status,
867
- taskParentNames: payload.parentNames,
868
- taskType: 'suite',
869
- testPath: payload.testPath,
870
- });
871
- }
872
- };
873
-
874
- const handleTestCaseStart = async (
875
- payload: TestCaseStartPayload,
876
- ): Promise<void> => {
877
- phaseTrackers
878
- ?.get(trackerKey(payload.project, payload.testPath))
879
- ?.recordCaseStart(payload);
880
- // Fire-and-forget on both transports (the sink does not await case-start).
881
- sinkForProjectName(payload.project).onTestCaseStart(payload);
882
- };
883
-
884
- const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
885
- caseResults.push(payload);
886
- phaseTrackers
887
- ?.get(trackerKey(payload.project, payload.testPath))
888
- ?.recordCaseResult(payload);
889
- await sinkForProjectName(payload.project).onTestCaseResult(payload);
890
-
891
- if (context.normalizedConfig.silent === 'passed-only') {
892
- silentConsoleController.flushBufferedLogsForTask({
893
- taskId: payload.testId,
894
- status: payload.status,
895
- taskParentNames: payload.parentNames,
896
- taskType: 'case',
897
- testPath: payload.testPath,
898
- });
899
- }
900
- };
901
-
902
- const handleTestFileComplete = async (
903
- payload: TestFileResult,
904
- ): Promise<void> => {
905
- reporterResults.push(payload);
906
- context.updateReporterResultState([payload], payload.results);
907
-
908
- if (phaseTrackers) {
909
- const key = trackerKey(payload.project, payload.testPath);
910
- const tracker = phaseTrackers.get(key);
911
- if (tracker) {
912
- tracker.end();
913
- const events = tracker.getTraceEvents();
914
- if (events) onTraceEvents?.(events);
915
- phaseTrackers.delete(key);
916
- }
917
- }
918
-
919
- if (context.normalizedConfig.silent === 'passed-only') {
920
- silentConsoleController.flushBufferedLogsForTask({
921
- taskId: payload.testId,
922
- status: payload.status,
923
- taskParentNames: payload.parentNames,
924
- taskType: 'file',
925
- testPath: payload.testPath,
926
- });
927
- }
928
-
929
- // Feeds stateManager, fans out onTestFileResult to reporters, and ingests
930
- // payload.snapshotResult (the snapshotManager.add moved into the sink).
931
- await sinkForProjectName(payload.project).onTestFileResult(payload);
932
- };
933
-
934
- const handleLog = async (payload: LogPayload): Promise<void> => {
935
- const log: UserConsoleLog = {
936
- content: payload.content,
937
- // Same colored level label as the node worker's CustomConsole.
938
- name: getPrettyConsoleName(payload.level),
939
- taskId: payload.taskId,
940
- taskName: payload.taskName,
941
- taskParentNames: payload.taskParentNames,
942
- taskType: payload.taskType,
943
- testPath: payload.testPath,
944
- project: payload.projectName,
945
- type: payload.type,
946
- trace: payload.trace,
947
- };
948
- silentConsoleController.onConsoleLog(log);
949
- };
950
-
951
- // Every failing file and every fatal error reaches core through the cycle
952
- // outcome on both commands, so the exit code keeps a single writer:
953
- // `finalizeRunCycle`.
954
- const handleFatal = async (payload: FatalPayload): Promise<void> => {
955
- const error = new Error(payload.message);
956
- error.stack = payload.stack;
957
- fatalErrorRef.current = error;
958
- };
959
-
960
- const runSnapshotRpc = async (
961
- request: SnapshotRpcRequest,
962
- ): Promise<unknown> => {
963
- switch (request.method) {
964
- case 'resolveSnapshotPath':
965
- return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
966
- case 'readSnapshotFile':
967
- return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
968
- case 'saveSnapshotFile':
969
- return snapshotRpcMethods.saveSnapshotFile(
970
- request.args.filepath,
971
- request.args.content,
972
- );
973
- case 'removeSnapshotFile':
974
- return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
975
- default: {
976
- // Exhaustiveness guard: a new SnapshotRpcRequest method without a case
977
- // here fails to compile rather than silently returning undefined.
978
- const _exhaustive: never = request;
979
- return _exhaustive;
980
- }
981
- }
982
- };
983
-
984
- const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
985
- return createHostDispatchRouter({
986
- routerOptions: options,
987
- runnerCallbacks: {
988
- onTestFileStart: handleTestFileStart,
989
- onTestFileReady: handleTestFileReady,
990
- onTestSuiteStart: handleTestSuiteStart,
991
- onTestSuiteResult: handleTestSuiteResult,
992
- onTestCaseStart: handleTestCaseStart,
993
- onTestCaseResult: handleTestCaseResult,
994
- onTestFileComplete: handleTestFileComplete,
995
- onLog: handleLog,
996
- onFatal: handleFatal,
997
- },
998
- runSnapshotRpc,
999
- extensionHandlers: runtime.dispatchHandlers,
1000
- onDuplicateNamespace: (namespace) => {
1001
- logger.debug(
1002
- `[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
1003
- );
1004
- },
1005
- });
1006
- };
1007
-
1008
- const schedulerDeps = {
1009
- context,
1010
- allTestFiles,
1011
- hostOptions,
1012
- isWatchMode,
1013
- createDispatchRouter,
1014
- fatalErrorRef,
1015
- watchSignals,
1016
- setDispatchPageResolver,
1017
- createWatchSession,
1018
- collectProjectEntries: () => collectProjectEntries(context),
1019
- logWatchReady: () => logWatchReadyMessage(context, enableCliShortcuts),
1020
- destroyRuntime: () => destroyBrowserRuntime(runtime),
1021
- };
1022
-
1023
- const { testTime, watchSession, close } = useHeadlessDirect
1024
- ? await createHeadlessScheduler({
1025
- ...schedulerDeps,
1026
- browser,
1027
- browserLaunchOptions,
1028
- projectServers: runtime.projectServers,
1029
- projectRuntimeConfigs,
1030
- watchState,
1031
- handlers: { handleFatal, handleTestFileComplete },
1032
- })
1033
- : await createHeadedScheduler({
1034
- ...schedulerDeps,
1035
- runtime,
1036
- handlers: {
1037
- handleTestFileStart,
1038
- handleTestCaseResult,
1039
- handleTestFileComplete,
1040
- handleLog,
1041
- handleFatal,
1042
- },
1043
- });
1044
-
1045
- // A fatal error the run reported outranks its results: it rides the returned
1046
- // outcome into core's finalize, which raises the exit code from it.
1047
- if (fatalErrorRef.current) {
1048
- return failWithError(fatalErrorRef.current, close);
1049
- }
1050
-
1051
- context.updateReporterResultState(reporterResults, caseResults);
1052
-
1053
- // Enable the compile hooks only after the initial cycle, so the first build
1054
- // never triggers a duplicate run.
1055
- watchState.hooksEnabled = isWatchMode;
1056
-
1057
- return {
1058
- results: reporterResults,
1059
- testResults: caseResults,
1060
- duration: {
1061
- totalTime: buildTime + testTime,
1062
- buildTime,
1063
- testTime,
1064
- },
1065
- hasFailure: reporterResults.some(
1066
- (result: TestFileResult) => result.status === 'fail',
1067
- ),
1068
- getSourcemap: getBrowserSourcemap,
1069
- resolveSourcemap: resolveBrowserSourcemap,
1070
- // `close` is already `undefined` in watch mode: the watch runtime outlives
1071
- // the cycle and is torn down through `executor.close()`.
1072
- close,
1073
- watchSession,
1074
- };
1075
- };
1076
- // ============================================================================
1077
- // List Browser Tests
1078
- // ============================================================================
1079
-
1080
- /**
1081
- * Result from collecting browser tests.
1082
- * This is the return type for listBrowserTests, designed for future extraction
1083
- * to a separate browser package.
1084
- */
1085
- export type ListBrowserTestsResult = {
1086
- list: ListCommandResult[];
1087
- close: () => Promise<void>;
1088
- };
1089
-
1090
- /**
1091
- * Collect test metadata from browser mode projects without running them.
1092
- * This function creates a headless browser runtime, loads test files,
1093
- * and collects their test structure (describe/test declarations).
1094
- */
1095
- export const listBrowserTests = async (
1096
- context: RstestContext,
1097
- options?: ListBrowserTestsOptions,
1098
- ): Promise<ListBrowserTestsResult> => {
1099
- const browserProjects = options?.projects ?? getBrowserProjects(context);
1100
- const projectEntries = await resolveProjectEntries(
1101
- context,
1102
- options?.shardedEntries,
1103
- browserProjects,
1104
- );
1105
- const totalTests = projectEntries.reduce(
1106
- (total, item) => total + item.testFiles.length,
1107
- 0,
1108
- );
1109
-
1110
- if (totalTests === 0 && !hasUserRstestConfigPlugins(browserProjects)) {
1111
- return {
1112
- list: [],
1113
- close: async () => {},
1114
- };
1115
- }
1116
-
1117
- const tempDir = join(
1118
- context.rootPath,
1119
- context.normalizedConfig.output.distPath.root,
1120
- 'browser',
1121
- `list-${Date.now()}`,
1122
- );
1123
-
1124
- // Create a simplified browser runtime for collect mode
1125
- let runtime: BrowserRuntime;
1126
- try {
1127
- runtime = await createBrowserRuntime({
1128
- context,
1129
- projectEntries,
1130
- browserProjects,
1131
- shardedEntries: options?.shardedEntries,
1132
- freezeShardedEntries: options?.freezeShardedEntries,
1133
- tempDir,
1134
- isWatchMode: false,
1135
- containerDistPath: undefined,
1136
- containerDevServer: undefined,
1137
- forceHeadless: true, // Always use headless for list command
1138
- skipProviderLaunch: options?.filesOnly,
1139
- appliedModifyRstestConfigEnvironments:
1140
- options?.appliedModifyRstestConfigEnvironments,
1141
- });
1142
- } catch (error) {
1143
- const providers = [
1144
- ...new Set(
1145
- browserProjects.map((p) => p.normalizedConfig.browser.provider),
1146
- ),
1147
- ];
1148
- logger.error(
1149
- color.red(
1150
- `Failed to initialize browser provider runtime (${providers.join(', ')}).`,
1151
- ),
1152
- error,
1153
- );
1154
- throw error;
1155
- }
1156
-
1157
- if (options?.filesOnly) {
1158
- const list = runtime.projectEntries.flatMap((entry) =>
1159
- entry.testFiles.map((testPath) => ({
1160
- testPath,
1161
- project: entry.project.name,
1162
- tests: [],
1163
- })),
1164
- );
1165
- await destroyBrowserRuntime(runtime);
1166
- return {
1167
- list,
1168
- close: async () => {},
1169
- };
1170
- }
1171
-
1172
- if (!runtime.projectEntries.some((entry) => entry.testFiles.length > 0)) {
1173
- await destroyBrowserRuntime(runtime);
1174
- return {
1175
- list: [],
1176
- close: async () => {},
1177
- };
1178
- }
1179
-
1180
- const { browser, browserLaunchOptions } = runtime;
1181
-
1182
- // Get browser projects for runtime config
1183
- // Normalize projectRoot to posix format for cross-platform compatibility
1184
- const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
1185
- (project: ProjectContext) => ({
1186
- name: project.name,
1187
- environmentName: project.environmentName,
1188
- projectRoot: normalize(project.rootPath),
1189
- runtimeConfig: serializableConfig(
1190
- projectRuntimeConfig(project, {
1191
- envMode: 'static',
1192
- envOverlay: options?.env,
1193
- }),
1194
- ),
1195
- viewport: project.normalizedConfig.browser.viewport,
1196
- }),
1197
- );
1198
-
1199
- const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
1200
-
1201
- const hostOptions: BrowserHostConfig = {
1202
- rootPath: normalize(context.rootPath),
1203
- projects: projectRuntimeConfigs,
1204
- snapshot: {
1205
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
1206
- },
1207
- mode: 'collect', // Use collect mode
1208
- debug: isDebug(),
1209
- rpcTimeout: maxTestTimeoutForRpc,
1210
- };
1211
-
1212
- runtime.setContainerOptions(hostOptions);
1213
-
1214
- // Collect results across every project's isolated dev server. Each server
1215
- // serves only its own project's manifest, so collection navigates one page
1216
- // per project; each page returns its own results, aggregated afterwards.
1217
- const browserContext = await browser.newContext({
1218
- providerOptions: browserLaunchOptions.providerOptions,
1219
- viewport: null,
1220
- });
1221
-
1222
- const serializedOptions = serializeForInlineScript(hostOptions);
1223
-
1224
- // Per-page collect watchdog: a test file whose module evaluation stalls must
1225
- // not hang `rstest list` forever.
1226
- const collectTimeoutMs = 30_000;
1227
-
1228
- const collectFromServer = async (
1229
- server: BrowserProjectServer,
1230
- ): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
1231
- const results: ListCommandResult[] = [];
1232
- let error: Error | null = null;
1233
- let collectCompleted = false;
1234
- let resolveCollect: (() => void) | undefined;
1235
- const collectPromise = new Promise<void>((resolve) => {
1236
- resolveCollect = resolve;
1237
- });
1238
-
1239
- const page = await browserContext.newPage();
1240
-
1241
- // Expose dispatch function for browser client to send messages
1242
- await page.exposeFunction(
1243
- DISPATCH_MESSAGE_TYPE,
1244
- (message: { type: string; payload?: unknown }) => {
1245
- switch (message.type) {
1246
- case 'collect-result': {
1247
- const payload = message.payload as {
1248
- testPath: string;
1249
- project: string;
1250
- tests: Test[];
1251
- };
1252
- results.push({
1253
- testPath: payload.testPath,
1254
- project: payload.project,
1255
- tests: payload.tests,
1256
- });
1257
- break;
1258
- }
1259
- case 'collect-complete':
1260
- collectCompleted = true;
1261
- resolveCollect?.();
1262
- break;
1263
- case 'fatal': {
1264
- const payload = message.payload as {
1265
- message: string;
1266
- stack?: string;
1267
- };
1268
- error = new Error(payload.message);
1269
- error.stack = payload.stack;
1270
- resolveCollect?.();
1271
- break;
1272
- }
1273
- case 'ready':
1274
- case 'log':
1275
- // Ignore these messages during collection
1276
- break;
1277
- default:
1278
- // Log unexpected messages for debugging
1279
- logger.debug(`[List] Unexpected message: ${message.type}`);
1280
- }
1281
- },
1282
- );
1283
-
1284
- // Inject host options before navigation so the runner can access them
1285
- await page.addInitScript(
1286
- `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
1287
- );
1288
-
1289
- // Navigate to this project's runner page
1290
- await page.goto(`http://localhost:${server.port}/runner.html`, {
1291
- waitUntil: 'load',
1292
- });
1293
-
1294
- // Wait for collection to complete with the shared collect timeout.
1295
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
1296
- const timeoutPromise = new Promise<void>((resolve) => {
1297
- timeoutId = setTimeout(() => {
1298
- if (!collectCompleted) {
1299
- logger.warn(
1300
- color.yellow(
1301
- `[List] Browser test collection timed out after ${collectTimeoutMs}ms`,
1302
- ),
1303
- );
1304
- }
1305
- resolve();
1306
- }, collectTimeoutMs);
1307
- });
1308
-
1309
- await Promise.race([collectPromise, timeoutPromise]);
1310
-
1311
- // Clear timeout to prevent Node.js from waiting for it
1312
- if (timeoutId) {
1313
- clearTimeout(timeoutId);
1314
- }
1315
-
1316
- await page.close().catch(() => {});
1317
- return { results, error };
1318
- };
1319
-
1320
- // Collect every project concurrently — each navigates its own page against
1321
- // its own dev server and returns its own results.
1322
- const collected = await Promise.all(
1323
- [...runtime.projectServers.values()].map((server) =>
1324
- collectFromServer(server),
1325
- ),
1326
- );
1327
- const collectResults = collected.flatMap((entry) => entry.results);
1328
- const fatalError = collected.find((entry) => entry.error)?.error ?? null;
1329
-
1330
- // Cleanup
1331
- const cleanup = async () => {
1332
- try {
1333
- await browserContext.close();
1334
- } catch {
1335
- // ignore
1336
- }
1337
- await destroyBrowserRuntime(runtime);
1338
- };
1339
-
1340
- if (fatalError) {
1341
- await cleanup();
1342
- // Return error in the result format instead of throwing
1343
- const errorResult: ListCommandResult = {
1344
- testPath: '',
1345
- project: '',
1346
- tests: [],
1347
- errors: [
1348
- {
1349
- name: 'BrowserCollectError',
1350
- message: fatalError.message,
1351
- stack: fatalError.stack,
1352
- } as FormattedError,
1353
- ],
1354
- };
1355
- return {
1356
- list: [errorResult],
1357
- close: async () => {},
1358
- };
1359
- }
1360
-
1361
- return {
1362
- list: collectResults,
1363
- close: cleanup,
1364
- };
1365
- };