@rstest/browser 0.11.1 → 0.11.3

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.
@@ -1,5 +1,5 @@
1
1
  import type { BrowserViewport } from '@rstest/core/internal/browser';
2
- import type { RuntimeConfig, TestFileResult, TestInfo, TestResult } from '@rstest/core/internal/browser-runtime';
2
+ import type { BrowserRuntimeConfig, TestFileResult, TestInfo, TestResult } from '@rstest/core/internal/browser-runtime';
3
3
  import type { SnapshotUpdateState } from '@vitest/snapshot';
4
4
  export type { BrowserLocatorIR, BrowserRpcRequest, SnapshotRpcCall, SnapshotRpcMethod, SnapshotRpcMethodArgs, SnapshotRpcRequest } from './rpcProtocol.js';
5
5
  export { validateBrowserRpcRequest } from './rpcProtocol.js';
@@ -12,7 +12,7 @@ export declare const DISPATCH_NAMESPACE_RUNNER = 'runner';
12
12
  export declare const DISPATCH_NAMESPACE_BROWSER = 'browser';
13
13
  export declare const DISPATCH_NAMESPACE_SNAPSHOT = 'snapshot';
14
14
  export declare const DISPATCH_METHOD_RPC = 'rpc';
15
- export type SerializedRuntimeConfig = RuntimeConfig;
15
+ export type SerializedRuntimeConfig = BrowserRuntimeConfig;
16
16
  // `BrowserViewport` is a core config type (`@rstest/core` owns the canonical
17
17
  // definition used by `NormalizedBrowserModeConfig`). Re-export it so the host
18
18
  // assigns the SAME type across the seam instead of a hand-copied duplicate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rstest/browser",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Browser mode support for Rstest testing framework.",
