@rstest/browser 0.11.4 → 0.11.5

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.
@@ -1789,6 +1789,55 @@ const createBrowserRuntime = async ({
1789
1789
  }
1790
1790
  };
1791
1791
 
1792
+ const serveContainerRoute = async (
1793
+ req: IncomingMessage,
1794
+ res: ServerResponse,
1795
+ next: () => void,
1796
+ ): Promise<void> => {
1797
+ if (!req.url) {
1798
+ next();
1799
+ return;
1800
+ }
1801
+
1802
+ const url = new URL(req.url, 'http://localhost');
1803
+ if (url.pathname === '/') {
1804
+ if (await respondWithDevServerHtml(url, res)) {
1805
+ return;
1806
+ }
1807
+
1808
+ const html =
1809
+ injectedContainerHtml ||
1810
+ containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1811
+
1812
+ if (html) {
1813
+ res.setHeader('Content-Type', 'text/html');
1814
+ res.end(html);
1815
+ return;
1816
+ }
1817
+
1818
+ res.statusCode = 502;
1819
+ res.end('Container UI is not available.');
1820
+ return;
1821
+ }
1822
+
1823
+ if (url.pathname.startsWith('/container-static/')) {
1824
+ if (await proxyDevServerAsset(req, res)) {
1825
+ return;
1826
+ }
1827
+
1828
+ if (serveContainer) {
1829
+ serveContainer(req, res, next);
1830
+ return;
1831
+ }
1832
+
1833
+ res.statusCode = 502;
1834
+ res.end('Container assets are not available.');
1835
+ return;
1836
+ }
1837
+
1838
+ next();
1839
+ };
1840
+
1792
1841
  // ---- Build one isolated rsbuild instance + dev server per project ----
