@rstest/browser 0.9.10 → 0.10.1

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 (37) hide show
  1. package/dist/augmentExpect.d.ts +1 -1
  2. package/dist/browser-container/container-static/js/{102.e63e4aa16d.js → 188.480c3c49b5.js} +4543 -4250
  3. package/dist/browser-container/container-static/js/188.480c3c49b5.js.LICENSE.txt +1 -0
  4. package/dist/browser-container/container-static/js/{index.af52585634.js → index.29fc60be33.js} +10 -10
  5. package/dist/browser-container/container-static/js/{lib-react.9e703e1b29.js → lib-react.5ae9b90ee5.js} +24 -24
  6. package/dist/browser-container/container-static/js/lib-react.5ae9b90ee5.js.LICENSE.txt +1 -0
  7. package/dist/browser-container/index.html +1 -1
  8. package/dist/browser.d.ts +2 -2
  9. package/dist/client/api.d.ts +2 -2
  10. package/dist/client/browserRpc.d.ts +2 -2
  11. package/dist/client/dispatchTransport.d.ts +1 -1
  12. package/dist/client/locator.d.ts +2 -2
  13. package/dist/concurrency.d.ts +1 -1
  14. package/dist/dispatchCapabilities.d.ts +2 -2
  15. package/dist/dispatchRouter.d.ts +1 -1
  16. package/dist/headlessTransport.d.ts +2 -2
  17. package/dist/hostController.d.ts +2 -2
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +205 -23
  20. package/dist/protocol.d.ts +6 -2
  21. package/dist/providers/index.d.ts +1 -1
  22. package/dist/providers/playwright/compileLocator.d.ts +1 -1
  23. package/dist/providers/playwright/dispatchBrowserRpc.d.ts +1 -1
  24. package/dist/providers/playwright/expectUtils.d.ts +1 -1
  25. package/dist/providers/playwright/implementation.d.ts +1 -1
  26. package/dist/providers/playwright/index.d.ts +1 -1
  27. package/dist/providers/playwright/runtime.d.ts +1 -1
  28. package/dist/providers/playwright/textMatcher.d.ts +2 -2
  29. package/dist/sessionRegistry.d.ts +1 -1
  30. package/dist/viewportPresets.d.ts +1 -1
  31. package/dist/watchRerunPlanner.d.ts +1 -1
  32. package/package.json +9 -9
  33. package/src/client/entry.ts +75 -14
  34. package/src/hostController.ts +405 -70
  35. package/src/protocol.ts +4 -0
  36. package/dist/browser-container/container-static/js/102.e63e4aa16d.js.LICENSE.txt +0 -1
  37. package/dist/browser-container/container-static/js/lib-react.9e703e1b29.js.LICENSE.txt +0 -1