5
5
  "keywords": [
6
6
  "rstest",
@@ -45,24 +45,24 @@
45
45
  "open-editor": "^6.0.0",
46
46
  "pathe": "^2.0.3",
47
47
  "sirv": "^3.0.2",
48
- "ws": "^8.21.0"
48
+ "ws": "^8.21.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@rslib/core": "0.23.2",
52
52
  "@types/convert-source-map": "^2.0.3",
53
53
  "@types/picomatch": "^4.0.3",
54
54
  "@types/ws": "^8.18.1",
55
- "@vitest/snapshot": "^3.2.6",
55
+ "@vitest/snapshot": "^3.2.7",
56
56
  "birpc": "^4.0.0",
57
57
  "picomatch": "^4.0.5",
58
58
  "playwright": "^1.61.1",
59
- "@rstest/core": "0.11.1",
60
- "@rstest/tsconfig": "0.0.1",
61
- "@rstest/browser-ui": "0.0.0"
59
+ "@rstest/core": "0.11.3",
60
+ "@rstest/browser-ui": "0.0.0",
61
+ "@rstest/tsconfig": "0.0.1"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "playwright": "^1.49.1",
65
- "@rstest/core": "^0.11.1"
65
+ "@rstest/core": "^0.11.3"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "playwright": {
package/src/AGENTS.md CHANGED
@@ -2,15 +2,28 @@
2
2
 
3
3
  This document is architecture-only and focuses on browser mode scheduling in `@rstest/browser` host-side modules.
4
4
 
5
+ ## Run lifecycle ownership (browser/node isomorphism)
6
+
7
+ `runBrowserController` splits finalize ownership on `context.command`:
8
+
9
+ - **Non-watch runs**: the host never self-finalizes. It returns a fully-populated `BrowserTestRunResult` with a deferred `close`, and `@rstest/core`'s `finalizeRunCycle` reduces the run's `ExecutorCycleOutcome`s into the verdict — reporter `onTestRunEnd`, coverage merge + report, exit code, and the bail message. `BrowserTestRunOptions.skipOnTestRunEnd` (deprecated and ignored since the finalize unification) has been removed.
10
+ - **Watch runs**: the host owns the per-rerun lifecycle (`onTestRunStart`/`onTestRunEnd` per rerun) and self-finalizes; core skips its finalize entirely for browser-only and zero-node mixed watch runs.
11
+
12
+ Runner lifecycle events flow through per-project `RunnerEventSink`s (`createRunnerEventSink` from core — the same event pump the node pool RPC uses). The host keeps a `Map<projectName, RunnerEventSink>` and resolves sinks via `sinkForProjectName`/`sinkForTestPath` (falling back to the first project for unknown names); it never fans out to reporters or `stateManager` directly.
13
+
14
+ Cross-file `bail` is enforced at file boundaries in the headless scheduler: before picking up the next file, each worker checks the cycle-wide `stateManager.getCountOfFailedTests()` and drains the remaining queue as skipped results once the budget is reached. The headed debugging UI does not apply bail; within a running file, the runner's per-test gate uses the client-local failed count (see `src/client/AGENTS.md`).
15
+
5
16
  ## Module topology
6
17
 
7
18
  ```mermaid
8
19
  flowchart LR
9
20
  subgraph Host["@rstest/browser host (Node.js)"]
10
21
  IDX["index.ts\nrunBrowserTests()"]
22
+ CV["configValidation.ts\nvalidateBrowserConfig()"]
11
23
  HC["hostController.ts\nrunBrowserController()"]
12
24
  RT["createBrowserRuntime()"]
13
25
  DR["dispatchCapabilities.ts + dispatchRouter.ts\nnamespace router"]
26
+ BRR["browserRpcRegistry.ts\nBrowser RPC allowlists"]
14
27
  RL["runSession.ts\nRunSessionLifecycle"]
15
28
  SR["sessionRegistry.ts\nRunnerSessionRegistry"]
16
29
  WP["watchRerunPlanner.ts"]
@@ -31,9 +44,11 @@ flowchart LR
31
44
  RPC["dispatch-rpc-request"]
32
45
  end
33
46
 
47
+ IDX -."re-export; invoked by @rstest/core before the run".-> CV
34
48
  IDX --> HC
35
49
  HC --> RT
36
50
  HC --> DR
51
+ DR --> BRR
37
52
  HC --> WP
38
53
  WP --> LS
39
54
  HC --> RL
@@ -81,6 +96,7 @@ sequenceDiagram
81
96
 
82
97
  Runner->>Container: postMessage runner lifecycle
83
98
  Container->>Host: forward lifecycle callbacks (onTest*)
99
+ Host->>Host: feed per-project RunnerEventSink (stateManager + reporters)
84
100
 
85
101
  Runner->>Container: postMessage dispatch rpc request
86
102
  Container->>Host: rpc.dispatch(request)
@@ -121,7 +137,7 @@ sequenceDiagram
121
137
  participant Handler as namespace handler
122
138
 
123
139
  Runner->>Host: __rstest_dispatch__ lifecycle
124
- Host->>Host: forward lifecycle callbacks
140
+ Host->>Host: feed per-project RunnerEventSink (stateManager + reporters)
125
141
 
126
142
  Runner->>Host: __rstest_dispatch_rpc__ request
127
143
  Host->>Router: routing inbound request
@@ -0,0 +1,138 @@
1
+ import {
2
+ type BrowserTestExecutor,
3
+ type BrowserTestRunResult,
4
+ buildBrowserCoverageMap,
5
+ type CreateBrowserExecutorOptions,
6
+ type ExecutorCycleOutcome,
7
+ type ExecutorRunCycleOptions,
8
+ type ListCommandResult,
9
+ type RstestContext,
10
+ type TestFileResult,
11
+ } from '@rstest/core/internal/browser';
12
+ import { listBrowserTests, runBrowserController } from './hostController';
13
+
14
+ const emptyOutcome = (): ExecutorCycleOutcome => ({
15
+ results: [],
16
+ testResults: [],
17
+ errors: [],
18
+ testPaths: [],
19
+ duration: { buildTime: 0, testTime: 0 },
20
+ });
21
+
22
+ /**
23
+ * The browser side of the {@link TestExecutor} seam. It delegates into the
24
+ * existing `hostController` in place (no file split this phase) and adapts the
25
+ * host's `BrowserTestRunResult` into the shared `ExecutorCycleOutcome` — the
26
+ * former `toBrowserOutcome` core adapter is folded in here and deleted.
27
+ *
28
+ * Only used in non-watch runs (the shared executor loop); browser watch stays
29
+ * host-driven and self-finalizing until Phase 6, so `runCycle` maps directly
30
+ * onto one `runBrowserController` invocation and its coverage/close semantics.
31
+ */
32
+ export async function createBrowserExecutor(
33
+ context: RstestContext,
34
+ options: CreateBrowserExecutorOptions,
35
+ ): Promise<BrowserTestExecutor> {
36
+ const {
37
+ projects,
38
+ coverageProvider,
39
+ freezeShardedEntries,
40
+ filesOnly,
41
+ allowEmptyRun,
42
+ appliedModifyRstestConfigEnvironments,
43
+ } = options;
44
+ let deferredClose: (() => Promise<void>) | undefined;
45
+ // The host has no mid-launch abort, so `close()` must wait for an in-flight
46
+ // cycle to settle before it can tear down — otherwise a close racing the
47
+ // cycle (e.g. the signal-driven cleanup path) sees no `deferredClose` yet
48
+ // and leaks the launching browser + servers.
49
+ let inFlightCycle: Promise<unknown> | undefined;
50
+
51
+ // Merge the host's per-file `result.coverage` into one map (shared core
52
+ // helper, stripping it from each result to avoid reporter/state cache
53
+ // bloat), then hand the shared finalize a coverage `map` (no `raw` — browser
54
+ // coverage is istanbul-only).
55
+ const foldOutcome = (
56
+ result: BrowserTestRunResult | void,
57
+ ): ExecutorCycleOutcome => {
58
+ if (!result) {
59
+ return emptyOutcome();
60
+ }
61
+ const map = buildBrowserCoverageMap(
62
+ result.results as TestFileResult[],
63
+ coverageProvider,
64
+ );
65
+ return {
66
+ results: result.results,
67
+ testResults: result.testResults,
68
+ errors: result.unhandledErrors ?? [],
69
+ testPaths: result.results.map((r) => r.testPath),
70
+ duration: {
71
+ buildTime: result.duration.buildTime,
72
+ testTime: result.duration.testTime,
73
+ },
74
+ coverage: { map: map?.toJSON() },
75
+ resolveSourcemap: result.resolveSourcemap,
76
+ };
77
+ };
78
+
79
+ return {
80
+ name: 'browser',
81
+ projects,
82
+ async init(): Promise<void> {
83
+ // Server/provider launch stays inside `runBrowserController` (delegate in
84
+ // place). Kept as an explicit hook so the plan → init → runCycle barrier
85
+ // is honored structurally and Phase 5 can attach browser-side hook
86
+ // application here.
87
+ },
88
+ async runCycle(
89
+ opts: ExecutorRunCycleOptions,
90
+ ): Promise<ExecutorCycleOutcome> {
91
+ const cycle = runBrowserController(context, {
92
+ projects,
93
+ shardedEntries: opts.shardedEntries,
94
+ freezeShardedEntries,
95
+ allowEmptyRun,
96
+ appliedModifyRstestConfigEnvironments,
97
+ onTraceEvents: opts.onTraceEvents,
98
+ env: opts.env,
99
+ });
100
+ inFlightCycle = cycle;
101
+ try {
102
+ const result = await cycle;
103
+ // Non-watch runs return a deferred `close`; collapse teardown into the
104
+ // shared `executors.close()` exit path.
105
+ deferredClose = result?.close;
106
+ return foldOutcome(result);
107
+ } finally {
108
+ inFlightCycle = undefined;
109
+ }
110
+ },
111
+ async collect(opts): Promise<{ list: ListCommandResult[] }> {
112
+ const pending = listBrowserTests(context, {
113
+ projects,
114
+ shardedEntries: opts.shardedEntries,
115
+ freezeShardedEntries,
116
+ filesOnly,
117
+ appliedModifyRstestConfigEnvironments,
118
+ });
119
+ inFlightCycle = pending;
120
+ try {
121
+ const { list, close } = await pending;
122
+ deferredClose = close;
123
+ return { list };
124
+ } finally {
125
+ inFlightCycle = undefined;
126
+ }
127
+ },
128
+ async close(): Promise<void> {
129
+ if (inFlightCycle) {
130
+ // A rejected cycle cleans up host-side; settling is all that's needed.
131
+ await inFlightCycle.catch(() => undefined);
132
+ }
133
+ const close = deferredClose;
134
+ deferredClose = undefined;
135
+ await close?.();
136
+ },
137
+ };
138
+ }
@@ -34,13 +34,13 @@ flowchart LR
34
34
  subgraph IframePath["Iframe path (headed)"]
35
35
  S1["send()"] --> P1["parent.postMessage(__rstest_dispatch__)"]
36
36
  R1["dispatchRunnerLifecycle()"] --> P2["postMessage dispatch-rpc-request"]
37
- SN1["snapshot.sendRpcRequest()"] --> P3["postMessage dispatch-rpc-request + wait __rstest_dispatch_response__"]
37
+ SN1["snapshot.ts / browserRpc.ts via dispatchTransport.dispatchRpc()"] --> P3["postMessage dispatch-rpc-request + wait __rstest_dispatch_response__"]
38
38
  end
39
39
 
40
40
  subgraph TopLevelRunPath["Top-level page path (headless run)"]
41
41
  S2["send()"] --> D1["window.__rstest_dispatch__"]
42
42
  R2["dispatchRunnerLifecycle()"] --> D2["window.__rstest_dispatch_rpc__"]
43
- SN2["snapshot.sendRpcRequest()"] --> D3["window.__rstest_dispatch_rpc__"]
43
+ SN2["snapshot.ts / browserRpc.ts via dispatchTransport.dispatchRpc()"] --> D3["window.__rstest_dispatch_rpc__"]
44
44
  end
45
45
 
46
46
  subgraph TopLevelCollectPath["Top-level page path (list collect)"]
@@ -48,31 +48,36 @@ flowchart LR
48
48
  end
49
49
  ```
50
50
 
51
- ## Snapshot RPC sequence
51
+ ## Dispatch RPC sequence (snapshot / browser namespaces)
52
+
53
+ The `snapshot` namespace (snapshot file ops) and the `browser` namespace
54
+ (locator/page RPC from `browserRpc.ts`) share one client channel:
55
+ `dispatchTransport.ts` owns request ids, timeouts, and pending-response
56
+ resolution for both the iframe and top-level transports.
52
57
 
53
58
  ```mermaid
54
59
  sequenceDiagram
55
- participant Snap as snapshot.ts
56
- participant Runner as entry.ts runtime
60
+ participant Caller as snapshot.ts / browserRpc.ts
61
+ participant Transport as dispatchTransport.ts
57
62
  participant Container as browser-ui channel
58
63
  participant Host as host dispatch router
59
64
 
60
- Snap->>Runner: sendRpcRequest(method, args)
65
+ Caller->>Transport: dispatchRpc(request)
61
66
 
62
67
  alt top-level runner (headless run)
63
- Runner->>Host: __rstest_dispatch_rpc__(namespace=snapshot)
64
- Host-->>Runner: BrowserDispatchResponse
65
- Runner-->>Snap: result/error
68
+ Transport->>Host: __rstest_dispatch_rpc__(namespace=snapshot|browser)
69
+ Host-->>Transport: BrowserDispatchResponse
70
+ Transport-->>Caller: result/error
66
71
  else iframe runner (headed)
67
- Runner->>Container: postMessage(dispatch-rpc-request)
72
+ Transport->>Container: postMessage(dispatch-rpc-request)
68
73
  Container->>Host: rpc.dispatch(request)
69
74
  Host-->>Container: BrowserDispatchResponse
70
- Container-->>Runner: __rstest_dispatch_response__
71
- Runner-->>Snap: resolve/reject pending request
75
+ Container-->>Transport: __rstest_dispatch_response__
76
+ Transport-->>Caller: resolve/reject pending request
72
77
  end
73
78
  ```
74
79
 
75
- List collect mode does not use the snapshot RPC namespace.
80
+ List collect mode does not use the snapshot or browser RPC namespaces.
76
81
 
77
82
  ## Runtime invariants
78
83
 
@@ -80,3 +85,5 @@ List collect mode does not use the snapshot RPC namespace.
80
85
  - Runner lifecycle events (`file-ready`, `suite-start`, `suite-result`, `case-start`) go through the `runner` dispatch namespace.
81
86
  - Snapshot file operations go through the `snapshot` dispatch namespace and never access filesystem directly in browser runtime.
82
87
  - Console interception is per test file and must restore original console methods in `finally`.
88
+ - An unhandled window error or `unhandledrejection` that escapes a test file fails the file even when every test passed. Before finalizing each file result, the runner yields two macrotasks so a rejection leaked by a synchronous test is still observed (the browser dispatches `unhandledrejection` in a task queued after the current task).
89
+ - The `getCountOfFailedTests` runner hook returns the client-local per-file failed count; cross-file `bail` is enforced host-side at file boundaries (see `../AGENTS.md`), not by this hook.
@@ -16,7 +16,9 @@ import type {
16
16
  import {
17
17
  createBrowserTaskContext,
18
18
  createRstestRuntime,
19
+ formatConsoleArgs,
19
20
  globalApis,
21
+ RSTEST_API_GLOBAL_KEY,
20
22
  RSTEST_ENV_SYMBOL_KEY,
21
23
  setRealTimers,
22
24
  unwrapRegex,
@@ -32,7 +34,6 @@ import {
32
34
  createRunnerLifecycleRequest,
33
35
  sendRunnerLifecycle,
34
36
  } from './dispatchTransport';
35
- import { formatConsoleArgs } from './formatConsole';
36
37
  import { BrowserSnapshotEnvironment } from './snapshot';
37
38
  import {
38
39
  findNewScriptUrl,
@@ -59,6 +60,23 @@ const debugLog = (...args: unknown[]): void => {
59
60
  type RuntimeEnvStore = Record<string, string | undefined>;
60
61
  const RSTEST_ENV_SYMBOL = Symbol.for(RSTEST_ENV_SYMBOL_KEY);
61
62
 
63
+ /**
64
+ * Publish the runtime API on the globals test modules read: the
65
+ * `@rstest/core` external and the `import.meta.rstest` define (node parity:
66
+ * `global['@rstest/core']` in runInPool), plus the `globals: true` API names.
67
+ */
68
+ const installRuntimeGlobals = (
69
+ runtime: Awaited<ReturnType<typeof createRstestRuntime>>,
70
+ runtimeConfig: RuntimeConfig,
71
+ ): void => {
72
+ (globalThis as Record<string, unknown>)[RSTEST_API_GLOBAL_KEY] = runtime.api;
73
+ if (runtimeConfig.globals) {
74
+ for (const apiKey of globalApis) {
75
+ (globalThis as any)[apiKey] = (runtime.api as any)[apiKey];
76
+ }
77
+ }
78
+ };
79
+
62
80
  type GlobalWithRuntimeEnv = typeof globalThis &
63
81
  Record<symbol, unknown> & {
64
82
  global?: typeof globalThis;
@@ -67,14 +85,19 @@ type GlobalWithRuntimeEnv = typeof globalThis &
67
85
  const restoreRuntimeConfig = (
68
86
  config: BrowserProjectRuntime['runtimeConfig'],
69
87
  ): RuntimeConfig => {
70
- const { testNamePattern } = config as RuntimeConfig;
88
+ const { testNamePattern } = config;
89
+ // The browser wire (BrowserRuntimeConfig) omits node-only fields
90
+ // (testEnvironment / coverage / logHeapUsage / detectAsyncLeaks) that the
91
+ // browser runtime never reads. The shared WorkerState / runner types require
92
+ // the full RuntimeConfig shape and runner heap sampling is guarded against
93
+ // the absent logHeapUsage, so widening back here is sound.
71
94
  return {
72
95
  ...config,
73
96
  testNamePattern:
74
97
  typeof testNamePattern === 'string'
75
98
  ? unwrapRegex(testNamePattern)
76
99
  : testNamePattern,
77
- };
100
+ } as RuntimeConfig;
78
101
  };
79
102
 
80
103
  const ensureRuntimeEnv = (env: RuntimeConfig['env'] | undefined): void => {
@@ -497,12 +520,7 @@ const run = async () => {
497
520
  taskContext: createBrowserTaskContext(),
498
521
  });
499
522
 
500
- // Register global APIs if globals config is enabled
501
- if (runtimeConfig.globals) {
502
- for (const apiKey of globalApis) {
503
- (globalThis as any)[apiKey] = (runtime.api as any)[apiKey];
504
- }
505
- }
523
+ installRuntimeGlobals(runtime, runtimeConfig);
506
524
 
507
525
  try {
508
526
  // Load setup files for this project after runtime is ready.
@@ -542,6 +560,28 @@ const run = async () => {
542
560
  return;
543
561
  }
544
562
 
563
+ // Capture unhandled errors/rejections that escape a test file's execution.
564
+ // Parity with the node worker, which attaches process-level
565
+ // uncaughtException/unhandledRejection to the running file's result and fails
566
+ // the file. `activeUnhandledErrors` points at the currently running file's
567
+ // collector (undefined between files, so stray late events are ignored).
568
+ let activeUnhandledErrors: Error[] | undefined;
569
+ const onWindowError = (event: ErrorEvent): void => {
570
+ activeUnhandledErrors?.push(
571
+ event.error instanceof Error
572
+ ? event.error
573
+ : new Error(event.message || String(event.error)),
574
+ );
575
+ };
576
+ const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
577
+ const { reason } = event;
578
+ activeUnhandledErrors?.push(
579
+ reason instanceof Error ? reason : new Error(String(reason)),
580
+ );
581
+ };
582
+ window.addEventListener('error', onWindowError);
583
+ window.addEventListener('unhandledrejection', onUnhandledRejection);
584
+
545
585
  // 2. Run tests for each file
546
586
  for (const key of testKeysToRun) {
547
587
  const testPath = toAbsolutePath(key, currentProject.projectRoot);
@@ -609,12 +649,7 @@ const run = async () => {
609
649
 
610
650
  const runtime = await createRstestRuntime(workerState, { taskContext });
611
651
 
612
- // Register global APIs if globals config is enabled
613
- if (runtimeConfig.globals) {
614
- for (const apiKey of globalApis) {
615
- (globalThis as any)[apiKey] = (runtime.api as any)[apiKey];
616
- }
617
- }
652
+ installRuntimeGlobals(runtime, runtimeConfig);
618
653
 
619
654
  let failedTestsCount = 0;
620
655
 
@@ -669,6 +704,9 @@ const run = async () => {
669
704
  },
670
705
  });
671
706
 
707
+ const unhandledErrors: Error[] = [];
708
+ activeUnhandledErrors = unhandledErrors;
709
+
672
710
  try {
673
711
  // Load setup files for this project after runtime is ready.
674
712
  await loadSetupFiles();
@@ -692,6 +730,32 @@ const run = async () => {
692
730
  runtime.api,
693
731
  );
694
732
 
733
+ // The browser dispatches `unhandledrejection` in a task queued at the
734
+ // current task's microtask checkpoint, so a rejection leaked by a
735
+ // synchronous test is not observable yet when `runTests()` resolves.
736
+ // Yield two macrotasks: the first reaches the checkpoint that queues
737
+ // the event task, the second runs after that task regardless of how
738
+ // the browser orders the timer and event task sources.
739
+ for (let i = 0; i < 2; i++) {
740
+ await new Promise<void>((resolve) => {
741
+ setTimeout(resolve, 0);
742
+ });
743
+ }
744
+
745
+ // An unhandled error/rejection that escaped the run fails the file even
746
+ // when every test passed.
747
+ if (unhandledErrors.length > 0) {
748
+ result.status = 'fail';
749
+ result.errors = [
750
+ ...(result.errors ?? []),
751
+ ...unhandledErrors.map((error) => ({
752
+ name: error.name,
753
+ message: error.message,
754
+ stack: error.stack,
755
+ })),
756
+ ];
757
+ }
758
+
695
759
  // Collect coverage data from global __coverage__ object
696
760
  if (globalThis.__coverage__) {
697
761
  result.coverage = globalThis.__coverage__ as CoverageMapData;
@@ -716,9 +780,13 @@ const run = async () => {
716
780
  } finally {
717
781
  // Restore original console methods
718
782
  restoreConsole();
783
+ activeUnhandledErrors = undefined;
719
784
  }
720
785
  }
721
786
 
787
+ window.removeEventListener('error', onWindowError);
788
+ window.removeEventListener('unhandledrejection', onUnhandledRejection);
789
+
722
790
  send({ type: 'complete' });
723
791
  window.__RSTEST_DONE__ = true;
724
792
  };
@@ -5,10 +5,9 @@ import {
5
5
  dispatchRpc,
6
6
  getRpcTimeout,
7
7
  } from './dispatchTransport';
8
+ import { SNAPSHOT_HEADER } from '@rstest/core/internal/browser-runtime';
8
9
  import { mapStackFrame } from './sourceMapSupport';
9
10
 
10
- const SNAPSHOT_HEADER = '// Rstest Snapshot';
11
-
12
11
  const createSnapshotDispatchRequest = (
13
12
  requestId: string,
14
13
  call: SnapshotRpcCall,
@@ -1,12 +1,17 @@
1
- import { getNumCpus, parseWorkers } from '@rstest/core/internal/browser';
1
+ import {
2
+ getNumCpus,
3
+ parseWorkers,
4
+ resolveWorkerCount,
5
+ } from '@rstest/core/internal/browser';
2
6
  import type { Rstest } from '@rstest/core/internal/browser';
3
7
 
4
8
  // Re-export the shared worker primitives so existing consumers (and unit tests
5
9
  // importing from `../src/concurrency`) keep a stable import surface.
6
10
  export { getNumCpus, parseWorkers };
7
11
 
8
- // Shared headless concurrency policy.
9
- // Keep this in one place so executors reuse the same worker semantics.
12
+ // The browser headless path caps CPU-derived workers at 12 and halves that
13
+ // capped base in watch; the shared `resolveWorkerCount` helper owns the
14
+ // `maxWorkers` override and workload clamp.
10
15
  const DEFAULT_MAX_HEADLESS_WORKERS = 12;
11
16
 
12
17
  type HeadlessConcurrencyContext = Pick<Rstest, 'command'> & {
@@ -17,32 +22,44 @@ type HeadlessConcurrencyContext = Pick<Rstest, 'command'> & {
17
22
  };
18
23
  };
19
24
 
25
+ const resolveHeadlessWorkerCount = ({
26
+ command,
27
+ maxWorkers,
28
+ totalTasks,
29
+ numCpus = getNumCpus(),
30
+ }: {
31
+ command: HeadlessConcurrencyContext['command'];
32
+ maxWorkers?: string | number;
33
+ totalTasks: number;
34
+ numCpus?: number;
35
+ }): number => {
36
+ const base = Math.max(Math.min(DEFAULT_MAX_HEADLESS_WORKERS, numCpus - 1), 1);
37
+ return resolveWorkerCount({
38
+ command,
39
+ maxWorkers,
40
+ totalTasks,
41
+ recommended: base,
42
+ watchRecommended: Math.max(Math.floor(base / 2), 1),
43
+ numCpus,
44
+ });
45
+ };
46
+
20
47
  export const resolveDefaultHeadlessWorkers = (
21
48
  command: HeadlessConcurrencyContext['command'],
22
49
  numCpus: number = getNumCpus(),
23
- ): number => {
24
- const baseWorkers = Math.max(
25
- Math.min(DEFAULT_MAX_HEADLESS_WORKERS, numCpus - 1),
26
- 1,
27
- );
28
-
29
- return command === 'watch'
30
- ? Math.max(Math.floor(baseWorkers / 2), 1)
31
- : baseWorkers;
32
- };
50
+ ): number =>
51
+ resolveHeadlessWorkerCount({
52
+ command,
53
+ totalTasks: Number.POSITIVE_INFINITY,
54
+ numCpus,
55
+ });
33
56
 
34
57
  export const getHeadlessConcurrency = (
35
58
  context: HeadlessConcurrencyContext,
36
59
  totalTests: number,
37
- ): number => {
38
- if (totalTests <= 0) {
39
- return 1;
40
- }
41
-
42
- const maxWorkers = context.normalizedConfig.pool.maxWorkers;
43
- if (maxWorkers !== undefined) {
44
- return Math.min(parseWorkers(maxWorkers), totalTests);
45
- }
46
-
47
- return Math.min(resolveDefaultHeadlessWorkers(context.command), totalTests);
48
- };
60
+ ): number =>
61
+ resolveHeadlessWorkerCount({
62
+ command: context.command,
63
+ maxWorkers: context.normalizedConfig.pool.maxWorkers,
64
+ totalTasks: totalTests,
65
+ });