@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.
- package/dist/browser-container/container-static/js/{328.37af068e21.js → 949.7d935e2ac9.js} +1638 -1612
- package/dist/browser-container/container-static/js/949.7d935e2ac9.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.c8fe4ff310.js → index.492a6c2f8b.js} +11 -11
- package/dist/browser-container/index.html +1 -1
- package/dist/browserExecutor.d.ts +11 -0
- package/dist/configValidation.d.ts +6 -1
- package/dist/hostController.d.ts +2 -6
- package/dist/index.d.ts +4 -3
- package/dist/index.js +570 -301
- package/dist/protocol.d.ts +2 -2
- package/package.json +7 -7
- package/src/AGENTS.md +17 -1
- package/src/browserExecutor.ts +138 -0
- package/src/client/AGENTS.md +20 -13
- package/src/client/entry.ts +83 -15
- package/src/client/snapshot.ts +1 -2
- package/src/concurrency.ts +42 -25
- package/src/configValidation.ts +182 -1
- package/src/hostController.ts +546 -350
- package/src/index.ts +9 -5
- package/src/protocol.ts +2 -2
- package/dist/browser-container/container-static/js/328.37af068e21.js.LICENSE.txt +0 -1
- package/src/client/formatConsole.ts +0 -77
- package/src/client/public.ts +0 -12
package/dist/protocol.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BrowserViewport } from '@rstest/core/internal/browser';
|
|
2
|
-
import type {
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
-
"@rstest/
|
|
61
|
-
"@rstest/
|
|
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.
|
|
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:
|
|
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
|
+
}
|
package/src/client/AGENTS.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
##
|
|
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
|
|
56
|
-
participant
|
|
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
|
-
|
|
65
|
+
Caller->>Transport: dispatchRpc(request)
|
|
61
66
|
|
|
62
67
|
alt top-level runner (headless run)
|
|
63
|
-
|
|
64
|
-
Host-->>
|
|
65
|
-
|
|
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
|
-
|
|
72
|
+
Transport->>Container: postMessage(dispatch-rpc-request)
|
|
68
73
|
Container->>Host: rpc.dispatch(request)
|
|
69
74
|
Host-->>Container: BrowserDispatchResponse
|
|
70
|
-
Container-->>
|
|
71
|
-
|
|
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
|
|
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.
|
package/src/client/entry.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/client/snapshot.ts
CHANGED
|
@@ -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,
|
package/src/concurrency.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
});
|