@@ -12,16 +12,19 @@ import {
12
12
  color,
13
13
  createCoverageProvider,
14
14
  type FormattedError,
15
+ getNoTestFilesMessage,
15
16
  getSetupFiles,
16
17
  getTestEntries,
17
18
  isDebug,
18
19
  type ListCommandResult,
19
20
  loadCoverageProvider,
20
21
  logger,
22
+ PhaseTracker,
21
23
  type ProjectContext,
22
24
  type Reporter,
23
25
  type Rstest,
24
26
  type RuntimeConfig,
27
+ resolveProjectBuildCache,
25
28
  rsbuild,
26
29
  serializableConfig,
27
30
  type Test,
@@ -94,6 +97,18 @@ type RsbuildInstance = rsbuild.RsbuildInstance;
94
97
  const __dirname = dirname(fileURLToPath(import.meta.url));
95
98
  const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
96
99
 
100
+ /**
101
+ * Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
102
+ * mode. Browser host runs every test file inside the same Node process, so
103
+ * without an override every file would emit events under the same `pid` and
104
+ * share a single track labelled `worker <hostPid>`. Giving each file its own
105
+ * synthetic `pid` makes the track title surface the file path instead,
106
+ * matching node mode's default `isolate: true` behavior. The 1_000_000_000
107
+ * base keeps each synthetic `pid` well clear of real OS `pid` values in
108
+ * mixed-mode traces.
109
+ */
110
+ let nextBrowserFilePid = 1_000_000_000;
111
+
97
112
  /**
98
113
  * Serialize JSON for inline <script> injection.
99
114
  * Escapes '<' to prevent accidental </script> break-out.
@@ -154,6 +169,10 @@ type TestFileStartPayload = {
154
169
  type LogPayload = {
155
170
  level: 'log' | 'warn' | 'error' | 'info' | 'debug';
156
171
  content: string;
172
+ taskId?: string;
173
+ taskName?: string;
174
+ taskParentNames?: string[];
175
+ taskType?: 'file' | 'suite' | 'case';
157
176
  testPath: string;
158
177
  type: 'stdout' | 'stderr';
159
178
  trace?: string;
@@ -186,6 +205,14 @@ type DeferredPromise<T> = {
186
205
  reject: (reason?: unknown) => void;
187
206
  };
188
207
 
208
+ const getFileTaskId = (testPath: string): string => {
209
+ return `file:${testPath}`;
210
+ };
211
+
212
+ const getBufferedLogTaskId = (log: UserConsoleLog): string => {
213
+ return log.taskId ?? getFileTaskId(log.testPath);
214
+ };
215
+
189
216
  const createDeferredPromise = <T>(): DeferredPromise<T> => {
190
217
  let resolve!: DeferredPromise<T>['resolve'];
191
218
  let reject!: DeferredPromise<T>['reject'];
@@ -795,12 +822,20 @@ const getRuntimeConfigFromProject = (
795
822
  env,
796
823
  bail,
797
824
  logHeapUsage,
825
+ detectAsyncLeaks,
798
826
  chaiConfig,
799
827
  includeTaskLocation,
828
+ silent,
800
829
  } = project.normalizedConfig;
801
830
 
802
831
  return {
803
- env,
832
+ // Propagate NODE_ENV from the host so `import.meta.env.NODE_ENV` resolves
833
+ // to `'test'` in browser tests (matches Node mode). User-supplied `env`
834
+ // wins so explicit overrides still take effect.
835
+ env: {
836
+ NODE_ENV: process.env.NODE_ENV,
837
+ ...env,
838
+ },
804
839
  testNamePattern,
805
840
  testTimeout,
806
841
  hookTimeout,
@@ -821,8 +856,10 @@ const getRuntimeConfigFromProject = (
821
856
  snapshotFormat,
822
857
  bail,
823
858
  logHeapUsage,
859
+ detectAsyncLeaks,
824
860
  chaiConfig,
825
861
  includeTaskLocation,
862
+ silent,
826
863
  };
827
864
  };
828
865
 
@@ -916,6 +953,7 @@ const collectProjectEntries = async (
916
953
  rootPath: context.rootPath,
917
954
  projectRoot: project.rootPath,
918
955
  fileFilters: context.fileFilters || [],
956
+ fileFilterMode: context.fileFilterMode,
919
957
  });
920
958
 
921
959
  const setup = getSetupFiles(setupFiles, project.rootPath);
@@ -1280,6 +1318,10 @@ const createBrowserRuntime = async ({
1280
1318
  }
1281
1319
 
1282
1320
  const userRsbuildConfig = project.normalizedConfig;
1321
+ const buildCache = resolveProjectBuildCache({
1322
+ context,
1323
+ project,
1324
+ });
1283
1325
  const setupFiles = Object.values(
1284
1326
  getSetupFiles(
1285
1327
  project.normalizedConfig.setupFiles,
@@ -1287,53 +1329,65 @@ const createBrowserRuntime = async ({
1287
1329
  ),
1288
1330
  );
1289
1331
  // Merge order: current config -> userConfig -> rstest required config (highest priority)
1290
- const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
1291
- resolve: {
1292
- alias: rstestInternalAliases,
1332
+ const merged = mergeEnvironmentConfig(
1333
+ config,
1334
+ {
1335
+ ...userRsbuildConfig,
1336
+ performance: buildCache
1337
+ ? {
1338
+ ...userRsbuildConfig.performance,
1339
+ buildCache,
1340
+ }
1341
+ : userRsbuildConfig.performance,
1293
1342
  },
1294
- source: {
1295
- define: {
1296
- 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1297
- 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1343
+ {
1344
+ resolve: {
1345
+ alias: rstestInternalAliases,
1298
1346
  },
1299
- },
1300
- output: {
1301
- target: 'web',
1302
- // Enable source map for inline snapshot support
1303
- sourceMap: {
1304
- js: 'source-map',
1347
+ source: {
1348
+ define: {
1349
+ 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1350
+ 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1351
+ },
1305
1352
  },
1306
- },
1307
- tools: {
1308
- rspack: (rspackConfig) => {
1309
- rspackConfig.mode = 'development';
1310
- rspackConfig.lazyCompilation =
1311
- createBrowserLazyCompilationConfig(setupFiles);
1312
- rspackConfig.plugins = rspackConfig.plugins || [];
1313
- rspackConfig.plugins.push(virtualManifestPlugin);
1314
-
1315
- applyDefaultWatchOptions(rspackConfig, isWatchMode);
1316
-
1317
- // Extract and merge sourcemaps from pre-built @rstest/core files
1318
- // This preserves the sourcemap chain for inline snapshot support
1319
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1320
- const browserRuntimeDir = dirname(browserRuntimePath);
1321
- rspackConfig.module = rspackConfig.module || {};
1322
- rspackConfig.module.rules = rspackConfig.module.rules || [];
1323
- rspackConfig.module.rules.unshift({
1324
- test: /\.js$/,
1325
- include: browserRuntimeDir,
1326
- extractSourceMap: true,
1327
- });
1328
-
1329
- if (isDebug()) {
1330
- logger.log(
1331
- `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1332
- );
1333
- }
1353
+ output: {
1354
+ target: 'web',
1355
+ // Enable source map for inline snapshot support
1356
+ sourceMap: {
1357
+ js: 'source-map',
1358
+ },
1359
+ },
1360
+ tools: {
1361
+ rspack: (rspackConfig) => {
1362
+ rspackConfig.mode = 'development';
1363
+ rspackConfig.lazyCompilation =
1364
+ createBrowserLazyCompilationConfig(setupFiles);
1365
+ rspackConfig.plugins = rspackConfig.plugins || [];
1366
+ rspackConfig.plugins.push(virtualManifestPlugin);
1367
+
1368
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
1369
+
1370
+ // Extract and merge sourcemaps from pre-built @rstest/core files
1371
+ // This preserves the sourcemap chain for inline snapshot support
1372
+ // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1373
+ const browserRuntimeDir = dirname(browserRuntimePath);
1374
+ rspackConfig.module = rspackConfig.module || {};
1375
+ rspackConfig.module.rules = rspackConfig.module.rules || [];
1376
+ rspackConfig.module.rules.unshift({
1377
+ test: /\.js$/,
1378
+ include: browserRuntimeDir,
1379
+ extractSourceMap: true,
1380
+ });
1381
+
1382
+ if (isDebug()) {
1383
+ logger.log(
1384
+ `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1385
+ );
1386
+ }
1387
+ },
1334
1388
  },
1335
1389
  },
1336
- });
1390
+ );
1337
1391
 
1338
1392
  // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1339
1393
  // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
@@ -1655,8 +1709,22 @@ export const runBrowserController = async (
1655
1709
  context: Rstest,
1656
1710
  options?: BrowserTestRunOptions,
1657
1711
  ): Promise<BrowserTestRunResult | void> => {
1658
- const { skipOnTestRunEnd = false } = options ?? {};
1712
+ const {
1713
+ skipOnTestRunEnd = false,
1714
+ allowEmptyWatchRun = false,
1715
+ onTraceEvents,
1716
+ } = options ?? {};
1659
1717
  const buildStart = Date.now();
1718
+ const isWatchMode = context.command === 'watch';
1719
+
1720
+ // Per-file PhaseTrackers, populated only when `--trace` is on (caller
1721
+ // passes `onTraceEvents`). The browser host shares one Node process across
1722
+ // every test file, so each tracker is assigned a synthetic per-file pid
1723
+ // (`nextBrowserFilePid`) that lets Perfetto render each file as its own
1724
+ // process track with the file path as the title.
1725
+ const phaseTrackers = onTraceEvents
1726
+ ? new Map<string, PhaseTracker>()
1727
+ : undefined;
1660
1728
  const browserProjects = getBrowserProjects(context);
1661
1729
  const useHeadlessDirect = browserProjects.every(
1662
1730
  (project) => project.normalizedConfig.browser.headless,
@@ -1873,27 +1941,47 @@ export const runBrowserController = async (
1873
1941
  (total, item) => total + item.testFiles.length,
1874
1942
  0,
1875
1943
  );
1944
+ const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
1876
1945
 
1877
1946
  if (totalTests === 0) {
1878
1947
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
1879
1948
  if (!skipOnTestRunEnd) {
1880
- const message = `No test files found, exiting with code ${code}.`;
1949
+ const message = shouldKeepWatchingWithEmptySet
1950
+ ? 'No test files found.'
1951
+ : getNoTestFilesMessage({
1952
+ context,
1953
+ code,
1954
+ defaultMessage: `No test files found, exiting with code ${code}.`,
1955
+ });
1881
1956
  if (code === 0) {
1882
1957
  logger.log(color.yellow(message));
1883
1958
  } else {
1884
1959
  logger.error(color.red(message));
1885
1960
  }
1961
+
1962
+ if (context.relatedFilters?.length) {
1963
+ logger.log(
1964
+ color.gray('related: '),
1965
+ context.relatedFilters.join(color.gray(', ')),
1966
+ );
1967
+ } else if (context.fileFilters?.length) {
1968
+ logger.log(
1969
+ color.gray('filter: '),
1970
+ context.fileFilters.join(color.gray(', ')),
1971
+ );
1972
+ }
1886
1973
  }
1887
1974
 
1888
- if (code !== 0) {
1975
+ if (code !== 0 && !shouldKeepWatchingWithEmptySet) {
1889
1976
  ensureProcessExitCode(code);
1890
1977
  }
1891
- return;
1978
+ if (!shouldKeepWatchingWithEmptySet) {
1979
+ return;
1980
+ }
1892
1981
  }
1893
1982
 
1894
1983
  await notifyTestRunStart();
1895
1984
 
1896
- const isWatchMode = context.command === 'watch';
1897
1985
  const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
1898
1986
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
1899
1987
  const tempDir =
@@ -2124,9 +2212,21 @@ export const runBrowserController = async (
2124
2212
  const handleTestFileStart = async (
2125
2213
  payload: TestFileStartPayload,
2126
2214
  ): Promise<void> => {
2215
+ if (phaseTrackers) {
2216
+ const tracker = new PhaseTracker({
2217
+ trace: {
2218
+ testPath: payload.testPath,
2219
+ project: payload.projectName,
2220
+ },
2221
+ pid: nextBrowserFilePid++,
2222
+ });
2223
+ tracker.transition('prepare');
2224
+ phaseTrackers.set(payload.testPath, tracker);
2225
+ }
2127
2226
  await Promise.all(
2128
2227
  context.reporters.map((reporter) =>
2129
2228
  (reporter as Reporter).onTestFileStart?.({
2229
+ testId: getFileTaskId(payload.testPath),
2130
2230
  testPath: payload.testPath,
2131
2231
  tests: [],
2132
2232
  }),
@@ -2137,6 +2237,7 @@ export const runBrowserController = async (
2137
2237
  const handleTestFileReady = async (
2138
2238
  payload: TestFileReadyPayload,
2139
2239
  ): Promise<void> => {
2240
+ phaseTrackers?.get(payload.testPath)?.transition('tests');
2140
2241
  await Promise.all(
2141
2242
  context.reporters.map((reporter) =>
2142
2243
  (reporter as Reporter).onTestFileReady?.(payload),
@@ -2147,6 +2248,7 @@ export const runBrowserController = async (
2147
2248
  const handleTestSuiteStart = async (
2148
2249
  payload: TestSuiteStartPayload,
2149
2250
  ): Promise<void> => {
2251
+ phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
2150
2252
  await Promise.all(
2151
2253
  context.reporters.map((reporter) =>
2152
2254
  (reporter as Reporter).onTestSuiteStart?.(payload),
@@ -2157,16 +2259,28 @@ export const runBrowserController = async (
2157
2259
  const handleTestSuiteResult = async (
2158
2260
  payload: TestSuiteResultPayload,
2159
2261
  ): Promise<void> => {
2262
+ phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
2160
2263
  await Promise.all(
2161
2264
  context.reporters.map((reporter) =>
2162
2265
  (reporter as Reporter).onTestSuiteResult?.(payload),
2163
2266
  ),
2164
2267
  );
2268
+
2269
+ if (context.normalizedConfig.silent === 'passed-only') {
2270
+ await flushBufferedLogsForTask({
2271
+ taskId: payload.testId,
2272
+ status: payload.status,
2273
+ taskParentNames: payload.parentNames,
2274
+ taskType: 'suite',
2275
+ testPath: payload.testPath,
2276
+ });
2277
+ }
2165
2278
  };
2166
2279
 
2167
2280
  const handleTestCaseStart = async (
2168
2281
  payload: TestCaseStartPayload,
2169
2282
  ): Promise<void> => {
2283
+ phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
2170
2284
  await Promise.all(
2171
2285
  context.reporters.map((reporter) =>
2172
2286
  (reporter as Reporter).onTestCaseStart?.(payload),
@@ -2176,11 +2290,22 @@ export const runBrowserController = async (
2176
2290
 
2177
2291
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2178
2292
  caseResults.push(payload);
2293
+ phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
2179
2294
  await Promise.all(
2180
2295
  context.reporters.map((reporter) =>
2181
2296
  (reporter as Reporter).onTestCaseResult?.(payload),
2182
2297
  ),
2183
2298
  );
2299
+
2300
+ if (context.normalizedConfig.silent === 'passed-only') {
2301
+ await flushBufferedLogsForTask({
2302
+ taskId: payload.testId,
2303
+ status: payload.status,
2304
+ taskParentNames: payload.parentNames,
2305
+ taskType: 'case',
2306
+ testPath: payload.testPath,
2307
+ });
2308
+ }
2184
2309
  };
2185
2310
 
2186
2311
  const handleTestFileComplete = async (
@@ -2191,6 +2316,27 @@ export const runBrowserController = async (
2191
2316
  if (payload.snapshotResult) {
2192
2317
  context.snapshotManager.add(payload.snapshotResult);
2193
2318
  }
2319
+
2320
+ if (phaseTrackers) {
2321
+ const tracker = phaseTrackers.get(payload.testPath);
2322
+ if (tracker) {
2323
+ tracker.end();
2324
+ const events = tracker.getTraceEvents();
2325
+ if (events) onTraceEvents?.(events);
2326
+ phaseTrackers.delete(payload.testPath);
2327
+ }
2328
+ }
2329
+
2330
+ if (context.normalizedConfig.silent === 'passed-only') {
2331
+ await flushBufferedLogsForTask({
2332
+ taskId: payload.testId,
2333
+ status: payload.status,
2334
+ taskParentNames: payload.parentNames,
2335
+ taskType: 'file',
2336
+ testPath: payload.testPath,
2337
+ });
2338
+ }
2339
+
2194
2340
  await Promise.all(
2195
2341
  context.reporters.map((reporter) =>
2196
2342
  (reporter as Reporter).onTestFileResult?.(payload),
@@ -2205,19 +2351,28 @@ export const runBrowserController = async (
2205
2351
  const log: UserConsoleLog = {
2206
2352
  content: payload.content,
2207
2353
  name: payload.level,
2354
+ taskId: payload.taskId,
2355
+ taskName: payload.taskName,
2356
+ taskParentNames: payload.taskParentNames,
2357
+ taskType: payload.taskType,
2208
2358
  testPath: payload.testPath,
2209
2359
  type: payload.type,
2210
2360
  trace: payload.trace,
2211
2361
  };
2212
- const shouldLog =
2213
- context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
2214
- if (shouldLog) {
2215
- await Promise.all(
2216
- context.reporters.map((reporter) =>
2217
- (reporter as Reporter).onUserConsoleLog?.(log),
2218
- ),
2219
- );
2362
+ if (context.normalizedConfig.silent === true) {
2363
+ return;
2364
+ }
2365
+
2366
+ if (context.normalizedConfig.silent === 'passed-only') {
2367
+ bufferConsoleLog(log);
2368
+ return;
2369
+ }
2370
+
2371
+ if (context.normalizedConfig.disableConsoleIntercept) {
2372
+ return;
2220
2373
  }
2374
+
2375
+ await emitUserConsoleLog(log);
2221
2376
  };
2222
2377
 
2223
2378
  const handleFatal = async (payload: FatalPayload): Promise<void> => {
@@ -2227,6 +2382,113 @@ export const runBrowserController = async (
2227
2382
  ensureProcessExitCode(1);
2228
2383
  };
2229
2384
 
2385
+ const bufferedConsoleLogs = new Map<string, UserConsoleLog[]>();
2386
+ const suiteIdsByChain = new Map<string, string>();
2387
+
2388
+ const getSuiteChainKey = (names: string[]): string => {
2389
+ return names.join('\u0000');
2390
+ };
2391
+
2392
+ const pushTaskId = (taskIds: string[], taskId: string): void => {
2393
+ if (!taskIds.includes(taskId)) {
2394
+ taskIds.push(taskId);
2395
+ }
2396
+ };
2397
+
2398
+ const shouldEmitUserConsoleLog = (log: UserConsoleLog): boolean => {
2399
+ return context.normalizedConfig.onConsoleLog?.(log.content) !== false;
2400
+ };
2401
+
2402
+ const emitUserConsoleLog = async (log: UserConsoleLog): Promise<void> => {
2403
+ if (!shouldEmitUserConsoleLog(log)) {
2404
+ return;
2405
+ }
2406
+
2407
+ await Promise.all(
2408
+ context.reporters.map((reporter) =>
2409
+ (reporter as Reporter).onUserConsoleLog?.(log),
2410
+ ),
2411
+ );
2412
+ };
2413
+
2414
+ const bufferConsoleLog = (log: UserConsoleLog): void => {
2415
+ const taskId = getBufferedLogTaskId(log);
2416
+ const logs = bufferedConsoleLogs.get(taskId) || [];
2417
+ logs.push(log);
2418
+ bufferedConsoleLogs.set(taskId, logs);
2419
+
2420
+ if (log.taskType === 'suite' && log.taskId) {
2421
+ suiteIdsByChain.set(
2422
+ getSuiteChainKey([...(log.taskParentNames || []), log.taskName || '']),
2423
+ log.taskId,
2424
+ );
2425
+ }
2426
+ };
2427
+
2428
+ const flushBufferedLogsForTask = async ({
2429
+ taskId,
2430
+ status,
2431
+ taskParentNames,
2432
+ taskType,
2433
+ testPath,
2434
+ }: {
2435
+ taskId: string;
2436
+ status: TestResult['status'];
2437
+ taskParentNames?: string[];
2438
+ taskType?: 'file' | 'suite' | 'case';
2439
+ testPath: string;
2440
+ }): Promise<void> => {
2441
+ if (status !== 'fail') {
2442
+ bufferedConsoleLogs.delete(taskId);
2443
+ return;
2444
+ }
2445
+
2446
+ const taskIdsToFlush: string[] = [];
2447
+
2448
+ if (taskType === 'case') {
2449
+ pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
2450
+
2451
+ const suiteNames = taskParentNames || [];
2452
+ for (let i = 0; i < suiteNames.length; i++) {
2453
+ const suiteId = suiteIdsByChain.get(
2454
+ getSuiteChainKey(suiteNames.slice(0, i + 1)),
2455
+ );
2456
+
2457
+ if (suiteId) {
2458
+ pushTaskId(taskIdsToFlush, suiteId);
2459
+ }
2460
+ }
2461
+
2462
+ pushTaskId(taskIdsToFlush, taskId);
2463
+ }
2464
+
2465
+ if (taskType === 'suite') {
2466
+ pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
2467
+ pushTaskId(taskIdsToFlush, taskId);
2468
+ }
2469
+
2470
+ if (taskType === 'file') {
2471
+ pushTaskId(taskIdsToFlush, taskId);
2472
+ }
2473
+
2474
+ for (const bufferedTaskId of taskIdsToFlush) {
2475
+ const logs = bufferedConsoleLogs.get(bufferedTaskId);
2476
+ if (!logs) {
2477
+ continue;
2478
+ }
2479
+
2480
+ bufferedConsoleLogs.delete(bufferedTaskId);
2481
+
2482
+ for (const log of logs) {
2483
+ await Promise.all(
2484
+ context.reporters.map((reporter) =>
2485
+ (reporter as Reporter).onUserConsoleLog?.(log),
2486
+ ),
2487
+ );
2488
+ }
2489
+ }
2490
+ };
2491
+
2230
2492
  const runSnapshotRpc = async (
2231
2493
  request: SnapshotRpcRequest,
2232
2494
  ): Promise<unknown> => {
@@ -2613,6 +2875,69 @@ export const runBrowserController = async (
2613
2875
  },
2614
2876
  });
2615
2877
 
2878
+ if (allTestFiles.length === 0) {
2879
+ const duration = {
2880
+ totalTime: buildTime,
2881
+ buildTime,
2882
+ testTime: 0,
2883
+ };
2884
+ const result = {
2885
+ results: reporterResults,
2886
+ testResults: caseResults,
2887
+ duration,
2888
+ hasFailure: false,
2889
+ getSourcemap: getBrowserSourcemap,
2890
+ resolveSourcemap: resolveBrowserSourcemap,
2891
+ close: skipOnTestRunEnd
2892
+ ? async () => {
2893
+ sessionRegistry.clear();
2894
+ await destroyBrowserRuntime(runtime);
2895
+ }
2896
+ : undefined,
2897
+ };
2898
+
2899
+ if (!skipOnTestRunEnd) {
2900
+ await notifyTestRunEnd({ duration });
2901
+ }
2902
+
2903
+ if (isWatchMode) {
2904
+ triggerRerun = async () => {
2905
+ const newProjectEntries = await collectProjectEntries(context);
2906
+ const rerunPlan = planWatchRerun({
2907
+ projectEntries: newProjectEntries,
2908
+ previousTestFiles: watchContext.lastTestFiles,
2909
+ affectedTestFiles: watchContext.affectedTestFiles,
2910
+ });
2911
+ watchContext.affectedTestFiles = [];
2912
+
2913
+ if (rerunPlan.filesChanged) {
2914
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
2915
+ if (rerunPlan.currentTestFiles.length === 0) {
2916
+ logger.log(
2917
+ color.cyan('No browser test files remain after update.\n'),
2918
+ );
2919
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2920
+ return;
2921
+ }
2922
+
2923
+ logger.log(
2924
+ color.cyan(
2925
+ `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
2926
+ ),
2927
+ );
2928
+ void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
2929
+ return;
2930
+ }
2931
+
2932
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2933
+ };
2934
+ watchContext.hooksEnabled = true;
2935
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2936
+ }
2937
+
2938
+ return result;
2939
+ }
2940
+
2616
2941
  const testStart = Date.now();
2617
2942
  await runFilesWithPool(allTestFiles);
2618
2943
  const testTime = Date.now() - testStart;
@@ -3089,24 +3414,27 @@ export const runBrowserController = async (
3089
3414
  });
3090
3415
  };
3091
3416
 
3092
- const testStart = Date.now();
3093
- try {
3094
- await waitForRunnerFramesReady(
3095
- currentTestFiles.map((file) => file.testPath),
3096
- );
3417
+ let testTime = 0;
3418
+ if (currentTestFiles.length > 0) {
3419
+ const testStart = Date.now();
3420
+ try {
3421
+ await waitForRunnerFramesReady(
3422
+ currentTestFiles.map((file) => file.testPath),
3423
+ );
3097
3424
 
3098
- for (const file of currentTestFiles) {
3099
- await enqueueHeadedReload(file);
3100
- if (fatalError) {
3101
- break;
3425
+ for (const file of currentTestFiles) {
3426
+ await enqueueHeadedReload(file);
3427
+ if (fatalError) {
3428
+ break;
3429
+ }
3102
3430
  }
3431
+ } catch (error) {
3432
+ fatalError = fatalError ?? toError(error);
3433
+ ensureProcessExitCode(1);
3103
3434
  }
3104
- } catch (error) {
3105
- fatalError = fatalError ?? toError(error);
3106
- ensureProcessExitCode(1);
3107
- }
3108
3435
 
3109
- const testTime = Date.now() - testStart;
3436
+ testTime = Date.now() - testStart;
3437
+ }
3110
3438
 
3111
3439
  // Define rerun logic for watch mode
3112
3440
  if (isWatchMode) {
@@ -3130,6 +3458,13 @@ export const runBrowserController = async (
3130
3458
  watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3131
3459
  currentTestFiles = rerunPlan.currentTestFiles;
3132
3460
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
3461
+ if (currentTestFiles.length === 0) {
3462
+ logger.log(
3463
+ color.cyan('No browser test files remain after update.\n'),
3464
+ );
3465
+ logBrowserWatchReadyMessage(enableCliShortcuts);
3466
+ return;
3467
+ }
3133
3468
  await waitForRunnerFramesReady(
3134
3469
  currentTestFiles.map((file) => file.testPath),
3135
3470
  );
package/src/protocol.ts CHANGED
@@ -106,6 +106,10 @@ export type BrowserClientMessage =
106
106
  payload: {
107
107
  level: 'log' | 'warn' | 'error' | 'info' | 'debug';
108
108
  content: string;
109
+ taskId?: string;
110
+ taskName?: string;
111
+ taskParentNames?: string[];
112
+ taskType?: 'file' | 'suite' | 'case';
109
113
  testPath: string;
110
114
  type: 'stdout' | 'stderr';
111
115
  trace?: string;