@rstest/browser 0.9.9 → 0.10.0

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/{464.89762ff658.js → 484.af891b432a.js} +3183 -3152
  3. package/dist/browser-container/container-static/js/484.af891b432a.js.LICENSE.txt +1 -0
  4. package/dist/browser-container/container-static/js/{index.c13dfe1ecf.js → index.463ae8de87.js} +12 -12
  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 -24
  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 -71
  35. package/src/protocol.ts +4 -0
  36. package/dist/browser-container/container-static/js/464.89762ff658.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'];
@@ -506,8 +533,9 @@ const applyDefaultWatchOptions = (
506
533
  rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
507
534
  }
508
535
 
509
- rspackConfig.output?.path &&
536
+ if (rspackConfig.output?.path) {
510
537
  rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
538
+ }
511
539
  };
512
540
 
513
541
  type LazyCompilationModule = {
@@ -796,10 +824,17 @@ const getRuntimeConfigFromProject = (
796
824
  logHeapUsage,
797
825
  chaiConfig,
798
826
  includeTaskLocation,
827
+ silent,
799
828
  } = project.normalizedConfig;
800
829
 
801
830
  return {
802
- env,
831
+ // Propagate NODE_ENV from the host so `import.meta.env.NODE_ENV` resolves
832
+ // to `'test'` in browser tests (matches Node mode). User-supplied `env`
833
+ // wins so explicit overrides still take effect.
834
+ env: {
835
+ NODE_ENV: process.env.NODE_ENV,
836
+ ...env,
837
+ },
803
838
  testNamePattern,
804
839
  testTimeout,
805
840
  hookTimeout,
@@ -822,6 +857,7 @@ const getRuntimeConfigFromProject = (
822
857
  logHeapUsage,
823
858
  chaiConfig,
824
859
  includeTaskLocation,
860
+ silent,
825
861
  };
826
862
  };
827
863
 
@@ -915,6 +951,7 @@ const collectProjectEntries = async (
915
951
  rootPath: context.rootPath,
916
952
  projectRoot: project.rootPath,
917
953
  fileFilters: context.fileFilters || [],
954
+ fileFilterMode: context.fileFilterMode,
918
955
  });
919
956
 
920
957
  const setup = getSetupFiles(setupFiles, project.rootPath);
