@rstest/browser 0.9.10 → 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/{102.e63e4aa16d.js → 484.af891b432a.js} +666 -668
  3. package/dist/browser-container/container-static/js/484.af891b432a.js.LICENSE.txt +1 -0
  4. package/dist/browser-container/container-static/js/{index.af52585634.js → index.463ae8de87.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 +204 -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 +8 -8
  33. package/src/client/entry.ts +75 -14
  34. package/src/hostController.ts +403 -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'];
@@ -797,10 +824,17 @@ const getRuntimeConfigFromProject = (
797
824
  logHeapUsage,
798
825
  chaiConfig,
799
826
  includeTaskLocation,
827
+ silent,
800
828
  } = project.normalizedConfig;
801
829
 
802
830
  return {
803
- 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
+ },
804
838
  testNamePattern,
805
839
  testTimeout,
806
840
  hookTimeout,
@@ -823,6 +857,7 @@ const getRuntimeConfigFromProject = (
823
857
  logHeapUsage,
824
858
  chaiConfig,
825
859
  includeTaskLocation,
860
+ silent,
826
861
  };
827
862
  };
828
863
 
@@ -916,6 +951,7 @@ const collectProjectEntries = async (
916
951
  rootPath: context.rootPath,
917
952
  projectRoot: project.rootPath,
918
953
  fileFilters: context.fileFilters || [],
954
+ fileFilterMode: context.fileFilterMode,
919
955
  });
920
956
 
921
957
  const setup = getSetupFiles(setupFiles, project.rootPath);
@@ -1280,6 +1316,10 @@ const createBrowserRuntime = async ({
1280
1316
  }
1281
1317
 
1282
1318
  const userRsbuildConfig = project.normalizedConfig;
1319
+ const buildCache = resolveProjectBuildCache({
1320
+ context,
1321
+ project,
1322
+ });
1283
1323
  const setupFiles = Object.values(
1284
1324
  getSetupFiles(
1285
1325
  project.normalizedConfig.setupFiles,
@@ -1287,53 +1327,65 @@ const createBrowserRuntime = async ({
1287
1327
  ),
1288
1328
  );
1289
1329
  // Merge order: current config -> userConfig -> rstest required config (highest priority)
1290
- const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
1291
- resolve: {
1292
- alias: rstestInternalAliases,
1330
+ const merged = mergeEnvironmentConfig(
1331
+ config,
1332
+ {
1333
+ ...userRsbuildConfig,
1334
+ performance: buildCache
1335
+ ? {
1336
+ ...userRsbuildConfig.performance,
1337
+ buildCache,
1338
+ }
1339
+ : userRsbuildConfig.performance,
1293
1340
  },
1294
- source: {
1295
- define: {
1296
- 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1297
- 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1341
+ {
1342
+ resolve: {
1343
+ alias: rstestInternalAliases,
1298
1344
  },
1299
- },
1300
- output: {
1301
- target: 'web',
1302
- // Enable source map for inline snapshot support
1303
- sourceMap: {
1304
- 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
+ },
1305
1350
  },
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
- }
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
+ },
1334
1386
  },
1335
1387
  },
1336
- });
1388
+ );
1337
1389
 
1338
1390
  // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1339
1391
  // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
