@rstest/browser 0.11.1 → 0.11.2

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.2",
4
4
  "description": "Browser mode support for Rstest testing framework.",
5
5
  "keywords": [
6
6
  "rstest",
@@ -52,17 +52,17 @@
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",
59
+ "@rstest/core": "0.11.2",
60
60
  "@rstest/tsconfig": "0.0.1",
61
61
  "@rstest/browser-ui": "0.0.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "playwright": "^1.49.1",
65
- "@rstest/core": "^0.11.1"
65
+ "@rstest/core": "^0.11.2"
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.
@@ -67,14 +67,19 @@ type GlobalWithRuntimeEnv = typeof globalThis &
67
67
  const restoreRuntimeConfig = (
68
68
  config: BrowserProjectRuntime['runtimeConfig'],
69
69
  ): RuntimeConfig => {
70
- const { testNamePattern } = config as RuntimeConfig;
70
+ const { testNamePattern } = config;
71
+ // The browser wire (BrowserRuntimeConfig) omits node-only fields
72
+ // (testEnvironment / coverage / logHeapUsage / detectAsyncLeaks) that the
73
+ // browser runtime never reads. The shared WorkerState / runner types require
74
+ // the full RuntimeConfig shape and runner heap sampling is guarded against
75
+ // the absent logHeapUsage, so widening back here is sound.
71
76
  return {
72
77
  ...config,
73
78
  testNamePattern:
74
79
  typeof testNamePattern === 'string'
75
80
  ? unwrapRegex(testNamePattern)
76
81
  : testNamePattern,
77
- };
82
+ } as RuntimeConfig;
78
83
  };
79
84
 
80
85
  const ensureRuntimeEnv = (env: RuntimeConfig['env'] | undefined): void => {
@@ -542,6 +547,28 @@ const run = async () => {
542
547
  return;
543
548
  }
544
549
 
550
+ // Capture unhandled errors/rejections that escape a test file's execution.
551
+ // Parity with the node worker, which attaches process-level
552
+ // uncaughtException/unhandledRejection to the running file's result and fails
553
+ // the file. `activeUnhandledErrors` points at the currently running file's
554
+ // collector (undefined between files, so stray late events are ignored).
555
+ let activeUnhandledErrors: Error[] | undefined;
556
+ const onWindowError = (event: ErrorEvent): void => {
557
+ activeUnhandledErrors?.push(
558
+ event.error instanceof Error
559
+ ? event.error
560
+ : new Error(event.message || String(event.error)),
561
+ );
562
+ };
563
+ const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
564
+ const { reason } = event;
565
+ activeUnhandledErrors?.push(
566
+ reason instanceof Error ? reason : new Error(String(reason)),
567
+ );
568
+ };
569
+ window.addEventListener('error', onWindowError);
570
+ window.addEventListener('unhandledrejection', onUnhandledRejection);
571
+
545
572
  // 2. Run tests for each file
546
573
  for (const key of testKeysToRun) {
547
574
  const testPath = toAbsolutePath(key, currentProject.projectRoot);
@@ -669,6 +696,9 @@ const run = async () => {
669
696
  },
670
697
  });
671
698
 
699
+ const unhandledErrors: Error[] = [];
700
+ activeUnhandledErrors = unhandledErrors;
701
+
672
702
  try {
673
703
  // Load setup files for this project after runtime is ready.
674
704
  await loadSetupFiles();
@@ -692,6 +722,32 @@ const run = async () => {
692
722
  runtime.api,
693
723
  );
694
724
 
725
+ // The browser dispatches `unhandledrejection` in a task queued at the
726
+ // current task's microtask checkpoint, so a rejection leaked by a
727
+ // synchronous test is not observable yet when `runTests()` resolves.
728
+ // Yield two macrotasks: the first reaches the checkpoint that queues
729
+ // the event task, the second runs after that task regardless of how
730
+ // the browser orders the timer and event task sources.
731
+ for (let i = 0; i < 2; i++) {
732
+ await new Promise<void>((resolve) => {
733
+ setTimeout(resolve, 0);
734
+ });
735
+ }
736
+
737
+ // An unhandled error/rejection that escaped the run fails the file even
738
+ // when every test passed.
739
+ if (unhandledErrors.length > 0) {
740
+ result.status = 'fail';
741
+ result.errors = [
742
+ ...(result.errors ?? []),
743
+ ...unhandledErrors.map((error) => ({
744
+ name: error.name,
745
+ message: error.message,
746
+ stack: error.stack,
747
+ })),
748
+ ];
749
+ }
750
+
695
751
  // Collect coverage data from global __coverage__ object
696
752
  if (globalThis.__coverage__) {
697
753
  result.coverage = globalThis.__coverage__ as CoverageMapData;
@@ -716,9 +772,13 @@ const run = async () => {
716
772
  } finally {
717
773
  // Restore original console methods
718
774
  restoreConsole();
775
+ activeUnhandledErrors = undefined;
719
776
  }
720
777
  }
721
778
 
779
+ window.removeEventListener('error', onWindowError);
780
+ window.removeEventListener('unhandledrejection', onUnhandledRejection);
781
+
722
782
  send({ type: 'complete' });
723
783
  window.__RSTEST_DONE__ = true;
724
784
  };
@@ -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
+ });