@@ -1279,6 +1316,10 @@ const createBrowserRuntime = async ({
1279
1316
  }
1280
1317
 
1281
1318
  const userRsbuildConfig = project.normalizedConfig;
1319
+ const buildCache = resolveProjectBuildCache({
1320
+ context,
1321
+ project,
1322
+ });
1282
1323
  const setupFiles = Object.values(
1283
1324
  getSetupFiles(
1284
1325
  project.normalizedConfig.setupFiles,
@@ -1286,53 +1327,65 @@ const createBrowserRuntime = async ({
1286
1327
  ),
1287
1328
  );
1288
1329
  // Merge order: current config -> userConfig -> rstest required config (highest priority)
1289
- const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
1290
- resolve: {
1291
- alias: rstestInternalAliases,
1330
+ const merged = mergeEnvironmentConfig(
1331
+ config,
1332
+ {
1333
+ ...userRsbuildConfig,
1334
+ performance: buildCache
1335
+ ? {
1336
+ ...userRsbuildConfig.performance,
1337
+ buildCache,
1338
+ }
1339
+ : userRsbuildConfig.performance,
1292
1340
  },
1293
- source: {
1294
- define: {
1295
- 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1296
- 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1341
+ {
1342
+ resolve: {
1343
+ alias: rstestInternalAliases,
1297
1344
  },
1298
- },
1299
- output: {
1300
- target: 'web',
1301
- // Enable source map for inline snapshot support
1302
- sourceMap: {
1303
- js: 'source-map',
1345
+ source: {
1346
+ define: {
1347
+ 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1348
+ 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1349
+ },
1304
1350
  },
1305
- },
1306
- tools: {
1307
- rspack: (rspackConfig) => {
1308
- rspackConfig.mode = 'development';
1309
- rspackConfig.lazyCompilation =
1310
- createBrowserLazyCompilationConfig(setupFiles);
1311
- rspackConfig.plugins = rspackConfig.plugins || [];
1312
- rspackConfig.plugins.push(virtualManifestPlugin);
1313
-
1314
- applyDefaultWatchOptions(rspackConfig, isWatchMode);
1315
-
1316
- // Extract and merge sourcemaps from pre-built @rstest/core files
1317
- // This preserves the sourcemap chain for inline snapshot support
1318
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1319
- const browserRuntimeDir = dirname(browserRuntimePath);
1320
- rspackConfig.module = rspackConfig.module || {};
1321
- rspackConfig.module.rules = rspackConfig.module.rules || [];
1322
- rspackConfig.module.rules.unshift({
1323
- test: /\.js$/,
1324
- include: browserRuntimeDir,
1325
- extractSourceMap: true,
1326
- });
1327
-
1328
- if (isDebug()) {
1329
- logger.log(
1330
- `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1331
- );
1332
- }
1351
+ output: {
1352
+ target: 'web',
1353
+ // Enable source map for inline snapshot support
1354
+ sourceMap: {
1355
+ js: 'source-map',
1356
+ },
1357
+ },
1358
+ tools: {
1359
+ rspack: (rspackConfig) => {
1360
+ rspackConfig.mode = 'development';
1361
+ rspackConfig.lazyCompilation =
1362
+ createBrowserLazyCompilationConfig(setupFiles);
1363
+ rspackConfig.plugins = rspackConfig.plugins || [];
1364
+ rspackConfig.plugins.push(virtualManifestPlugin);
1365
+
1366
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
1367
+
1368
+ // Extract and merge sourcemaps from pre-built @rstest/core files
1369
+ // This preserves the sourcemap chain for inline snapshot support
1370
+ // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1371
+ const browserRuntimeDir = dirname(browserRuntimePath);
1372
+ rspackConfig.module = rspackConfig.module || {};
1373
+ rspackConfig.module.rules = rspackConfig.module.rules || [];
1374
+ rspackConfig.module.rules.unshift({
1375
+ test: /\.js$/,
1376
+ include: browserRuntimeDir,
1377
+ extractSourceMap: true,
1378
+ });
1379
+
1380
+ if (isDebug()) {
1381
+ logger.log(
1382
+ `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1383
+ );
1384
+ }
1385
+ },
1333
1386
  },
1334
1387
  },
1335
- });
1388
+ );
1336
1389
 
1337
1390
  // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1338
1391
  // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
@@ -1654,8 +1707,22 @@ export const runBrowserController = async (
1654
1707
  context: Rstest,
1655
1708
  options?: BrowserTestRunOptions,
1656
1709
  ): Promise<BrowserTestRunResult | void> => {
1657
- const { skipOnTestRunEnd = false } = options ?? {};
1710
+ const {
1711
+ skipOnTestRunEnd = false,
1712
+ allowEmptyWatchRun = false,
1713
+ onTraceEvents,
1714
+ } = options ?? {};
1658
1715
  const buildStart = Date.now();
1716
+ const isWatchMode = context.command === 'watch';
1717
+
1718
+ // Per-file PhaseTrackers, populated only when `--trace` is on (caller
1719
+ // passes `onTraceEvents`). The browser host shares one Node process across
1720
+ // every test file, so each tracker is assigned a synthetic per-file pid
1721
+ // (`nextBrowserFilePid`) that lets Perfetto render each file as its own
1722
+ // process track with the file path as the title.
1723
+ const phaseTrackers = onTraceEvents
1724
+ ? new Map<string, PhaseTracker>()
1725
+ : undefined;
1659
1726
  const browserProjects = getBrowserProjects(context);
1660
1727
  const useHeadlessDirect = browserProjects.every(
1661
1728
  (project) => project.normalizedConfig.browser.headless,
@@ -1872,27 +1939,47 @@ export const runBrowserController = async (
1872
1939
  (total, item) => total + item.testFiles.length,
1873
1940
  0,
1874
1941
  );
1942
+ const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
1875
1943
 
1876
1944
  if (totalTests === 0) {
1877
1945
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
1878
1946
  if (!skipOnTestRunEnd) {
1879
- const message = `No test files found, exiting with code ${code}.`;
1947
+ const message = shouldKeepWatchingWithEmptySet
1948
+ ? 'No test files found.'
1949
+ : getNoTestFilesMessage({
1950
+ context,
1951
+ code,
1952
+ defaultMessage: `No test files found, exiting with code ${code}.`,
1953
+ });
1880
1954
  if (code === 0) {
1881
1955
  logger.log(color.yellow(message));
1882
1956
  } else {
1883
1957
  logger.error(color.red(message));
1884
1958
  }
1959
+
1960
+ if (context.relatedFilters?.length) {
1961
+ logger.log(
1962
+ color.gray('related: '),
1963
+ context.relatedFilters.join(color.gray(', ')),
1964
+ );
1965
+ } else if (context.fileFilters?.length) {
1966
+ logger.log(
1967
+ color.gray('filter: '),
1968
+ context.fileFilters.join(color.gray(', ')),
1969
+ );
1970
+ }
1885
1971
  }
1886
1972
 
1887
- if (code !== 0) {
1973
+ if (code !== 0 && !shouldKeepWatchingWithEmptySet) {
1888
1974
  ensureProcessExitCode(code);
1889
1975
  }
1890
- return;
1976
+ if (!shouldKeepWatchingWithEmptySet) {
1977
+ return;
1978
+ }
1891
1979
  }
1892
1980
 
1893
1981
  await notifyTestRunStart();
1894
1982
 
1895
- const isWatchMode = context.command === 'watch';
1896
1983
  const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
1897
1984
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
1898
1985
  const tempDir =
@@ -2123,9 +2210,21 @@ export const runBrowserController = async (
2123
2210
  const handleTestFileStart = async (
2124
2211
  payload: TestFileStartPayload,
2125
2212
  ): Promise<void> => {
2213
+ if (phaseTrackers) {
2214
+ const tracker = new PhaseTracker({
2215
+ trace: {
2216
+ testPath: payload.testPath,
2217
+ project: payload.projectName,
2218
+ },
2219
+ pid: nextBrowserFilePid++,
2220
+ });
2221
+ tracker.transition('prepare');
2222
+ phaseTrackers.set(payload.testPath, tracker);
2223
+ }
2126
2224
  await Promise.all(
2127
2225
  context.reporters.map((reporter) =>
2128
2226
  (reporter as Reporter).onTestFileStart?.({
2227
+ testId: getFileTaskId(payload.testPath),
2129
2228
  testPath: payload.testPath,
2130
2229
  tests: [],
2131
2230
  }),
@@ -2136,6 +2235,7 @@ export const runBrowserController = async (
2136
2235
  const handleTestFileReady = async (
2137
2236
  payload: TestFileReadyPayload,
2138
2237
  ): Promise<void> => {
2238
+ phaseTrackers?.get(payload.testPath)?.transition('tests');
2139
2239
  await Promise.all(
2140
2240
  context.reporters.map((reporter) =>
2141
2241
  (reporter as Reporter).onTestFileReady?.(payload),
@@ -2146,6 +2246,7 @@ export const runBrowserController = async (
2146
2246
  const handleTestSuiteStart = async (
2147
2247
  payload: TestSuiteStartPayload,
2148
2248
  ): Promise<void> => {
2249
+ phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
2149
2250
  await Promise.all(
2150
2251
  context.reporters.map((reporter) =>
2151
2252
  (reporter as Reporter).onTestSuiteStart?.(payload),
@@ -2156,16 +2257,28 @@ export const runBrowserController = async (
2156
2257
  const handleTestSuiteResult = async (
2157
2258
  payload: TestSuiteResultPayload,
2158
2259
  ): Promise<void> => {
2260
+ phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
2159
2261
  await Promise.all(
2160
2262
  context.reporters.map((reporter) =>
2161
2263
  (reporter as Reporter).onTestSuiteResult?.(payload),
2162
2264
  ),
2163
2265
  );
2266
+
2267
+ if (context.normalizedConfig.silent === 'passed-only') {
2268
+ await flushBufferedLogsForTask({
2269
+ taskId: payload.testId,
2270
+ status: payload.status,
2271
+ taskParentNames: payload.parentNames,
2272
+ taskType: 'suite',
2273
+ testPath: payload.testPath,
2274
+ });
2275
+ }
2164
2276
  };
2165
2277
 
2166
2278
  const handleTestCaseStart = async (
2167
2279
  payload: TestCaseStartPayload,
2168
2280
  ): Promise<void> => {
2281
+ phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
2169
2282
  await Promise.all(
2170
2283
  context.reporters.map((reporter) =>
2171
2284
  (reporter as Reporter).onTestCaseStart?.(payload),
@@ -2175,11 +2288,22 @@ export const runBrowserController = async (
2175
2288
 
2176
2289
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2177
2290
  caseResults.push(payload);
2291
+ phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
2178
2292
  await Promise.all(
2179
2293
  context.reporters.map((reporter) =>
2180
2294
  (reporter as Reporter).onTestCaseResult?.(payload),
2181
2295
  ),
2182
2296
  );
2297
+
2298
+ if (context.normalizedConfig.silent === 'passed-only') {
2299
+ await flushBufferedLogsForTask({
2300
+ taskId: payload.testId,
2301
+ status: payload.status,
2302
+ taskParentNames: payload.parentNames,
2303
+ taskType: 'case',
2304
+ testPath: payload.testPath,
2305
+ });
2306
+ }
2183
2307
  };
2184
2308
 
2185
2309
  const handleTestFileComplete = async (
@@ -2190,6 +2314,27 @@ export const runBrowserController = async (
2190
2314
  if (payload.snapshotResult) {
2191
2315
  context.snapshotManager.add(payload.snapshotResult);
2192
2316
  }
2317
+
2318
+ if (phaseTrackers) {
2319
+ const tracker = phaseTrackers.get(payload.testPath);
2320
+ if (tracker) {
2321
+ tracker.end();
2322
+ const events = tracker.getTraceEvents();
2323
+ if (events) onTraceEvents?.(events);
2324
+ phaseTrackers.delete(payload.testPath);
2325
+ }
2326
+ }
2327
+
2328
+ if (context.normalizedConfig.silent === 'passed-only') {
2329
+ await flushBufferedLogsForTask({
2330
+ taskId: payload.testId,
2331
+ status: payload.status,
2332
+ taskParentNames: payload.parentNames,
2333
+ taskType: 'file',
2334
+ testPath: payload.testPath,
2335
+ });
2336
+ }
2337
+
2193
2338
  await Promise.all(
2194
2339
  context.reporters.map((reporter) =>
2195
2340
  (reporter as Reporter).onTestFileResult?.(payload),
@@ -2204,19 +2349,28 @@ export const runBrowserController = async (
2204
2349
  const log: UserConsoleLog = {
2205
2350
  content: payload.content,
2206
2351
  name: payload.level,
2352
+ taskId: payload.taskId,
2353
+ taskName: payload.taskName,
2354
+ taskParentNames: payload.taskParentNames,
2355
+ taskType: payload.taskType,
2207
2356
  testPath: payload.testPath,
2208
2357
  type: payload.type,
2209
2358
  trace: payload.trace,
2210
2359
  };
2211
- const shouldLog =
2212
- context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
2213
- if (shouldLog) {
2214
- await Promise.all(
2215
- context.reporters.map((reporter) =>
2216
- (reporter as Reporter).onUserConsoleLog?.(log),
2217
- ),
2218
- );
2360
+ if (context.normalizedConfig.silent === true) {
2361
+ return;
2362
+ }
2363
+
2364
+ if (context.normalizedConfig.silent === 'passed-only') {
2365
+ bufferConsoleLog(log);
2366
+ return;
2219
2367
  }
2368
+
2369
+ if (context.normalizedConfig.disableConsoleIntercept) {
2370
+ return;
2371
+ }
2372
+
2373
+ await emitUserConsoleLog(log);
2220
2374
  };
2221
2375
 
2222
2376
  const handleFatal = async (payload: FatalPayload): Promise<void> => {
@@ -2226,6 +2380,113 @@ export const runBrowserController = async (
2226
2380
  ensureProcessExitCode(1);
2227
2381
  };
2228
2382
 
2383
+ const bufferedConsoleLogs = new Map<string, UserConsoleLog[]>();
2384
+ const suiteIdsByChain = new Map<string, string>();
2385
+
2386
+ const getSuiteChainKey = (names: string[]): string => {
2387
+ return names.join('\u0000');
2388
+ };
2389
+
2390
+ const pushTaskId = (taskIds: string[], taskId: string): void => {
2391
+ if (!taskIds.includes(taskId)) {
2392
+ taskIds.push(taskId);
2393
+ }
2394
+ };
2395
+
2396
+ const shouldEmitUserConsoleLog = (log: UserConsoleLog): boolean => {
2397
+ return context.normalizedConfig.onConsoleLog?.(log.content) !== false;
2398
+ };
2399
+
2400
+ const emitUserConsoleLog = async (log: UserConsoleLog): Promise<void> => {
2401
+ if (!shouldEmitUserConsoleLog(log)) {
2402
+ return;
2403
+ }
2404
+
2405
+ await Promise.all(
2406
+ context.reporters.map((reporter) =>
2407
+ (reporter as Reporter).onUserConsoleLog?.(log),
2408
+ ),
2409
+ );
2410
+ };
2411
+
2412
+ const bufferConsoleLog = (log: UserConsoleLog): void => {
2413
+ const taskId = getBufferedLogTaskId(log);
2414
+ const logs = bufferedConsoleLogs.get(taskId) || [];
2415
+ logs.push(log);
2416
+ bufferedConsoleLogs.set(taskId, logs);
2417
+
2418
+ if (log.taskType === 'suite' && log.taskId) {
2419
+ suiteIdsByChain.set(
2420
+ getSuiteChainKey([...(log.taskParentNames || []), log.taskName || '']),
2421
+ log.taskId,
2422
+ );
2423
+ }
2424
+ };
2425
+
2426
+ const flushBufferedLogsForTask = async ({
2427
+ taskId,
2428
+ status,
2429
+ taskParentNames,
2430
+ taskType,
2431
+ testPath,
2432
+ }: {
2433
+ taskId: string;
2434
+ status: TestResult['status'];
2435
+ taskParentNames?: string[];
2436
+ taskType?: 'file' | 'suite' | 'case';
2437
+ testPath: string;
2438
+ }): Promise<void> => {
2439
+ if (status !== 'fail') {
2440
+ bufferedConsoleLogs.delete(taskId);
2441
+ return;
2442
+ }
2443
+
2444
+ const taskIdsToFlush: string[] = [];
2445
+
2446
+ if (taskType === 'case') {
2447
+ pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
2448
+
2449
+ const suiteNames = taskParentNames || [];
2450
+ for (let i = 0; i < suiteNames.length; i++) {
2451
+ const suiteId = suiteIdsByChain.get(
2452
+ getSuiteChainKey(suiteNames.slice(0, i + 1)),
2453
+ );
2454
+
2455
+ if (suiteId) {
2456
+ pushTaskId(taskIdsToFlush, suiteId);
2457
+ }
2458
+ }
2459
+
2460
+ pushTaskId(taskIdsToFlush, taskId);
2461
+ }
2462
+
2463
+ if (taskType === 'suite') {
2464
+ pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
2465
+ pushTaskId(taskIdsToFlush, taskId);
2466
+ }
2467
+
2468
+ if (taskType === 'file') {
2469
+ pushTaskId(taskIdsToFlush, taskId);
2470
+ }
2471
+
2472
+ for (const bufferedTaskId of taskIdsToFlush) {
2473
+ const logs = bufferedConsoleLogs.get(bufferedTaskId);
2474
+ if (!logs) {
2475
+ continue;
2476
+ }
2477
+
2478
+ bufferedConsoleLogs.delete(bufferedTaskId);
2479
+
2480
+ for (const log of logs) {
2481
+ await Promise.all(
2482
+ context.reporters.map((reporter) =>
2483
+ (reporter as Reporter).onUserConsoleLog?.(log),
2484
+ ),
2485
+ );
2486
+ }
2487
+ }
2488
+ };
2489
+
2229
2490
  const runSnapshotRpc = async (
2230
2491
  request: SnapshotRpcRequest,
2231
2492
  ): Promise<unknown> => {
@@ -2612,6 +2873,69 @@ export const runBrowserController = async (
2612
2873
  },
2613
2874
  });
2614
2875
 
2876
+ if (allTestFiles.length === 0) {
2877
+ const duration = {
2878
+ totalTime: buildTime,
2879
+ buildTime,
2880
+ testTime: 0,
2881
+ };
2882
+ const result = {
2883
+ results: reporterResults,
2884
+ testResults: caseResults,
2885
+ duration,
2886
+ hasFailure: false,
2887
+ getSourcemap: getBrowserSourcemap,
2888
+ resolveSourcemap: resolveBrowserSourcemap,
2889
+ close: skipOnTestRunEnd
2890
+ ? async () => {
2891
+ sessionRegistry.clear();
2892
+ await destroyBrowserRuntime(runtime);
2893
+ }
2894
+ : undefined,
2895
+ };
2896
+
2897
+ if (!skipOnTestRunEnd) {
2898
+ await notifyTestRunEnd({ duration });
2899
+ }
2900
+
2901
+ if (isWatchMode) {
2902
+ triggerRerun = async () => {
2903
+ const newProjectEntries = await collectProjectEntries(context);
2904
+ const rerunPlan = planWatchRerun({
2905
+ projectEntries: newProjectEntries,
2906
+ previousTestFiles: watchContext.lastTestFiles,
2907
+ affectedTestFiles: watchContext.affectedTestFiles,
2908
+ });
2909
+ watchContext.affectedTestFiles = [];
2910
+
2911
+ if (rerunPlan.filesChanged) {
2912
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
2913
+ if (rerunPlan.currentTestFiles.length === 0) {
2914
+ logger.log(
2915
+ color.cyan('No browser test files remain after update.\n'),
2916
+ );
2917
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2918
+ return;
2919
+ }
2920
+
2921
+ logger.log(
2922
+ color.cyan(
2923
+ `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
2924
+ ),
2925
+ );
2926
+ void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
2927
+ return;
2928
+ }
2929
+
2930
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2931
+ };
2932
+ watchContext.hooksEnabled = true;
2933
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2934
+ }
2935
+
2936
+ return result;
2937
+ }
2938
+
2615
2939
  const testStart = Date.now();
2616
2940
  await runFilesWithPool(allTestFiles);
2617
2941
  const testTime = Date.now() - testStart;
@@ -3088,24 +3412,27 @@ export const runBrowserController = async (
3088
3412
  });
3089
3413
  };
3090
3414
 
3091
- const testStart = Date.now();
3092
- try {
3093
- await waitForRunnerFramesReady(
3094
- currentTestFiles.map((file) => file.testPath),
3095
- );
3415
+ let testTime = 0;
3416
+ if (currentTestFiles.length > 0) {
3417
+ const testStart = Date.now();
3418
+ try {
3419
+ await waitForRunnerFramesReady(
3420
+ currentTestFiles.map((file) => file.testPath),
3421
+ );
3096
3422
 
3097
- for (const file of currentTestFiles) {
3098
- await enqueueHeadedReload(file);
3099
- if (fatalError) {
3100
- break;
3423
+ for (const file of currentTestFiles) {
3424
+ await enqueueHeadedReload(file);
3425
+ if (fatalError) {
3426
+ break;
3427
+ }
3101
3428
  }
3429
+ } catch (error) {
3430
+ fatalError = fatalError ?? toError(error);
3431
+ ensureProcessExitCode(1);
3102
3432
  }
3103
- } catch (error) {
3104
- fatalError = fatalError ?? toError(error);
3105
- ensureProcessExitCode(1);
3106
- }
3107
3433
 
3108
- const testTime = Date.now() - testStart;
3434
+ testTime = Date.now() - testStart;
3435
+ }
3109
3436
 
3110
3437
  // Define rerun logic for watch mode
3111
3438
  if (isWatchMode) {
@@ -3129,6 +3456,13 @@ export const runBrowserController = async (
3129
3456
  watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3130
3457
  currentTestFiles = rerunPlan.currentTestFiles;
3131
3458
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
3459
+ if (currentTestFiles.length === 0) {
3460
+ logger.log(
3461
+ color.cyan('No browser test files remain after update.\n'),
3462
+ );
3463
+ logBrowserWatchReadyMessage(enableCliShortcuts);
3464
+ return;
3465
+ }
3132
3466
  await waitForRunnerFramesReady(
3133
3467
  currentTestFiles.map((file) => file.testPath),
3134
3468
  );