@@ -1655,8 +1707,22 @@ export const runBrowserController = async (
1655
1707
  context: Rstest,
1656
1708
  options?: BrowserTestRunOptions,
1657
1709
  ): Promise<BrowserTestRunResult | void> => {
1658
- const { skipOnTestRunEnd = false } = options ?? {};
1710
+ const {
1711
+ skipOnTestRunEnd = false,
1712
+ allowEmptyWatchRun = false,
1713
+ onTraceEvents,
1714
+ } = options ?? {};
1659
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;
1660
1726
  const browserProjects = getBrowserProjects(context);
1661
1727
  const useHeadlessDirect = browserProjects.every(
1662
1728
  (project) => project.normalizedConfig.browser.headless,
@@ -1873,27 +1939,47 @@ export const runBrowserController = async (
1873
1939
  (total, item) => total + item.testFiles.length,
1874
1940
  0,
1875
1941
  );
1942
+ const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
1876
1943
 
1877
1944
  if (totalTests === 0) {
1878
1945
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
1879
1946
  if (!skipOnTestRunEnd) {
1880
- 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
+ });
1881
1954
  if (code === 0) {
1882
1955
  logger.log(color.yellow(message));
1883
1956
  } else {
1884
1957
  logger.error(color.red(message));
1885
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
+ }
1886
1971
  }
1887
1972
 
1888
- if (code !== 0) {
1973
+ if (code !== 0 && !shouldKeepWatchingWithEmptySet) {
1889
1974
  ensureProcessExitCode(code);
1890
1975
  }
1891
- return;
1976
+ if (!shouldKeepWatchingWithEmptySet) {
1977
+ return;
1978
+ }
1892
1979
  }
1893
1980
 
1894
1981
  await notifyTestRunStart();
1895
1982
 
1896
- const isWatchMode = context.command === 'watch';
1897
1983
  const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
1898
1984
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
1899
1985
  const tempDir =
@@ -2124,9 +2210,21 @@ export const runBrowserController = async (
2124
2210
  const handleTestFileStart = async (
2125
2211
  payload: TestFileStartPayload,
2126
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
+ }
2127
2224
  await Promise.all(
2128
2225
  context.reporters.map((reporter) =>
2129
2226
  (reporter as Reporter).onTestFileStart?.({
2227
+ testId: getFileTaskId(payload.testPath),
2130
2228
  testPath: payload.testPath,
2131
2229
  tests: [],
2132
2230
  }),
@@ -2137,6 +2235,7 @@ export const runBrowserController = async (
2137
2235
  const handleTestFileReady = async (
2138
2236
  payload: TestFileReadyPayload,
2139
2237
  ): Promise<void> => {
2238
+ phaseTrackers?.get(payload.testPath)?.transition('tests');
2140
2239
  await Promise.all(
2141
2240
  context.reporters.map((reporter) =>
2142
2241
  (reporter as Reporter).onTestFileReady?.(payload),
@@ -2147,6 +2246,7 @@ export const runBrowserController = async (
2147
2246
  const handleTestSuiteStart = async (
2148
2247
  payload: TestSuiteStartPayload,
2149
2248
  ): Promise<void> => {
2249
+ phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
2150
2250
  await Promise.all(
2151
2251
  context.reporters.map((reporter) =>
2152
2252
  (reporter as Reporter).onTestSuiteStart?.(payload),
@@ -2157,16 +2257,28 @@ export const runBrowserController = async (
2157
2257
  const handleTestSuiteResult = async (
2158
2258
  payload: TestSuiteResultPayload,
2159
2259
  ): Promise<void> => {
2260
+ phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
2160
2261
  await Promise.all(
2161
2262
  context.reporters.map((reporter) =>
2162
2263
  (reporter as Reporter).onTestSuiteResult?.(payload),
2163
2264
  ),
2164
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
+ }
2165
2276
  };
2166
2277
 
2167
2278
  const handleTestCaseStart = async (
2168
2279
  payload: TestCaseStartPayload,
2169
2280
  ): Promise<void> => {
2281
+ phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
2170
2282
  await Promise.all(
2171
2283
  context.reporters.map((reporter) =>
2172
2284
  (reporter as Reporter).onTestCaseStart?.(payload),
@@ -2176,11 +2288,22 @@ export const runBrowserController = async (
2176
2288
 
2177
2289
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2178
2290
  caseResults.push(payload);
2291
+ phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
2179
2292
  await Promise.all(
2180
2293
  context.reporters.map((reporter) =>
2181
2294
  (reporter as Reporter).onTestCaseResult?.(payload),
2182
2295
  ),
2183
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
+ }
2184
2307
  };
2185
2308
 
2186
2309
  const handleTestFileComplete = async (
@@ -2191,6 +2314,27 @@ export const runBrowserController = async (
2191
2314
  if (payload.snapshotResult) {
2192
2315
  context.snapshotManager.add(payload.snapshotResult);
2193
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
+
2194
2338
  await Promise.all(
2195
2339
  context.reporters.map((reporter) =>
2196
2340
  (reporter as Reporter).onTestFileResult?.(payload),
@@ -2205,19 +2349,28 @@ export const runBrowserController = async (
2205
2349
  const log: UserConsoleLog = {
2206
2350
  content: payload.content,
2207
2351
  name: payload.level,
2352
+ taskId: payload.taskId,
2353
+ taskName: payload.taskName,
2354
+ taskParentNames: payload.taskParentNames,
2355
+ taskType: payload.taskType,
2208
2356
  testPath: payload.testPath,
2209
2357
  type: payload.type,
2210
2358
  trace: payload.trace,
2211
2359
  };
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
- );
2360
+ if (context.normalizedConfig.silent === true) {
2361
+ return;
2362
+ }
2363
+
2364
+ if (context.normalizedConfig.silent === 'passed-only') {
2365
+ bufferConsoleLog(log);
2366
+ return;
2367
+ }
2368
+
2369
+ if (context.normalizedConfig.disableConsoleIntercept) {
2370
+ return;
2220
2371
  }
2372
+
2373
+ await emitUserConsoleLog(log);
2221
2374
  };
2222
2375
 
2223
2376
  const handleFatal = async (payload: FatalPayload): Promise<void> => {
@@ -2227,6 +2380,113 @@ export const runBrowserController = async (
2227
2380
  ensureProcessExitCode(1);
2228
2381
  };
2229
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
+
2230
2490
  const runSnapshotRpc = async (
2231
2491
  request: SnapshotRpcRequest,
2232
2492
  ): Promise<unknown> => {
@@ -2613,6 +2873,69 @@ export const runBrowserController = async (
2613
2873
  },
2614
2874
  });
2615
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
+
2616
2939
  const testStart = Date.now();
2617
2940
  await runFilesWithPool(allTestFiles);
2618
2941
  const testTime = Date.now() - testStart;
@@ -3089,24 +3412,27 @@ export const runBrowserController = async (
3089
3412
  });
3090
3413
  };
3091
3414
 
3092
- const testStart = Date.now();
3093
- try {
3094
- await waitForRunnerFramesReady(
3095
- currentTestFiles.map((file) => file.testPath),
3096
- );
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
+ );
3097
3422
 
3098
- for (const file of currentTestFiles) {
3099
- await enqueueHeadedReload(file);
3100
- if (fatalError) {
3101
- break;
3423
+ for (const file of currentTestFiles) {
3424
+ await enqueueHeadedReload(file);
3425
+ if (fatalError) {
3426
+ break;
3427
+ }
3102
3428
  }
3429
+ } catch (error) {
3430
+ fatalError = fatalError ?? toError(error);
3431
+ ensureProcessExitCode(1);
3103
3432
  }
3104
- } catch (error) {
3105
- fatalError = fatalError ?? toError(error);
3106
- ensureProcessExitCode(1);
3107
- }
3108
3433
 
3109
- const testTime = Date.now() - testStart;
3434
+ testTime = Date.now() - testStart;
3435
+ }
3110
3436
 
3111
3437
  // Define rerun logic for watch mode
3112
3438
  if (isWatchMode) {
@@ -3130,6 +3456,13 @@ export const runBrowserController = async (
3130
3456
  watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3131
3457
  currentTestFiles = rerunPlan.currentTestFiles;
3132
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
+ }
3133
3466
  await waitForRunnerFramesReady(
3134
3467
  currentTestFiles.map((file) => file.testPath),
3135
3468
  );
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;
@@ -1 +0,0 @@
1
- /*! LICENSE: 102.e63e4aa16d.js.LICENSE.txt */
@@ -1 +0,0 @@
1
- /*! LICENSE: lib-react.9e703e1b29.js.LICENSE.txt */