1793
1842
  const buildProjectServer = async (
1794
1843
  project: ProjectContext,
@@ -1846,6 +1895,13 @@ const createBrowserRuntime = async ({
1846
1895
  project.normalizedConfig.browser.port ??
1847
1896
  (isContainerServer ? 4000 : 0),
1848
1897
  strictPort: project.normalizedConfig.browser.strictPort,
1898
+ // User plugins may emit index.html; register before Rsbuild's HTML
1899
+ // completion middleware so `/` remains owned by the Browser UI.
1900
+ setup: isContainerServer
1901
+ ? ({ server }) => {
1902
+ server.middlewares.use(serveContainerRoute);
1903
+ }
1904
+ : undefined,
1849
1905
  },
1850
1906
  dev: createBrowserRsbuildDevConfig(enableHmr),
1851
1907
  environments: {
@@ -2196,43 +2252,6 @@ const createBrowserRuntime = async ({
2196
2252
  }
2197
2253
  return;
2198
2254
  }
2199
- // Container UI HTML + static assets are served by the container origin
2200
- // only. Per-project runner servers expose just /runner.html + assets.
2201
- if (isContainerServer) {
2202
- if (url.pathname === '/') {
2203
- if (await respondWithDevServerHtml(url, res)) {
2204
- return;
2205
- }
2206
-
2207
- const html =
2208
- injectedContainerHtml ||
2209
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
2210
-
2211
- if (html) {
2212
- res.setHeader('Content-Type', 'text/html');
2213
- res.end(html);
2214
- return;
2215
- }
2216
-
2217
- res.statusCode = 502;
2218
- res.end('Container UI is not available.');
2219
- return;
2220
- }
2221
- if (url.pathname.startsWith('/container-static/')) {
2222
- if (await proxyDevServerAsset(req, res)) {
2223
- return;
2224
- }
2225
-
2226
- if (serveContainer) {
2227
- serveContainer(req, res, next);
2228
- return;
2229
- }
2230
-
2231
- res.statusCode = 502;
2232
- res.end('Container assets are not available.');
2233
- return;
2234
- }
2235
- }
2236
2255
  if (url.pathname === '/runner.html') {
2237
2256
  res.setHeader('Content-Type', 'text/html');
2238
2257
  res.end(htmlTemplate);
@@ -2353,7 +2372,6 @@ export const runBrowserController = async (
2353
2372
  options?: BrowserTestRunOptions,
2354
2373
  ): Promise<BrowserTestRunResult | void> => {
2355
2374
  const {
2356
- allowEmptyWatchRun = false,
2357
2375
  allowEmptyRun = false,
2358
2376
  filesOnly = false,
2359
2377
  onTraceEvents,
@@ -2371,10 +2389,13 @@ export const runBrowserController = async (
2371
2389
  // passes `onTraceEvents`). The browser host shares one Node process across
2372
2390
  // every test file, so each tracker is assigned a synthetic per-file pid
2373
2391
  // (`nextBrowserFilePid`) that lets Perfetto render each file as its own
2374
- // process track with the file path as the title.
2392
+ // process track with the file path as the title. Keyed by project + path so
2393
+ // concurrent projects running the same file keep separate trackers.
2375
2394
  const phaseTrackers = onTraceEvents
2376
2395
  ? new Map<string, PhaseTracker>()
2377
2396
  : undefined;
2397
+ const trackerKey = (project: string, testPath: string) =>
2398
+ `${project}\u0000${testPath}`;
2378
2399
  // Explicit projects input (plan output) replaces re-deriving `browser.enabled`
2379
2400
  // projects from `context`, whose `projects` array is mutated during planning.
2380
2401
  // Falls back to re-derivation only when the caller passes no list at all —
@@ -2520,47 +2541,27 @@ export const runBrowserController = async (
2520
2541
 
2521
2542
  const notifyTestRunEnd = async ({
2522
2543
  duration,
2523
- unhandledErrors,
2524
- filterRerunTestPaths,
2544
+ coverage,
2525
2545
  }: {
2526
2546
  duration: {
2527
2547
  totalTime: number;
2528
2548
  buildTime: number;
2529
2549
  testTime: number;
2530
2550
  };
2531
- unhandledErrors?: Error[];
2532
- filterRerunTestPaths?: string[];
2551
+ coverage?: CoverageMapData;
2533
2552
  }): Promise<void> => {
2534
2553
  if (!isWatchMode) {
2535
2554
  return;
2536
2555
  }
2537
2556
 
2538
- // Merge per-file coverage into a single CoverageMapData for reporters
2539
- let mergedCoverage: CoverageMapData | undefined;
2540
- if (coverageProvider) {
2541
- const coverageMap = coverageProvider.createCoverageMap();
2542
- let hasCoverage = false;
2543
- for (const result of context.reporterResults.results) {
2544
- if (result.coverage) {
2545
- coverageMap.merge(result.coverage);
2546
- hasCoverage = true;
2547
- }
2548
- }
2549
- if (hasCoverage) {
2550
- mergedCoverage = coverageMap.toJSON();
2551
- }
2552
- }
2553
-
2554
2557
  for (const reporter of context.reporters) {
2555
2558
  await reporter.onTestRunEnd?.({
2556
2559
  results: context.reporterResults.results,
2557
- coverage: mergedCoverage,
2560
+ coverage,
2558
2561
  testResults: context.reporterResults.testResults,
2559
2562
  duration,
2560
2563
  snapshotSummary: context.snapshotManager.summary,
2561
2564
  getSourcemap: getBrowserSourcemap,
2562
- unhandledErrors,
2563
- filterRerunTestPaths,
2564
2565
  });
2565
2566
  }
2566
2567
  };
@@ -2601,7 +2602,6 @@ export const runBrowserController = async (
2601
2602
  (total, item) => total + item.testFiles.length,
2602
2603
  0,
2603
2604
  );
2604
- const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
2605
2605
  const shouldInitializeEmptyBrowserHooks =
2606
2606
  totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
2607
2607
 
@@ -2621,16 +2621,14 @@ export const runBrowserController = async (
2621
2621
  };
2622
2622
  };
2623
2623
 
2624
- const reportEmptyTestSet = (): boolean => {
2624
+ const reportEmptyTestSet = (): void => {
2625
2625
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
2626
2626
  if (isWatchMode || !allowEmptyRun) {
2627
- const message = shouldKeepWatchingWithEmptySet
2628
- ? 'No test files found.'
2629
- : getNoTestFilesMessage({
2630
- context,
2631
- code,
2632
- defaultMessage: `No test files found, exiting with code ${code}.`,
2633
- });
2627
+ const message = getNoTestFilesMessage({
2628
+ context,
2629
+ code,
2630
+ defaultMessage: `No test files found, exiting with code ${code}.`,
2631
+ });
2634
2632
  if (code === 0) {
2635
2633
  logger.log(color.yellow(message));
2636
2634
  } else {
@@ -2653,22 +2651,14 @@ export const runBrowserController = async (
2653
2651
  // In non-watch runs the host returns a void outcome and core's
2654
2652
  // `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
2655
2653
  // the host must not set the code itself. Watch keeps its own exit code.
2656
- if (
2657
- isWatchMode &&
2658
- code !== 0 &&
2659
- !shouldKeepWatchingWithEmptySet &&
2660
- !allowEmptyRun
2661
- ) {
2654
+ if (isWatchMode && code !== 0 && !allowEmptyRun) {
2662
2655
  ensureProcessExitCode(code);
2663
2656
  }
2664
-
2665
- return !shouldKeepWatchingWithEmptySet;
2666
2657
  };
2667
2658
 
2668
2659
  if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
2669
- if (reportEmptyTestSet()) {
2670
- return allowEmptyRun ? createEmptyRunResult() : undefined;
2671
- }
2660
+ reportEmptyTestSet();
2661
+ return allowEmptyRun ? createEmptyRunResult() : undefined;
2672
2662
  }
2673
2663
 
2674
2664
  if (!filesOnly) {
@@ -2795,23 +2785,19 @@ export const runBrowserController = async (
2795
2785
  unhandledErrors?: Error[];
2796
2786
  }): Promise<void> => {
2797
2787
  const rerunPathSet = new Set(rerunTestPaths);
2798
- // Reporter coverage spans the whole session (unaffected files keep their
2799
- // last coverage), matching the previous self-finalize payload. The merge
2800
- // must not strip `result.coverage`, or later reruns would lose it.
2801
- let sessionCoverage: CoverageMapData | undefined;
2802
- const coverageMap = buildBrowserCoverageMap(
2803
- context.reporterResults.results,
2804
- coverageProvider,
2805
- { keepResultCoverage: true },
2788
+ const rerunResults = context.reporterResults.results.filter((result) =>
2789
+ rerunPathSet.has(result.testPath),
2806
2790
  );
2791
+ // Watch coverage is per-cycle on both transports: only the files this
2792
+ // rerun executed are reported.
2793
+ let rerunCoverage: CoverageMapData | undefined;
2794
+ const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
2807
2795
  if (coverageMap && coverageMap.files().length > 0) {
2808
- sessionCoverage = coverageMap.toJSON();
2796
+ rerunCoverage = coverageMap.toJSON();
2809
2797
  }
2810
2798
 
2811
2799
  const outcome: ExecutorCycleOutcome = {
2812
- results: context.reporterResults.results.filter((result) =>
2813
- rerunPathSet.has(result.testPath),
2814
- ),
2800
+ results: rerunResults,
2815
2801
  testResults: context.reporterResults.testResults.filter((result) =>
2816
2802
  rerunPathSet.has(result.testPath),
2817
2803
  ),
@@ -2821,7 +2807,7 @@ export const runBrowserController = async (
2821
2807
  buildTime: drainPendingBuildTime(watchState),
2822
2808
  testTime,
2823
2809
  },
2824
- coverage: sessionCoverage ? { map: sessionCoverage } : undefined,
2810
+ coverage: rerunCoverage ? { map: rerunCoverage } : undefined,
2825
2811
  resolveSourcemap: resolveBrowserSourcemap,
2826
2812
  };
2827
2813
 
@@ -2858,7 +2844,8 @@ export const runBrowserController = async (
2858
2844
  };
2859
2845
  }
2860
2846
 
2861
- if (totalTests === 0 && reportEmptyTestSet()) {
2847
+ if (totalTests === 0) {
2848
+ reportEmptyTestSet();
2862
2849
  await destroyBrowserRuntime(runtime);
2863
2850
  return allowEmptyRun ? createEmptyRunResult() : undefined;
2864
2851
  }
@@ -3022,22 +3009,18 @@ export const runBrowserController = async (
3022
3009
  createRunnerEventSink(context, project.normalizedConfig),
3023
3010
  ]),
3024
3011
  );
3025
- const firstBrowserSink = runnerSinks.get(browserProjects[0]!.name)!;
3026
-
3027
- // testPath -> owning project name, stamped from the authoritative client
3028
- // file-start event (it carries the manifest-resolved projectName) before any
3029
- // other per-file event for that path fires including on watch reruns, so the
3030
- // mapping stays correct when a rerun adds a file. Fully eliminating this map in
3031
- // favor of a project stamp on every wire event is deferred (it would add
3032
- // `project` to the shared `TestResult`/`TestFileResult` payloads).
3033
- const projectNameByTestPath = new Map<string, string>();
3034
-
3035
- const sinkForProjectName = (projectName: string): RunnerEventSink =>
3036
- runnerSinks.get(projectName) ?? firstBrowserSink;
3037
-
3038
- const sinkForTestPath = (testPath: string): RunnerEventSink => {
3039
- const projectName = projectNameByTestPath.get(testPath);
3040
- return projectName ? sinkForProjectName(projectName) : firstBrowserSink;
3012
+ // Every per-file wire payload carries its owning project name, so routing
3013
+ // never derives a project from a test path — concurrent projects can run the
3014
+ // same file, and a path-keyed lookup would attribute events to the wrong one.
3015
+ // The client resolves its project from the host's own manifest, so a miss is
3016
+ // a protocol bug; fail loudly rather than route through another project's
3017
+ // config.
3018
+ const sinkForProjectName = (projectName: string): RunnerEventSink => {
3019
+ const sink = runnerSinks.get(projectName);
3020
+ if (!sink) {
3021
+ throw new Error(`No runner event sink for project "${projectName}"`);
3022
+ }
3023
+ return sink;
3041
3024
  };
3042
3025
 
3043
3026
  // Silent-console buffering runs through the shared controller — the same
@@ -3054,7 +3037,7 @@ export const runBrowserController = async (
3054
3037
  disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
3055
3038
  },
3056
3039
  emitInterceptedLog: (log) =>
3057
- sinkForTestPath(log.testPath).onConsoleLog(log),
3040
+ sinkForProjectName(log.project).onConsoleLog(log),
3058
3041
  writeOriginalLog: () => {},
3059
3042
  });
3060
3043
 
@@ -3089,7 +3072,6 @@ export const runBrowserController = async (
3089
3072
  const handleTestFileStart = async (
3090
3073
  payload: TestFileStartPayload,
3091
3074
  ): Promise<void> => {
3092
- projectNameByTestPath.set(payload.testPath, payload.projectName);
3093
3075
  if (phaseTrackers) {
3094
3076
  const tracker = new PhaseTracker({
3095
3077
  trace: {
@@ -3099,13 +3081,17 @@ export const runBrowserController = async (
3099
3081
  pid: nextBrowserFilePid++,
3100
3082
  });
3101
3083
  tracker.transition('prepare');
3102
- phaseTrackers.set(payload.testPath, tracker);
3084
+ phaseTrackers.set(
3085
+ trackerKey(payload.projectName, payload.testPath),
3086
+ tracker,
3087
+ );
3103
3088
  }
3104
3089
  // The client sends `{ testPath, projectName }`; the sink adapter builds the
3105
3090
  // `TestFileInfo` the reporters and stateManager expect.
3106
3091
  await sinkForProjectName(payload.projectName).onTestFileStart({
3107
3092
  testId: getFileTaskId(payload.testPath),
3108
3093
  testPath: payload.testPath,
3094
+ project: payload.projectName,
3109
3095
  tests: [],
3110
3096
  });
3111
3097
  };
@@ -3113,22 +3099,28 @@ export const runBrowserController = async (
3113
3099
  const handleTestFileReady = async (
3114
3100
  payload: TestFileReadyPayload,
3115
3101
  ): Promise<void> => {
3116
- phaseTrackers?.get(payload.testPath)?.transition('tests');
3117
- await sinkForTestPath(payload.testPath).onTestFileReady(payload);
3102
+ phaseTrackers
3103
+ ?.get(trackerKey(payload.project, payload.testPath))
3104
+ ?.transition('tests');
3105
+ await sinkForProjectName(payload.project).onTestFileReady(payload);
3118
3106
  };
3119
3107
 
3120
3108
  const handleTestSuiteStart = async (
3121
3109
  payload: TestSuiteStartPayload,
3122
3110
  ): Promise<void> => {
3123
- phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
3124
- await sinkForTestPath(payload.testPath).onTestSuiteStart(payload);
3111
+ phaseTrackers
3112
+ ?.get(trackerKey(payload.project, payload.testPath))
3113
+ ?.recordSuiteStart(payload);
3114
+ await sinkForProjectName(payload.project).onTestSuiteStart(payload);
3125
3115
  };
3126
3116
 
3127
3117
  const handleTestSuiteResult = async (
3128
3118
  payload: TestSuiteResultPayload,
3129
3119
  ): Promise<void> => {
3130
- phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
3131
- await sinkForTestPath(payload.testPath).onTestSuiteResult(payload);
3120
+ phaseTrackers
3121
+ ?.get(trackerKey(payload.project, payload.testPath))
3122
+ ?.recordSuiteResult(payload);
3123
+ await sinkForProjectName(payload.project).onTestSuiteResult(payload);
3132
3124
 
3133
3125
  if (context.normalizedConfig.silent === 'passed-only') {
3134
3126
  silentConsoleController.flushBufferedLogsForTask({
@@ -3144,15 +3136,19 @@ export const runBrowserController = async (
3144
3136
  const handleTestCaseStart = async (
3145
3137
  payload: TestCaseStartPayload,
3146
3138
  ): Promise<void> => {
3147
- phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
3139
+ phaseTrackers
3140
+ ?.get(trackerKey(payload.project, payload.testPath))
3141
+ ?.recordCaseStart(payload);
3148
3142
  // Fire-and-forget on both transports (the sink does not await case-start).
3149
- sinkForTestPath(payload.testPath).onTestCaseStart(payload);
3143
+ sinkForProjectName(payload.project).onTestCaseStart(payload);
3150
3144
  };
3151
3145
 
3152
3146
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
3153
3147
  caseResults.push(payload);
3154
- phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
3155
- await sinkForTestPath(payload.testPath).onTestCaseResult(payload);
3148
+ phaseTrackers
3149
+ ?.get(trackerKey(payload.project, payload.testPath))
3150
+ ?.recordCaseResult(payload);
3151
+ await sinkForProjectName(payload.project).onTestCaseResult(payload);
3156
3152
 
3157
3153
  if (context.normalizedConfig.silent === 'passed-only') {
3158
3154
  silentConsoleController.flushBufferedLogsForTask({
@@ -3172,12 +3168,13 @@ export const runBrowserController = async (
3172
3168
  context.updateReporterResultState([payload], payload.results);
3173
3169
 
3174
3170
  if (phaseTrackers) {
3175
- const tracker = phaseTrackers.get(payload.testPath);
3171
+ const key = trackerKey(payload.project, payload.testPath);
3172
+ const tracker = phaseTrackers.get(key);
3176
3173
  if (tracker) {
3177
3174
  tracker.end();
3178
3175
  const events = tracker.getTraceEvents();
3179
3176
  if (events) onTraceEvents?.(events);
3180
- phaseTrackers.delete(payload.testPath);
3177
+ phaseTrackers.delete(key);
3181
3178
  }
3182
3179
  }
3183
3180
 
@@ -3193,7 +3190,7 @@ export const runBrowserController = async (
3193
3190
 
3194
3191
  // Feeds stateManager, fans out onTestFileResult to reporters, and ingests
3195
3192
  // payload.snapshotResult (the snapshotManager.add moved into the sink).
3196
- await sinkForTestPath(payload.testPath).onTestFileResult(payload);
3193
+ await sinkForProjectName(payload.project).onTestFileResult(payload);
3197
3194
  // In non-watch runs core owns the exit code via `finalizeRunCycle` (the
3198
3195
  // failing file rides the returned outcome); watch reruns set it here.
3199
3196
  if (isWatchMode && payload.status === 'fail') {
@@ -3211,6 +3208,7 @@ export const runBrowserController = async (
3211
3208
  taskParentNames: payload.taskParentNames,
3212
3209
  taskType: payload.taskType,
3213
3210
  testPath: payload.testPath,
3211
+ project: payload.projectName,
3214
3212
  type: payload.type,
3215
3213
  trace: payload.trace,
3216
3214
  };
@@ -3671,71 +3669,6 @@ export const runBrowserController = async (
3671
3669
 
3672
3670
  awaitHeadlessRerunIdle = () => latestRerunScheduler.whenIdle();
3673
3671
 
3674
- if (allTestFiles.length === 0) {
3675
- const duration = {
3676
- totalTime: buildTime,
3677
- buildTime,
3678
- testTime: 0,
3679
- };
3680
- const result = {
3681
- results: reporterResults,
3682
- testResults: caseResults,
3683
- duration,
3684
- hasFailure: false,
3685
- getSourcemap: getBrowserSourcemap,
3686
- resolveSourcemap: resolveBrowserSourcemap,
3687
- close: !isWatchMode
3688
- ? async () => {
3689
- sessionRegistry.clear();
3690
- await destroyBrowserRuntime(runtime);
3691
- }
3692
- : undefined,
3693
- watch: watchHandles,
3694
- };
3695
-
3696
- if (isWatchMode) {
3697
- await notifyTestRunEnd({ duration });
3698
- }
3699
-
3700
- if (isWatchMode) {
3701
- triggerRerun = async () => {
3702
- const newProjectEntries = await collectProjectEntries(context);
3703
- const rerunPlan = planWatchRerun({
3704
- projectEntries: newProjectEntries,
3705
- previousTestFiles: watchState.lastTestFiles,
3706
- affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3707
- });
3708
-
3709
- if (rerunPlan.filesChanged) {
3710
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
3711
- if (rerunPlan.currentTestFiles.length === 0) {
3712
- logger.log(
3713
- color.cyan('No browser test files remain after update.\n'),
3714
- );
3715
- logWatchReadyMessage(context, enableCliShortcuts);
3716
- return;
3717
- }
3718
-
3719
- logger.log(
3720
- color.cyan(
3721
- `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3722
- ),
3723
- );
3724
- await latestRerunScheduler.enqueueLatest(
3725
- rerunPlan.currentTestFiles,
3726
- );
3727
- return;
3728
- }
3729
-
3730
- logWatchReadyMessage(context, enableCliShortcuts);
3731
- };
3732
- watchState.hooksEnabled = true;
3733
- logWatchReadyMessage(context, enableCliShortcuts);
3734
- }
3735
-
3736
- return result;
3737
- }
3738
-
3739
3672
  const testStart = Date.now();
3740
3673
  await runFilesWithPool(allTestFiles);
3741
3674
  const testTime = Date.now() - testStart;
@@ -3823,6 +3756,15 @@ export const runBrowserController = async (
3823
3756
  ensureProcessExitCode(1);
3824
3757
  }
3825
3758
 
3759
+ // Fold and strip the initial cycle's coverage (the same per-cycle fold
3760
+ // reruns get in `finalizeWatchRerun`); the map rides on the result so the
3761
+ // browser-only watch path can report it without re-merging. Non-watch runs
3762
+ // keep `result.coverage` intact for the executor's outcome fold.
3763
+ const cycleCoverageMap =
3764
+ isWatchMode && coverageProvider
3765
+ ? buildBrowserCoverageMap(reporterResults, coverageProvider)
3766
+ : undefined;
3767
+
3826
3768
  const result = {
3827
3769
  results: reporterResults,
3828
3770
  testResults: caseResults,
@@ -3833,12 +3775,18 @@ export const runBrowserController = async (
3833
3775
  // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3834
3776
  // non-watch caller (core) receives the deferred close and watch does not.
3835
3777
  close: closeHeadlessRuntime,
3778
+ coverage: cycleCoverageMap,
3836
3779
  watch: watchHandles,
3837
3780
  };
3838
3781
 
3839
3782
  if (isWatchMode) {
3840
3783
  try {
3841
- await notifyTestRunEnd({ duration });
3784
+ await notifyTestRunEnd({
3785
+ duration,
3786
+ coverage: cycleCoverageMap?.files().length
3787
+ ? cycleCoverageMap.toJSON()
3788
+ : undefined,
3789
+ });
3842
3790
  } finally {
3843
3791
  await closeHeadlessRuntime?.();
3844
3792
  }
@@ -4354,6 +4302,12 @@ export const runBrowserController = async (
4354
4302
  ensureProcessExitCode(1);
4355
4303
  }
4356
4304
 
4305
+ // Same per-cycle fold-and-strip as the headless path above.
4306
+ const cycleCoverageMap =
4307
+ isWatchMode && coverageProvider
4308
+ ? buildBrowserCoverageMap(reporterResults, coverageProvider)
4309
+ : undefined;
4310
+
4357
4311
  const result = {
4358
4312
  results: reporterResults,
4359
4313
  testResults: caseResults,
@@ -4364,12 +4318,18 @@ export const runBrowserController = async (
4364
4318
  // `closeContainerRuntime` is already `undefined` in watch mode, so the
4365
4319
  // non-watch caller (core) receives the deferred close and watch does not.
4366
4320
  close: closeContainerRuntime,
4321
+ coverage: cycleCoverageMap,
4367
4322
  watch: watchHandles,
4368
4323
  };
4369
4324
 
4370
4325
  if (isWatchMode) {
4371
4326
  try {
4372
- await notifyTestRunEnd({ duration });
4327
+ await notifyTestRunEnd({
4328
+ duration,
4329
+ coverage: cycleCoverageMap?.files().length
4330
+ ? cycleCoverageMap.toJSON()
4331
+ : undefined,
4332
+ });
4373
4333
  } finally {
4374
4334
  await closeContainerRuntime?.();
4375
4335
  }
package/src/protocol.ts CHANGED
@@ -69,6 +69,12 @@ export type BrowserExecutionMode = 'run' | 'collect';
69
69
  export type BrowserLogPayload = {
70
70
  level: 'log' | 'warn' | 'error' | 'info' | 'debug';
71
71
  content: string;
72
+ /**
73
+ * Owning project, resolved by the client from its manifest. The host must
74
+ * not re-derive it from `testPath` — concurrent projects can run the same
75
+ * file, so a path-keyed lookup can attribute the log to the wrong project.
76
+ */
77
+ projectName: string;
72
78
  taskId?: string;
73
79
  taskName?: string;
74
80
  taskParentNames?: string[];