@rstest/browser 0.11.5 → 0.11.7
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/css/index.43daaf6c93.css +1 -0
- package/dist/browser-container/container-static/js/260.3739fe815c.js +194 -0
- package/dist/browser-container/container-static/js/index.c0f87bfb3e.js +1 -0
- package/dist/browser-container/index.html +1 -1
- package/dist/browserExecutor.d.ts +7 -5
- package/dist/browserRsbuild.d.ts +139 -0
- package/dist/browserV8Coverage.d.ts +15 -0
- package/dist/client/240~1.js +150 -0
- package/dist/{augmentExpect.d.ts → client/client/augmentExpect.d.ts} +1 -1
- package/dist/client/{browserRpc.d.ts → client/browserRpc.d.ts} +1 -1
- package/dist/client/{dispatchTransport.d.ts → client/dispatchTransport.d.ts} +8 -4
- package/dist/client/client/index.d.ts +2 -0
- package/dist/client/client/runIdentity.d.ts +12 -0
- package/dist/client/client/runner.d.ts +4 -0
- package/dist/client/client/snapshot.d.ts +27 -0
- package/dist/client/client/sourceMapSupport.d.ts +36 -0
- package/dist/{browser.js → client/index.js} +3 -102
- package/dist/client/protocol.d.ts +225 -0
- package/dist/client/rpcProtocol.d.ts +138 -0
- package/dist/client/runner.js +675 -0
- package/dist/client/sourceMap/sourceMapLoader.d.ts +25 -0
- package/dist/containerRpc.d.ts +60 -0
- package/dist/dispatchCapabilities.d.ts +1 -2
- package/dist/headedRunRegistry.d.ts +51 -0
- package/dist/headedScheduler.d.ts +58 -0
- package/dist/headlessScheduler.d.ts +38 -0
- package/dist/headlessTransport.d.ts +4 -2
- package/dist/hostController.d.ts +12 -47
- package/dist/hostPayloads.d.ts +24 -0
- package/dist/index.js +2019 -1516
- package/dist/protocol.d.ts +48 -8
- package/dist/providers/index.d.ts +21 -0
- package/dist/providers/playwright/v8Coverage.d.ts +2 -0
- package/dist/rpcProtocol.d.ts +1 -2
- package/dist/schedulerSeam.d.ts +42 -0
- package/dist/sourceMap/sourceMapLoader.d.ts +12 -1
- package/dist/watchRerunPlanner.d.ts +20 -4
- package/dist/watchRuntime.d.ts +21 -0
- package/dist/watchSignals.d.ts +29 -0
- package/package.json +12 -12
- package/dist/33.js +0 -7
- package/dist/browser-container/container-static/css/index.0521ff95b1.css +0 -1
- package/dist/browser-container/container-static/js/506.b2f920fa67.js +0 -194
- package/dist/browser-container/container-static/js/index.d27fd63f5e.js +0 -1
- package/dist/browser.d.ts +0 -2
- package/dist/headlessLatestRerunScheduler.d.ts +0 -18
- package/src/augmentExpect.ts +0 -62
- package/src/browser.ts +0 -3
- package/src/browserExecutor.ts +0 -140
- package/src/browserRpcRegistry.ts +0 -57
- package/src/client/api.ts +0 -213
- package/src/client/browserRpc.ts +0 -86
- package/src/client/dispatchTransport.ts +0 -240
- package/src/client/entry.ts +0 -807
- package/src/client/locator.ts +0 -448
- package/src/client/snapshot.ts +0 -113
- package/src/client/sourceMapSupport.ts +0 -159
- package/src/concurrency.ts +0 -65
- package/src/configValidation.ts +0 -263
- package/src/dispatchCapabilities.ts +0 -182
- package/src/dispatchRouter.ts +0 -82
- package/src/env.d.ts +0 -54
- package/src/headedSerialTaskQueue.ts +0 -23
- package/src/headlessLatestRerunScheduler.ts +0 -76
- package/src/headlessTransport.ts +0 -28
- package/src/hostController.ts +0 -4633
- package/src/index.ts +0 -51
- package/src/manifest.d.ts +0 -41
- package/src/protocol.ts +0 -225
- package/src/providers/index.ts +0 -126
- package/src/providers/playwright/compileLocator.ts +0 -130
- package/src/providers/playwright/dispatchBrowserRpc.ts +0 -372
- package/src/providers/playwright/expectUtils.ts +0 -57
- package/src/providers/playwright/implementation.ts +0 -35
- package/src/providers/playwright/index.ts +0 -1
- package/src/providers/playwright/runtime.ts +0 -159
- package/src/providers/playwright/textMatcher.ts +0 -10
- package/src/rpcProtocol.ts +0 -218
- package/src/runSession.ts +0 -110
- package/src/sessionRegistry.ts +0 -89
- package/src/sourceMap/sourceMapLoader.ts +0 -96
- package/src/viewportPresets.ts +0 -69
- package/src/watchRerunPlanner.ts +0 -77
- /package/dist/{rslib-runtime.js → 612~0.js} +0 -0
- /package/dist/client/{api.d.ts → client/api.d.ts} +0 -0
- /package/dist/client/{locator.d.ts → client/locator.d.ts} +0 -0
package/src/hostController.ts
DELETED
|
@@ -1,4633 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
-
import type { AddressInfo } from 'node:net';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
-
import type { Rspack } from '@rstest/core';
|
|
8
|
-
import {
|
|
9
|
-
applyWatchInvalidation,
|
|
10
|
-
applyWebMockRspackConfig,
|
|
11
|
-
type BrowserTestRunOptions,
|
|
12
|
-
type BrowserTestRunResult,
|
|
13
|
-
type BrowserWatchHandles,
|
|
14
|
-
buildBrowserCoverageMap,
|
|
15
|
-
type CoverageMapData,
|
|
16
|
-
type EntryHashSnapshot,
|
|
17
|
-
type ExecutorCycleOutcome,
|
|
18
|
-
FATAL_SIGNALS,
|
|
19
|
-
finalizeRunCycle,
|
|
20
|
-
type ListBrowserTestsOptions,
|
|
21
|
-
color,
|
|
22
|
-
createCoverageProvider,
|
|
23
|
-
createRunnerEventSink,
|
|
24
|
-
createSilentConsoleController,
|
|
25
|
-
DEFAULT_TEST_TIMEOUT,
|
|
26
|
-
type FormattedError,
|
|
27
|
-
getNoTestFilesMessage,
|
|
28
|
-
getPrettyConsoleName,
|
|
29
|
-
getSetupFiles,
|
|
30
|
-
getTestEntries,
|
|
31
|
-
hasUserRstestConfigPlugins,
|
|
32
|
-
importMetaRstestDefine,
|
|
33
|
-
initModifyRstestConfigHooks,
|
|
34
|
-
isDebug,
|
|
35
|
-
isTTY,
|
|
36
|
-
type ListCommandResult,
|
|
37
|
-
loadCoverageProvider,
|
|
38
|
-
logger,
|
|
39
|
-
logWatchReadyMessage,
|
|
40
|
-
pluginMockRuntime,
|
|
41
|
-
prepareWatchRerunState,
|
|
42
|
-
projectRuntimeConfig,
|
|
43
|
-
PhaseTracker,
|
|
44
|
-
type ProjectContext,
|
|
45
|
-
type Reporter,
|
|
46
|
-
type RunnerEventSink,
|
|
47
|
-
type RstestContext,
|
|
48
|
-
resolveProjectBuildCache,
|
|
49
|
-
resolveSnapshotPathDefault,
|
|
50
|
-
resolveShardedEntries,
|
|
51
|
-
RSTEST_ENV_SYMBOL_KEY,
|
|
52
|
-
rsbuild,
|
|
53
|
-
serializableConfig,
|
|
54
|
-
type Test,
|
|
55
|
-
type TestFileResult,
|
|
56
|
-
type TestResult,
|
|
57
|
-
type UserConsoleLog,
|
|
58
|
-
type WatchInvalidationState,
|
|
59
|
-
} from '@rstest/core/internal/browser';
|
|
60
|
-
import { type BirpcReturn, createBirpc } from 'birpc';
|
|
61
|
-
import openEditor from 'open-editor';
|
|
62
|
-
import { dirname, join, normalize, relative, resolve } from 'pathe';
|
|
63
|
-
import picomatch from 'picomatch';
|
|
64
|
-
import sirv from 'sirv';
|
|
65
|
-
import { type WebSocket, WebSocketServer } from 'ws';
|
|
66
|
-
import { getHeadlessConcurrency } from './concurrency';
|
|
67
|
-
import {
|
|
68
|
-
createHostDispatchRouter,
|
|
69
|
-
type HostDispatchRouterOptions,
|
|
70
|
-
} from './dispatchCapabilities';
|
|
71
|
-
import { validateBrowserConfig } from './configValidation';
|
|
72
|
-
import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
|
|
73
|
-
import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
|
|
74
|
-
import { attachHeadlessRunnerTransport } from './headlessTransport';
|
|
75
|
-
import type {
|
|
76
|
-
BrowserClientMessage,
|
|
77
|
-
BrowserDispatchHandler,
|
|
78
|
-
BrowserDispatchRequest,
|
|
79
|
-
BrowserDispatchResponse,
|
|
80
|
-
BrowserHostConfig,
|
|
81
|
-
BrowserLogPayload,
|
|
82
|
-
BrowserProjectRuntime,
|
|
83
|
-
BrowserRpcRequest,
|
|
84
|
-
BrowserViewport,
|
|
85
|
-
SnapshotRpcRequest,
|
|
86
|
-
TestFileInfo,
|
|
87
|
-
} from './protocol';
|
|
88
|
-
import {
|
|
89
|
-
DISPATCH_MESSAGE_TYPE,
|
|
90
|
-
DISPATCH_NAMESPACE_BROWSER,
|
|
91
|
-
DISPATCH_NAMESPACE_RUNNER,
|
|
92
|
-
validateBrowserRpcRequest,
|
|
93
|
-
} from './protocol';
|
|
94
|
-
import {
|
|
95
|
-
type BrowserProvider,
|
|
96
|
-
type BrowserProviderBrowser,
|
|
97
|
-
type BrowserProviderContext,
|
|
98
|
-
type BrowserProviderImplementation,
|
|
99
|
-
type BrowserProviderPage,
|
|
100
|
-
getBrowserProviderImplementation,
|
|
101
|
-
} from './providers';
|
|
102
|
-
import {
|
|
103
|
-
createRunSession,
|
|
104
|
-
type RunSession,
|
|
105
|
-
RunSessionLifecycle,
|
|
106
|
-
} from './runSession';
|
|
107
|
-
import { RunnerSessionRegistry } from './sessionRegistry';
|
|
108
|
-
import {
|
|
109
|
-
loadSourceMapWithCache,
|
|
110
|
-
normalizeJavaScriptUrl,
|
|
111
|
-
type SourceMapPayload,
|
|
112
|
-
} from './sourceMap/sourceMapLoader';
|
|
113
|
-
import { resolveBrowserViewportPreset } from './viewportPresets';
|
|
114
|
-
import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
|
|
115
|
-
|
|
116
|
-
const { createRsbuild, rspack } = rsbuild;
|
|
117
|
-
type RsbuildDevServer = rsbuild.RsbuildDevServer;
|
|
118
|
-
type RsbuildInstance = rsbuild.RsbuildInstance;
|
|
119
|
-
type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
|
|
120
|
-
Pick<rsbuild.RsbuildConfig, 'root'>;
|
|
121
|
-
|
|
122
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
123
|
-
const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
|
|
127
|
-
* mode. Browser host runs every test file inside the same Node process, so
|
|
128
|
-
* without an override every file would emit events under the same `pid` and
|
|
129
|
-
* share a single track labelled `worker <hostPid>`. Giving each file its own
|
|
130
|
-
* synthetic `pid` makes the track title surface the file path instead,
|
|
131
|
-
* matching node mode's default `isolate: true` behavior. The 1_000_000_000
|
|
132
|
-
* base keeps each synthetic `pid` well clear of real OS `pid` values in
|
|
133
|
-
* mixed-mode traces.
|
|
134
|
-
*/
|
|
135
|
-
let nextBrowserFilePid = 1_000_000_000;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Serialize JSON for inline <script> injection.
|
|
139
|
-
* Escapes '<' to prevent accidental </script> break-out.
|
|
140
|
-
* Escapes U+2028/U+2029 to keep script parsing safe.
|
|
141
|
-
*/
|
|
142
|
-
const serializeForInlineScript = (value: unknown): string => {
|
|
143
|
-
return JSON.stringify(value)
|
|
144
|
-
.replace(/</g, '\\u003c')
|
|
145
|
-
.replace(/\u2028/g, '\\u2028')
|
|
146
|
-
.replace(/\u2029/g, '\\u2029');
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
// ============================================================================
|
|
150
|
-
// Type Definitions
|
|
151
|
-
// ============================================================================
|
|
152
|
-
|
|
153
|
-
type BrowserProjectEntries = {
|
|
154
|
-
project: ProjectContext;
|
|
155
|
-
setupFiles: string[];
|
|
156
|
-
testFiles: string[];
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
type BrowserProviderProject = {
|
|
160
|
-
rootPath: string;
|
|
161
|
-
provider: BrowserProvider;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
type BrowserLaunchOptions = {
|
|
165
|
-
provider: BrowserProvider;
|
|
166
|
-
browser: ProjectContext['normalizedConfig']['browser']['browser'];
|
|
167
|
-
headless: ProjectContext['normalizedConfig']['browser']['headless'];
|
|
168
|
-
port: ProjectContext['normalizedConfig']['browser']['port'];
|
|
169
|
-
strictPort: ProjectContext['normalizedConfig']['browser']['strictPort'];
|
|
170
|
-
providerOptions: Record<string, unknown>;
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const getBrowserProviderOptions = (
|
|
174
|
-
project: ProjectContext,
|
|
175
|
-
): Record<string, unknown> => {
|
|
176
|
-
const browserConfig = project.normalizedConfig.browser as {
|
|
177
|
-
providerOptions?: Record<string, unknown>;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
return browserConfig.providerOptions ?? {};
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
/** Payload for test file start event */
|
|
184
|
-
type TestFileStartPayload = {
|
|
185
|
-
testPath: string;
|
|
186
|
-
projectName: string;
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
/** Payload for log event — single-sourced from the wire protocol. */
|
|
190
|
-
type LogPayload = BrowserLogPayload;
|
|
191
|
-
|
|
192
|
-
/** Payload for fatal error event */
|
|
193
|
-
type FatalPayload = {
|
|
194
|
-
message: string;
|
|
195
|
-
stack?: string;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
type ReporterHookArg<THook extends keyof Reporter> =
|
|
199
|
-
NonNullable<Reporter[THook]> extends (...args: infer TArgs) => unknown
|
|
200
|
-
? TArgs[0]
|
|
201
|
-
: never;
|
|
202
|
-
|
|
203
|
-
type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
|
|
204
|
-
type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
|
|
205
|
-
type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
|
|
206
|
-
type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
|
|
207
|
-
type ReloadTestFileAck = {
|
|
208
|
-
runId: string;
|
|
209
|
-
};
|
|
210
|
-
type HeadedTestFileCompletePayload = TestFileResult & {
|
|
211
|
-
runId?: string;
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
type DeferredPromise<T> = {
|
|
215
|
-
promise: Promise<T>;
|
|
216
|
-
resolve: (value: T | PromiseLike<T>) => void;
|
|
217
|
-
reject: (reason?: unknown) => void;
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const getFileTaskId = (testPath: string): string => {
|
|
221
|
-
return `file:${testPath}`;
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const createDeferredPromise = <T>(): DeferredPromise<T> => {
|
|
225
|
-
let resolve!: DeferredPromise<T>['resolve'];
|
|
226
|
-
let reject!: DeferredPromise<T>['reject'];
|
|
227
|
-
const promise = new Promise<T>((res, rej) => {
|
|
228
|
-
resolve = res;
|
|
229
|
-
reject = rej;
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
promise,
|
|
234
|
-
resolve,
|
|
235
|
-
reject,
|
|
236
|
-
};
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
/** RPC methods exposed by the host (server) to the container (client) */
|
|
240
|
-
type HostRpcMethods = {
|
|
241
|
-
rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
|
|
242
|
-
getTestFiles: () => Promise<TestFileInfo[]>;
|
|
243
|
-
onRunnerFramesReady: (testFiles: string[]) => Promise<void>;
|
|
244
|
-
// Test result callbacks from container
|
|
245
|
-
onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
|
|
246
|
-
onTestCaseResult: (payload: TestResult) => Promise<void>;
|
|
247
|
-
onTestFileComplete: (payload: HeadedTestFileCompletePayload) => Promise<void>;
|
|
248
|
-
onLog: (payload: LogPayload) => Promise<void>;
|
|
249
|
-
onFatal: (payload: FatalPayload) => Promise<void>;
|
|
250
|
-
// Generic dispatch endpoint used by runner RPC requests.
|
|
251
|
-
dispatch: (
|
|
252
|
-
request: BrowserDispatchRequest,
|
|
253
|
-
) => Promise<BrowserDispatchResponse>;
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
/** RPC methods exposed by the container (client) to the host (server) */
|
|
257
|
-
type ContainerRpcMethods = {
|
|
258
|
-
onTestFileUpdate: (testFiles: TestFileInfo[]) => Promise<void>;
|
|
259
|
-
reloadTestFile: (
|
|
260
|
-
testFile: string,
|
|
261
|
-
testNamePattern?: string,
|
|
262
|
-
) => Promise<ReloadTestFileAck>;
|
|
263
|
-
/**
|
|
264
|
-
* Replace the container's copy of the host config so runner iframes loaded
|
|
265
|
-
* from now on receive fresh values (e.g. the 'u' shortcut flipping
|
|
266
|
-
* `snapshot.updateSnapshot` between watch reruns).
|
|
267
|
-
*/
|
|
268
|
-
onHostConfigUpdate: (config: BrowserHostConfig) => Promise<void>;
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
|
|
272
|
-
|
|
273
|
-
// ============================================================================
|
|
274
|
-
// RPC Manager - Encapsulates WebSocket and birpc management
|
|
275
|
-
// ============================================================================
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Manages the WebSocket connection and birpc communication with the container UI.
|
|
279
|
-
* Provides a clean interface for sending RPC calls and handling connections.
|
|
280
|
-
*/
|
|
281
|
-
class ContainerRpcManager {
|
|
282
|
-
private wss: WebSocketServer;
|
|
283
|
-
private ws: WebSocket | null = null;
|
|
284
|
-
private rpc: ContainerRpc | null = null;
|
|
285
|
-
private methods: HostRpcMethods;
|
|
286
|
-
private onDisconnect?: (error: Error) => void;
|
|
287
|
-
private detachActiveSocketListeners: (() => void) | null = null;
|
|
288
|
-
|
|
289
|
-
constructor(
|
|
290
|
-
wss: WebSocketServer,
|
|
291
|
-
methods: HostRpcMethods,
|
|
292
|
-
onDisconnect?: (error: Error) => void,
|
|
293
|
-
) {
|
|
294
|
-
this.wss = wss;
|
|
295
|
-
this.methods = methods;
|
|
296
|
-
this.onDisconnect = onDisconnect;
|
|
297
|
-
this.setupConnectionHandler();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Update the RPC methods (used when starting a new test run) */
|
|
301
|
-
updateMethods(
|
|
302
|
-
methods: HostRpcMethods,
|
|
303
|
-
onDisconnect?: (error: Error) => void,
|
|
304
|
-
): void {
|
|
305
|
-
this.methods = methods;
|
|
306
|
-
this.onDisconnect = onDisconnect;
|
|
307
|
-
// Re-create birpc with new methods if already connected
|
|
308
|
-
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
|
309
|
-
this.attachWebSocket(this.ws);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private setupConnectionHandler(): void {
|
|
314
|
-
this.wss.on('connection', (ws: WebSocket) => {
|
|
315
|
-
logger.debug('[Browser UI] Container WebSocket connected');
|
|
316
|
-
logger.debug(
|
|
317
|
-
`[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`,
|
|
318
|
-
);
|
|
319
|
-
this.attachWebSocket(ws);
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private attachWebSocket(ws: WebSocket): void {
|
|
324
|
-
this.detachActiveSocketListeners?.();
|
|
325
|
-
if (this.rpc && !this.rpc.$closed) {
|
|
326
|
-
this.rpc.$close(new Error('Container RPC transport reattached'));
|
|
327
|
-
}
|
|
328
|
-
this.ws = ws;
|
|
329
|
-
const messageHandlers = new WeakMap<
|
|
330
|
-
(data: any) => void,
|
|
331
|
-
(message: any) => void
|
|
332
|
-
>();
|
|
333
|
-
|
|
334
|
-
this.rpc = createBirpc<ContainerRpcMethods, HostRpcMethods>(this.methods, {
|
|
335
|
-
timeout: -1,
|
|
336
|
-
post: (data) => {
|
|
337
|
-
if (ws.readyState === ws.OPEN) {
|
|
338
|
-
ws.send(JSON.stringify(data));
|
|
339
|
-
}
|
|
340
|
-
},
|
|
341
|
-
on: (fn) => {
|
|
342
|
-
const handler = (message: any) => {
|
|
343
|
-
try {
|
|
344
|
-
const data = JSON.parse(message.toString());
|
|
345
|
-
fn(data);
|
|
346
|
-
} catch {
|
|
347
|
-
// ignore invalid messages
|
|
348
|
-
}
|
|
349
|
-
};
|
|
350
|
-
messageHandlers.set(fn, handler);
|
|
351
|
-
ws.on('message', handler);
|
|
352
|
-
},
|
|
353
|
-
off: (fn) => {
|
|
354
|
-
const handler = messageHandlers.get(fn);
|
|
355
|
-
if (!handler) {
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
ws.off('message', handler);
|
|
359
|
-
messageHandlers.delete(fn);
|
|
360
|
-
},
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
const handleClose = () => {
|
|
364
|
-
// Only clear if this is still the active connection
|
|
365
|
-
// This prevents a race condition when a new connection is established
|
|
366
|
-
// before the old one's close event fires
|
|
367
|
-
if (this.ws === ws) {
|
|
368
|
-
this.ws = null;
|
|
369
|
-
}
|
|
370
|
-
this.detachActiveSocketListeners?.();
|
|
371
|
-
this.detachActiveSocketListeners = null;
|
|
372
|
-
if (this.rpc && !this.rpc.$closed) {
|
|
373
|
-
const disconnectError = new Error(
|
|
374
|
-
'Browser UI WebSocket disconnected before reload completed',
|
|
375
|
-
);
|
|
376
|
-
this.rpc.$close(disconnectError);
|
|
377
|
-
this.onDisconnect?.(disconnectError);
|
|
378
|
-
}
|
|
379
|
-
this.rpc = null;
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
ws.on('close', handleClose);
|
|
383
|
-
this.detachActiveSocketListeners = () => {
|
|
384
|
-
ws.off('close', handleClose);
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Check if a container is currently connected */
|
|
389
|
-
get isConnected(): boolean {
|
|
390
|
-
return this.ws !== null && this.ws.readyState === this.ws.OPEN;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Get the current WebSocket instance (for reuse in watch mode) */
|
|
394
|
-
get currentWebSocket(): WebSocket | null {
|
|
395
|
-
return this.ws;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** Reattach an existing WebSocket (for watch mode reuse) */
|
|
399
|
-
reattach(ws: WebSocket): void {
|
|
400
|
-
this.attachWebSocket(ws);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** Notify container of test file changes */
|
|
404
|
-
async notifyTestFileUpdate(files: TestFileInfo[]): Promise<void> {
|
|
405
|
-
await this.rpc?.onTestFileUpdate(files);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/** Push a refreshed host config to the container (watch reruns) */
|
|
409
|
-
async updateHostConfig(config: BrowserHostConfig): Promise<void> {
|
|
410
|
-
await this.rpc?.onHostConfigUpdate(config);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Request container to reload a specific test file */
|
|
414
|
-
async reloadTestFile(
|
|
415
|
-
testFile: string,
|
|
416
|
-
testNamePattern?: string,
|
|
417
|
-
): Promise<ReloadTestFileAck> {
|
|
418
|
-
logger.debug(
|
|
419
|
-
`[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
|
|
420
|
-
);
|
|
421
|
-
if (!this.rpc) {
|
|
422
|
-
throw new Error('Browser UI RPC not available for reloadTestFile');
|
|
423
|
-
}
|
|
424
|
-
logger.debug(`[Browser UI] Calling reloadTestFile: ${testFile}`);
|
|
425
|
-
return this.rpc.reloadTestFile(testFile, testNamePattern);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// ============================================================================
|
|
430
|
-
// Browser Runtime - Core runtime state
|
|
431
|
-
// ============================================================================
|
|
432
|
-
|
|
433
|
-
// One isolated rsbuild instance + dev server per browser project. Physical
|
|
434
|
-
// isolation (separate compiler, dev server, port, virtual manifest, runner)
|
|
435
|
-
// prevents one project's build/resolve config from leaking into another's
|
|
436
|
-
// compilation and avoids shared-dev-server races (chunk filenames,
|
|
437
|
-
// lazyCompilation backend) between projects.
|
|
438
|
-
type BrowserProjectServer = {
|
|
439
|
-
projectName: string;
|
|
440
|
-
environmentName: string;
|
|
441
|
-
rsbuildInstance: RsbuildInstance;
|
|
442
|
-
devServer: RsbuildDevServer;
|
|
443
|
-
port: number;
|
|
444
|
-
manifestPath: string;
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
// Watch diff/rerun state. Lives on the BrowserRuntime (one per set of
|
|
448
|
-
// per-project compilers, surviving controller re-entry that reuses the
|
|
449
|
-
// runtime) instead of module scope, so its lifetime always matches the
|
|
450
|
-
// compilers whose baselines it holds.
|
|
451
|
-
type BrowserWatchState = {
|
|
452
|
-
lastTestFiles: TestFileInfo[];
|
|
453
|
-
hooksEnabled: boolean;
|
|
454
|
-
// Diff baselines keyed per project: sibling projects have isolated
|
|
455
|
-
// compilers, so a shared flat baseline would let one project's compile
|
|
456
|
-
// clobber another's (missed reruns) and collide on compiler-local chunk
|
|
457
|
-
// keys.
|
|
458
|
-
invalidation: Map<string, WatchInvalidationState>;
|
|
459
|
-
// Affected files accumulated per project until a rerun drains them, so a
|
|
460
|
-
// compile finishing while another project's rerun is being planned cannot
|
|
461
|
-
// drop pending work.
|
|
462
|
-
pendingAffectedTestFiles: Map<string, Set<string>>;
|
|
463
|
-
// Per-project compile start times and the accumulated compile duration of
|
|
464
|
-
// the pending rerun, so the rerun's finalize reports the real buildTime.
|
|
465
|
-
compileStartTimes: Map<string, number>;
|
|
466
|
-
pendingBuildTimeMs: number;
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
const createBrowserWatchState = (): BrowserWatchState => ({
|
|
470
|
-
lastTestFiles: [],
|
|
471
|
-
hooksEnabled: false,
|
|
472
|
-
invalidation: new Map(),
|
|
473
|
-
pendingAffectedTestFiles: new Map(),
|
|
474
|
-
compileStartTimes: new Map(),
|
|
475
|
-
pendingBuildTimeMs: 0,
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
const drainPendingBuildTime = (watchState: BrowserWatchState): number => {
|
|
479
|
-
const buildTime = watchState.pendingBuildTimeMs;
|
|
480
|
-
watchState.pendingBuildTimeMs = 0;
|
|
481
|
-
return buildTime;
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
const drainPendingAffectedTestFiles = (
|
|
485
|
-
watchState: BrowserWatchState,
|
|
486
|
-
): string[] => {
|
|
487
|
-
const affected = new Set<string>();
|
|
488
|
-
for (const files of watchState.pendingAffectedTestFiles.values()) {
|
|
489
|
-
for (const file of files) {
|
|
490
|
-
affected.add(file);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
watchState.pendingAffectedTestFiles.clear();
|
|
494
|
-
return Array.from(affected);
|
|
495
|
-
};
|
|
496
|
-
|
|
497
|
-
type BrowserRuntime = {
|
|
498
|
-
// Per-project servers, keyed by project name.
|
|
499
|
-
projectServers: Map<string, BrowserProjectServer>;
|
|
500
|
-
// The server that hosts the container UI HTML (headed mode). The WebSocket
|
|
501
|
-
// server below is shared and reachable from any origin.
|
|
502
|
-
containerServer: BrowserProjectServer;
|
|
503
|
-
browser: BrowserProviderBrowser;
|
|
504
|
-
browserLaunchOptions: BrowserLaunchOptions;
|
|
505
|
-
wsPort: number;
|
|
506
|
-
tempDir: string;
|
|
507
|
-
containerPage?: BrowserProviderPage;
|
|
508
|
-
containerContext?: BrowserProviderContext;
|
|
509
|
-
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
510
|
-
// Reserved extension seam for host-side dispatch capabilities.
|
|
511
|
-
dispatchHandlers: Map<string, BrowserDispatchHandler>;
|
|
512
|
-
wss: WebSocketServer;
|
|
513
|
-
rpcManager?: ContainerRpcManager;
|
|
514
|
-
projectEntries: BrowserProjectEntries[];
|
|
515
|
-
watchState: BrowserWatchState;
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
// ============================================================================
|
|
519
|
-
// Watch Mode Context - Process-lifecycle watch state
|
|
520
|
-
// ============================================================================
|
|
521
|
-
|
|
522
|
-
// Only process-wide concerns stay module-level: the runtime handle reused
|
|
523
|
-
// across controller re-entry (config-change restarts), and the signal/exit
|
|
524
|
-
// cleanup that must run once per process. Diff/rerun state lives on
|
|
525
|
-
// `BrowserRuntime.watchState`.
|
|
526
|
-
type WatchContext = {
|
|
527
|
-
runtime: BrowserRuntime | null;
|
|
528
|
-
cleanupRegistered: boolean;
|
|
529
|
-
cleanupPromise: Promise<void> | null;
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const watchContext: WatchContext = {
|
|
533
|
-
runtime: null,
|
|
534
|
-
cleanupRegistered: false,
|
|
535
|
-
cleanupPromise: null,
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
// ============================================================================
|
|
539
|
-
// Utility Functions
|
|
540
|
-
// ============================================================================
|
|
541
|
-
|
|
542
|
-
const resolveViewport = (
|
|
543
|
-
viewport: BrowserViewport | undefined,
|
|
544
|
-
): { width: number; height: number } | null => {
|
|
545
|
-
if (!viewport) {
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (typeof viewport === 'string') {
|
|
550
|
-
return resolveBrowserViewportPreset(viewport);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
typeof viewport.width === 'number' &&
|
|
555
|
-
Number.isFinite(viewport.width) &&
|
|
556
|
-
viewport.width > 0 &&
|
|
557
|
-
typeof viewport.height === 'number' &&
|
|
558
|
-
Number.isFinite(viewport.height) &&
|
|
559
|
-
viewport.height > 0
|
|
560
|
-
) {
|
|
561
|
-
return {
|
|
562
|
-
width: viewport.width,
|
|
563
|
-
height: viewport.height,
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
return null;
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
const mapViewportByProject = (
|
|
571
|
-
projects: BrowserProjectRuntime[],
|
|
572
|
-
): Map<string, { width: number; height: number }> => {
|
|
573
|
-
const map = new Map<string, { width: number; height: number }>();
|
|
574
|
-
for (const project of projects) {
|
|
575
|
-
const viewport = resolveViewport(project.viewport);
|
|
576
|
-
if (viewport) {
|
|
577
|
-
map.set(project.name, viewport);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
return map;
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
const ensureProcessExitCode = (code: number): void => {
|
|
584
|
-
if (process.exitCode === undefined || process.exitCode === 0) {
|
|
585
|
-
process.exitCode = code;
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
|
|
589
|
-
const castArray = <T>(arr?: T | T[]): T[] => {
|
|
590
|
-
if (arr === undefined) {
|
|
591
|
-
return [];
|
|
592
|
-
}
|
|
593
|
-
return Array.isArray(arr) ? arr : [arr];
|
|
594
|
-
};
|
|
595
|
-
|
|
596
|
-
const applyDefaultWatchOptions = (
|
|
597
|
-
rspackConfig: Rspack.Configuration,
|
|
598
|
-
isWatchMode: boolean,
|
|
599
|
-
) => {
|
|
600
|
-
rspackConfig.watchOptions ??= {};
|
|
601
|
-
|
|
602
|
-
if (!isWatchMode) {
|
|
603
|
-
rspackConfig.watchOptions.ignored = '**/**';
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
rspackConfig.watchOptions.ignored = castArray(
|
|
608
|
-
rspackConfig.watchOptions.ignored || [],
|
|
609
|
-
) as string[];
|
|
610
|
-
|
|
611
|
-
if (rspackConfig.watchOptions.ignored.length === 0) {
|
|
612
|
-
rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
if (rspackConfig.output?.path) {
|
|
616
|
-
rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
type LazyCompilationModule = {
|
|
621
|
-
nameForCondition?: () => string | null | undefined;
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
type BrowserLazyCompilationConfig = {
|
|
625
|
-
imports: true;
|
|
626
|
-
entries: false;
|
|
627
|
-
test?: (module: LazyCompilationModule) => boolean;
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Resolve the actual port the dev server is listening on.
|
|
632
|
-
*
|
|
633
|
-
* Rsbuild's `devServer.listen()` may return `0` when configured with
|
|
634
|
-
* `server.port: 0` because its internal `getPort` never reads back the
|
|
635
|
-
* OS-assigned ephemeral port. This helper falls back to
|
|
636
|
-
* `httpServer.address()` to obtain the real bound port.
|
|
637
|
-
*/
|
|
638
|
-
export const resolveListenPort = (
|
|
639
|
-
listenPort: number,
|
|
640
|
-
httpServer: {
|
|
641
|
-
address: () => ReturnType<import('node:net').Server['address']>;
|
|
642
|
-
} | null,
|
|
643
|
-
): number => {
|
|
644
|
-
if (listenPort) {
|
|
645
|
-
return listenPort;
|
|
646
|
-
}
|
|
647
|
-
const addr = httpServer?.address();
|
|
648
|
-
if (addr && typeof addr === 'object') {
|
|
649
|
-
return addr.port;
|
|
650
|
-
}
|
|
651
|
-
return listenPort;
|
|
652
|
-
};
|
|
653
|
-
|
|
654
|
-
export const createBrowserLazyCompilationConfig = (
|
|
655
|
-
setupFiles: string[],
|
|
656
|
-
): BrowserLazyCompilationConfig => {
|
|
657
|
-
const eagerSetupFiles = new Set(
|
|
658
|
-
setupFiles.map((filePath) => normalize(filePath)),
|
|
659
|
-
);
|
|
660
|
-
|
|
661
|
-
if (eagerSetupFiles.size === 0) {
|
|
662
|
-
return {
|
|
663
|
-
imports: true,
|
|
664
|
-
entries: false,
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
return {
|
|
669
|
-
imports: true,
|
|
670
|
-
entries: false,
|
|
671
|
-
test(module: LazyCompilationModule) {
|
|
672
|
-
const filePath = module.nameForCondition?.();
|
|
673
|
-
return !filePath || !eagerSetupFiles.has(normalize(filePath));
|
|
674
|
-
},
|
|
675
|
-
};
|
|
676
|
-
};
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* HMR — and the lazyCompilation transport it carries — is wired only for headed
|
|
680
|
-
* watch, the sole path that reuses a persistent page and applies module updates
|
|
681
|
-
* in place. Headless always loads each test file in a fresh page (pulling the
|
|
682
|
-
* latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
|
|
683
|
-
* so pushing HMR updates there is dead weight that only races factory
|
|
684
|
-
* registration for chunk-split node_modules (rspack#11922) and lets
|
|
685
|
-
* lazyCompilation's accept-chain walk abort the next spec when no boundary
|
|
686
|
-
* exists (#1472). Disabling HMR does not make watch rebuilds any less
|
|
687
|
-
* incremental — HMR is only the client push transport.
|
|
688
|
-
*/
|
|
689
|
-
export const shouldEnableBrowserHmr = (
|
|
690
|
-
isWatchMode: boolean,
|
|
691
|
-
isHeadless: boolean,
|
|
692
|
-
): boolean => isWatchMode && !isHeadless;
|
|
693
|
-
|
|
694
|
-
export const createBrowserRsbuildDevConfig = (
|
|
695
|
-
enableHmr: boolean,
|
|
696
|
-
): {
|
|
697
|
-
writeToDisk: boolean;
|
|
698
|
-
hmr: boolean;
|
|
699
|
-
client: {
|
|
700
|
-
logLevel: 'error';
|
|
701
|
-
};
|
|
702
|
-
} => {
|
|
703
|
-
return {
|
|
704
|
-
writeToDisk: isDebug(),
|
|
705
|
-
// `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
|
|
706
|
-
// path that reuses a page. See that helper for why fresh-page runs (headless,
|
|
707
|
-
// or any one-shot) must not receive HMR pushes.
|
|
708
|
-
hmr: enableHmr,
|
|
709
|
-
client: {
|
|
710
|
-
logLevel: 'error' as const,
|
|
711
|
-
},
|
|
712
|
-
};
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Convert a single glob pattern to RegExp using picomatch
|
|
717
|
-
* Based on Storybook's implementation
|
|
718
|
-
*/
|
|
719
|
-
const globToRegexp = (glob: string): RegExp => {
|
|
720
|
-
const regex = picomatch.makeRe(glob, {
|
|
721
|
-
fastpaths: false,
|
|
722
|
-
noglobstar: false,
|
|
723
|
-
bash: false,
|
|
724
|
-
dot: true,
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
if (!regex) {
|
|
728
|
-
throw new Error(`Invalid glob pattern: ${glob}`);
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// picomatch generates regex starting with ^
|
|
732
|
-
// For patterns starting with ./, we need special handling
|
|
733
|
-
if (!glob.startsWith('./')) {
|
|
734
|
-
return regex;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// makeRe is sort of funny. If you pass it a directory starting with `./` it
|
|
738
|
-
// creates a matcher that expects files with no prefix (e.g. `src/file.js`)
|
|
739
|
-
// but if you pass it a directory that starts with `../` it expects files that
|
|
740
|
-
// start with `../`. Let's make it consistent.
|
|
741
|
-
// Globs starting `**` need special treatment due to the regex they produce
|
|
742
|
-
return new RegExp(
|
|
743
|
-
[
|
|
744
|
-
'^\\.',
|
|
745
|
-
glob.startsWith('./**') ? '' : '[\\\\/]',
|
|
746
|
-
regex.source.substring(1),
|
|
747
|
-
].join(''),
|
|
748
|
-
);
|
|
749
|
-
};
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* Convert rstest include glob patterns to RegExp for import.meta.webpackContext
|
|
753
|
-
* Uses picomatch for robust glob-to-regexp conversion
|
|
754
|
-
*/
|
|
755
|
-
const globPatternsToRegExp = (patterns: string[]): RegExp => {
|
|
756
|
-
const regexParts = patterns.map((pattern) => {
|
|
757
|
-
const regex = globToRegexp(pattern);
|
|
758
|
-
// Remove ^ anchor and $ anchor to allow combining patterns
|
|
759
|
-
let source = regex.source;
|
|
760
|
-
if (source.startsWith('^')) {
|
|
761
|
-
source = source.substring(1);
|
|
762
|
-
}
|
|
763
|
-
if (source.endsWith('$')) {
|
|
764
|
-
source = source.substring(0, source.length - 1);
|
|
765
|
-
}
|
|
766
|
-
return source;
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
return new RegExp(`(?:${regexParts.join('|')})$`);
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
|
|
773
|
-
const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
|
|
774
|
-
const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
|
|
775
|
-
|
|
776
|
-
const escapeRegExp = (value: string): string =>
|
|
777
|
-
value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
|
|
778
|
-
|
|
779
|
-
const normalizePathForRegExp = (value: string): string =>
|
|
780
|
-
normalize(value).replaceAll('\\', '/');
|
|
781
|
-
|
|
782
|
-
const normalizeExcludePatternForRegExp = (value: string): string =>
|
|
783
|
-
value.startsWith('./')
|
|
784
|
-
? `./${normalizePathForRegExp(value.substring(2))}`
|
|
785
|
-
: normalizePathForRegExp(value);
|
|
786
|
-
|
|
787
|
-
const isAbsolutePatternForRegExp = (value: string): boolean =>
|
|
788
|
-
value.startsWith('/') || /^[A-Za-z]:\//.test(value);
|
|
789
|
-
|
|
790
|
-
const isEscapedRegExpCharacter = (source: string, index: number): boolean => {
|
|
791
|
-
let backslashCount = 0;
|
|
792
|
-
for (
|
|
793
|
-
let current = index - 1;
|
|
794
|
-
current >= 0 && source[current] === '\\';
|
|
795
|
-
current--
|
|
796
|
-
) {
|
|
797
|
-
backslashCount++;
|
|
798
|
-
}
|
|
799
|
-
return backslashCount % 2 === 1;
|
|
800
|
-
};
|
|
801
|
-
|
|
802
|
-
const replacePathSeparatorsInRegExpSource = (source: string): string => {
|
|
803
|
-
let result = '';
|
|
804
|
-
let inCharacterClass = false;
|
|
805
|
-
|
|
806
|
-
for (let index = 0; index < source.length; index++) {
|
|
807
|
-
const character = source[index];
|
|
808
|
-
const isEscaped = isEscapedRegExpCharacter(source, index);
|
|
809
|
-
|
|
810
|
-
if (character === '[' && !isEscaped) {
|
|
811
|
-
inCharacterClass = true;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
if (!inCharacterClass && character === '\\' && source[index + 1] === '/') {
|
|
815
|
-
result += PATH_SEPARATOR_SOURCE;
|
|
816
|
-
index++;
|
|
817
|
-
continue;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
result += character;
|
|
821
|
-
|
|
822
|
-
if (character === ']' && !isEscaped) {
|
|
823
|
-
inCharacterClass = false;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
return result;
|
|
828
|
-
};
|
|
829
|
-
|
|
830
|
-
type BrowserContextExcludeSource = {
|
|
831
|
-
relative: string;
|
|
832
|
-
absolute: string;
|
|
833
|
-
isAbsolute: boolean;
|
|
834
|
-
};
|
|
835
|
-
|
|
836
|
-
const createRelativeContextExcludeSource = (
|
|
837
|
-
source: string,
|
|
838
|
-
normalizedPattern: string,
|
|
839
|
-
): string => {
|
|
840
|
-
if (normalizedPattern.startsWith('./')) {
|
|
841
|
-
return source;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return normalizedPattern.startsWith('**/')
|
|
845
|
-
? `(?:(?:${source})|\\.(?:${source}))`
|
|
846
|
-
: `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
|
|
847
|
-
};
|
|
848
|
-
|
|
849
|
-
const createProjectAbsoluteExcludeSource = (
|
|
850
|
-
source: string,
|
|
851
|
-
normalizedPattern: string,
|
|
852
|
-
): string =>
|
|
853
|
-
normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/')
|
|
854
|
-
? source
|
|
855
|
-
: `${PATH_SEPARATOR_SOURCE}(?:${source})`;
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
|
|
859
|
-
* This is used at compile time to filter out files during bundling
|
|
860
|
-
*
|
|
861
|
-
* Example:
|
|
862
|
-
* Input: ['**\/node_modules\/**', '**\/dist\/**']
|
|
863
|
-
* Output: a regexp matching node_modules or dist path segments.
|
|
864
|
-
*/
|
|
865
|
-
const excludePatternsToRegExpSources = (
|
|
866
|
-
patterns: string[],
|
|
867
|
-
): BrowserContextExcludeSource[] | null => {
|
|
868
|
-
const sources = patterns.map((pattern) => {
|
|
869
|
-
const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
|
|
870
|
-
const regex = globToRegexp(normalizedPattern);
|
|
871
|
-
let source = regex.source;
|
|
872
|
-
if (source.startsWith('^')) {
|
|
873
|
-
source = source.substring(1);
|
|
874
|
-
}
|
|
875
|
-
if (source.endsWith('$')) {
|
|
876
|
-
source = source.substring(0, source.length - 1);
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
source = replacePathSeparatorsInRegExpSource(source);
|
|
880
|
-
const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
|
|
881
|
-
const absolute = normalizedPattern.startsWith('./')
|
|
882
|
-
? source.substring(2)
|
|
883
|
-
: source;
|
|
884
|
-
|
|
885
|
-
return {
|
|
886
|
-
relative: isAbsolute
|
|
887
|
-
? source
|
|
888
|
-
: createRelativeContextExcludeSource(source, normalizedPattern),
|
|
889
|
-
absolute: isAbsolute
|
|
890
|
-
? absolute
|
|
891
|
-
: createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
|
|
892
|
-
isAbsolute,
|
|
893
|
-
};
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
if (sources.length === 0) {
|
|
897
|
-
return null;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
return sources;
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
export const createBrowserContextExcludeRegExp = (
|
|
904
|
-
patterns: string[],
|
|
905
|
-
projectRoot: string,
|
|
906
|
-
): RegExp | null => {
|
|
907
|
-
const excludeSources = excludePatternsToRegExpSources(patterns);
|
|
908
|
-
if (!excludeSources) {
|
|
909
|
-
return null;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(
|
|
913
|
-
/[\\/]$/,
|
|
914
|
-
'',
|
|
915
|
-
);
|
|
916
|
-
const projectRootSource = normalizedProjectRoot
|
|
917
|
-
.split('/')
|
|
918
|
-
.map(escapeRegExp)
|
|
919
|
-
.join(PATH_SEPARATOR_SOURCE);
|
|
920
|
-
const relativeExcludeSources = excludeSources.filter(
|
|
921
|
-
(source) => !source.isAbsolute,
|
|
922
|
-
);
|
|
923
|
-
const absoluteExcludeSources = excludeSources.filter(
|
|
924
|
-
(source) => source.isAbsolute,
|
|
925
|
-
);
|
|
926
|
-
const sourceBranches: string[] = [];
|
|
927
|
-
|
|
928
|
-
if (relativeExcludeSources.length > 0) {
|
|
929
|
-
const relativePatternSource = `(?:${relativeExcludeSources
|
|
930
|
-
.map((source) => source.relative)
|
|
931
|
-
.join('|')})`;
|
|
932
|
-
const absolutePatternSource = `(?:${relativeExcludeSources
|
|
933
|
-
.map((source) => source.absolute)
|
|
934
|
-
.join('|')})`;
|
|
935
|
-
const relativeSource = `(?:${relativePatternSource})`;
|
|
936
|
-
const absoluteSource = normalizedProjectRoot
|
|
937
|
-
? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})`
|
|
938
|
-
: `(?:${absolutePatternSource})`;
|
|
939
|
-
|
|
940
|
-
sourceBranches.push(
|
|
941
|
-
`(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`,
|
|
942
|
-
absoluteSource,
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
if (absoluteExcludeSources.length > 0) {
|
|
947
|
-
sourceBranches.push(
|
|
948
|
-
`(?:${absoluteExcludeSources
|
|
949
|
-
.map((source) => source.relative)
|
|
950
|
-
.join('|')})`,
|
|
951
|
-
);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
return new RegExp(`^(?:${sourceBranches.join('|')})$`);
|
|
955
|
-
};
|
|
956
|
-
|
|
957
|
-
type StatsModule = {
|
|
958
|
-
nameForCondition?: string;
|
|
959
|
-
children?: StatsModule[];
|
|
960
|
-
};
|
|
961
|
-
|
|
962
|
-
type StatsChunk = {
|
|
963
|
-
id?: string | number;
|
|
964
|
-
names?: string[];
|
|
965
|
-
hash?: string;
|
|
966
|
-
files?: string[];
|
|
967
|
-
modules?: StatsModule[];
|
|
968
|
-
};
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Find test file path from chunk modules by matching against known entry files.
|
|
972
|
-
*/
|
|
973
|
-
const findTestFileInModules = (
|
|
974
|
-
modules: StatsModule[] | undefined,
|
|
975
|
-
entryTestFiles: Set<string>,
|
|
976
|
-
): string | null => {
|
|
977
|
-
if (!modules) return null;
|
|
978
|
-
|
|
979
|
-
for (const m of modules) {
|
|
980
|
-
if (m.nameForCondition) {
|
|
981
|
-
const normalizedPath = normalize(m.nameForCondition);
|
|
982
|
-
if (entryTestFiles.has(normalizedPath)) {
|
|
983
|
-
return normalizedPath;
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
if (m.children) {
|
|
987
|
-
const found = findTestFileInModules(m.children, entryTestFiles);
|
|
988
|
-
if (found) return found;
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
return null;
|
|
992
|
-
};
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* Get a stable identifier for a chunk.
|
|
996
|
-
* Prefers chunk.id or chunk.names[0] over file paths for stability.
|
|
997
|
-
*/
|
|
998
|
-
const getChunkKey = (chunk: StatsChunk): string | null => {
|
|
999
|
-
if (chunk.id != null) {
|
|
1000
|
-
return String(chunk.id);
|
|
1001
|
-
}
|
|
1002
|
-
if (chunk.names && chunk.names.length > 0) {
|
|
1003
|
-
return chunk.names[0]!;
|
|
1004
|
-
}
|
|
1005
|
-
if (chunk.files && chunk.files.length > 0) {
|
|
1006
|
-
return chunk.files[0]!;
|
|
1007
|
-
}
|
|
1008
|
-
return null;
|
|
1009
|
-
};
|
|
1010
|
-
|
|
1011
|
-
/**
|
|
1012
|
-
* Fold one project compile's chunks into per-entry hash snapshots and apply
|
|
1013
|
-
* the shared watch-invalidation policy against that project's baseline.
|
|
1014
|
-
* Chunks are attributed to a test/setup file by scanning their modules; the
|
|
1015
|
-
* chunk.id/names key is only the hash-record key, never a cross-project one.
|
|
1016
|
-
*/
|
|
1017
|
-
const getAffectedTestFiles = ({
|
|
1018
|
-
chunks,
|
|
1019
|
-
entryTestFiles,
|
|
1020
|
-
setupFiles,
|
|
1021
|
-
state,
|
|
1022
|
-
}: {
|
|
1023
|
-
chunks: StatsChunk[] | undefined;
|
|
1024
|
-
entryTestFiles: Set<string>;
|
|
1025
|
-
setupFiles: Set<string>;
|
|
1026
|
-
state: WatchInvalidationState;
|
|
1027
|
-
}): string[] => {
|
|
1028
|
-
const entryHashes: EntryHashSnapshot = new Map();
|
|
1029
|
-
const setupHashes: EntryHashSnapshot = new Map();
|
|
1030
|
-
|
|
1031
|
-
const recordChunk = (
|
|
1032
|
-
snapshot: EntryHashSnapshot,
|
|
1033
|
-
entryPath: string,
|
|
1034
|
-
chunkKey: string,
|
|
1035
|
-
hash: string,
|
|
1036
|
-
) => {
|
|
1037
|
-
const record = snapshot.get(entryPath) ?? {};
|
|
1038
|
-
record[chunkKey] = hash;
|
|
1039
|
-
snapshot.set(entryPath, record);
|
|
1040
|
-
};
|
|
1041
|
-
|
|
1042
|
-
for (const chunk of chunks || []) {
|
|
1043
|
-
if (!chunk.hash) continue;
|
|
1044
|
-
|
|
1045
|
-
const chunkKey = getChunkKey(chunk);
|
|
1046
|
-
if (!chunkKey) continue;
|
|
1047
|
-
|
|
1048
|
-
const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
|
|
1049
|
-
if (testFile) {
|
|
1050
|
-
recordChunk(entryHashes, testFile, chunkKey, chunk.hash);
|
|
1051
|
-
continue;
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
const setupFile = findTestFileInModules(chunk.modules, setupFiles);
|
|
1055
|
-
if (setupFile) {
|
|
1056
|
-
recordChunk(setupHashes, setupFile, chunkKey, chunk.hash);
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// Headed watch compiles chunks on demand (lazyCompilation), so an entry's
|
|
1061
|
-
// first appearance in stats means "just loaded", not "just added": its first
|
|
1062
|
-
// sighting establishes the baseline instead of marking a change. Genuinely
|
|
1063
|
-
// new and deleted test files are owned by the test-file-set diff in
|
|
1064
|
-
// `planWatchRerun` / `collectDeletedTestPaths`.
|
|
1065
|
-
const seedFirstSeen = (
|
|
1066
|
-
baseline: EntryHashSnapshot | undefined,
|
|
1067
|
-
current: EntryHashSnapshot,
|
|
1068
|
-
) => {
|
|
1069
|
-
if (!baseline) return;
|
|
1070
|
-
for (const [entryPath, record] of current) {
|
|
1071
|
-
if (!baseline.has(entryPath)) {
|
|
1072
|
-
baseline.set(entryPath, record);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
};
|
|
1076
|
-
seedFirstSeen(state.entryHashes, entryHashes);
|
|
1077
|
-
seedFirstSeen(state.setupHashes, setupHashes);
|
|
1078
|
-
|
|
1079
|
-
const outcome = applyWatchInvalidation(state, { entryHashes, setupHashes });
|
|
1080
|
-
|
|
1081
|
-
if (outcome.rerunAll) {
|
|
1082
|
-
logger.debug(
|
|
1083
|
-
'[Watch] Setup file changed, re-running all test files of the project',
|
|
1084
|
-
);
|
|
1085
|
-
return Array.from(entryTestFiles);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
for (const affected of outcome.affectedPaths) {
|
|
1089
|
-
logger.debug(`[Watch] Chunk hash changed for test: ${affected}`);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
return outcome.affectedPaths;
|
|
1093
|
-
};
|
|
1094
|
-
|
|
1095
|
-
const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
|
|
1096
|
-
context.projects.filter(
|
|
1097
|
-
(project) => project.normalizedConfig.browser.enabled,
|
|
1098
|
-
);
|
|
1099
|
-
|
|
1100
|
-
const getBrowserRsbuildEnvironmentConfig = (
|
|
1101
|
-
project: ProjectContext,
|
|
1102
|
-
): RsbuildEnvironmentConfig => ({
|
|
1103
|
-
plugins: project.normalizedConfig.plugins,
|
|
1104
|
-
root: project.rootPath,
|
|
1105
|
-
});
|
|
1106
|
-
|
|
1107
|
-
// Max testTimeout across browser projects, used as the host->client RPC timeout.
|
|
1108
|
-
const getMaxTestTimeoutForRpc = (projects: ProjectContext[]): number =>
|
|
1109
|
-
Math.max(
|
|
1110
|
-
...projects.map(
|
|
1111
|
-
(p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
|
|
1112
|
-
),
|
|
1113
|
-
);
|
|
1114
|
-
|
|
1115
|
-
const getBrowserLaunchOptions = (
|
|
1116
|
-
project: ProjectContext,
|
|
1117
|
-
): BrowserLaunchOptions => ({
|
|
1118
|
-
provider: project.normalizedConfig.browser.provider,
|
|
1119
|
-
browser: project.normalizedConfig.browser.browser,
|
|
1120
|
-
headless: project.normalizedConfig.browser.headless,
|
|
1121
|
-
port: project.normalizedConfig.browser.port,
|
|
1122
|
-
strictPort: project.normalizedConfig.browser.strictPort,
|
|
1123
|
-
providerOptions: getBrowserProviderOptions(project),
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
const ensureConsistentBrowserLaunchOptions = (
|
|
1127
|
-
projects: ProjectContext[],
|
|
1128
|
-
): BrowserLaunchOptions => {
|
|
1129
|
-
if (projects.length === 0) {
|
|
1130
|
-
throw new Error('No browser-enabled projects found.');
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
const firstProject = projects[0]!;
|
|
1134
|
-
const firstOptions = getBrowserLaunchOptions(firstProject);
|
|
1135
|
-
|
|
1136
|
-
for (const project of projects.slice(1)) {
|
|
1137
|
-
const options = getBrowserLaunchOptions(project);
|
|
1138
|
-
// Each browser project now runs on its own rsbuild dev server, so ports may
|
|
1139
|
-
// differ per project. Only the shared single Playwright browser forces
|
|
1140
|
-
// provider/browser/headless/providerOptions to match across projects.
|
|
1141
|
-
if (
|
|
1142
|
-
options.provider !== firstOptions.provider ||
|
|
1143
|
-
options.browser !== firstOptions.browser ||
|
|
1144
|
-
options.headless !== firstOptions.headless ||
|
|
1145
|
-
!isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
|
|
1146
|
-
) {
|
|
1147
|
-
throw new Error(
|
|
1148
|
-
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
1149
|
-
'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
|
|
1150
|
-
);
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
return firstOptions;
|
|
1155
|
-
};
|
|
1156
|
-
|
|
1157
|
-
const resolveProviderForTestPath = ({
|
|
1158
|
-
testPath,
|
|
1159
|
-
browserProjects,
|
|
1160
|
-
}: {
|
|
1161
|
-
testPath: string;
|
|
1162
|
-
browserProjects: BrowserProviderProject[];
|
|
1163
|
-
}): BrowserProvider => {
|
|
1164
|
-
const normalizedTestPath = normalize(testPath);
|
|
1165
|
-
const sortedProjects = [...browserProjects].sort(
|
|
1166
|
-
(a, b) => b.rootPath.length - a.rootPath.length,
|
|
1167
|
-
);
|
|
1168
|
-
|
|
1169
|
-
for (const project of sortedProjects) {
|
|
1170
|
-
if (normalizedTestPath.startsWith(project.rootPath)) {
|
|
1171
|
-
return project.provider;
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
throw new Error(
|
|
1176
|
-
`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
|
|
1177
|
-
`Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
|
|
1178
|
-
);
|
|
1179
|
-
};
|
|
1180
|
-
|
|
1181
|
-
const collectProjectEntries = async (
|
|
1182
|
-
context: RstestContext,
|
|
1183
|
-
// The explicit browser-project subset the executor was constructed with. Falls
|
|
1184
|
-
// back to re-deriving from `context` for internal callers (e.g. the watch
|
|
1185
|
-
// plugin) that do not carry the plan's project list.
|
|
1186
|
-
browserProjects: ProjectContext[] = getBrowserProjects(context),
|
|
1187
|
-
): Promise<BrowserProjectEntries[]> => {
|
|
1188
|
-
return Promise.all(
|
|
1189
|
-
browserProjects.map(async (project) => {
|
|
1190
|
-
const {
|
|
1191
|
-
normalizedConfig: { include, exclude, includeSource, setupFiles },
|
|
1192
|
-
} = project;
|
|
1193
|
-
|
|
1194
|
-
const tests = await getTestEntries({
|
|
1195
|
-
include,
|
|
1196
|
-
exclude: exclude.patterns,
|
|
1197
|
-
includeSource,
|
|
1198
|
-
rootPath: context.rootPath,
|
|
1199
|
-
projectRoot: project.rootPath,
|
|
1200
|
-
fileFilters: context.fileFilters || [],
|
|
1201
|
-
fileFilterMode: context.fileFilterMode,
|
|
1202
|
-
});
|
|
1203
|
-
|
|
1204
|
-
const setup = getSetupFiles(setupFiles, project.rootPath);
|
|
1205
|
-
|
|
1206
|
-
return {
|
|
1207
|
-
project,
|
|
1208
|
-
setupFiles: Object.values(setup),
|
|
1209
|
-
testFiles: Object.values(tests),
|
|
1210
|
-
};
|
|
1211
|
-
}),
|
|
1212
|
-
);
|
|
1213
|
-
};
|
|
1214
|
-
|
|
1215
|
-
const resolveBrowserFile = (relativePath: string): string => {
|
|
1216
|
-
// __dirname points to packages/browser/dist when running from built code
|
|
1217
|
-
// or packages/browser/src when running from source
|
|
1218
|
-
const candidates = [
|
|
1219
|
-
// When running from built dist: look in ../src for source files
|
|
1220
|
-
resolve(__dirname, '../src', relativePath),
|
|
1221
|
-
// When running from source (dev mode)
|
|
1222
|
-
resolve(__dirname, relativePath),
|
|
1223
|
-
];
|
|
1224
|
-
|
|
1225
|
-
for (const candidate of candidates) {
|
|
1226
|
-
if (existsSync(candidate)) {
|
|
1227
|
-
return candidate;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
throw new Error(`Unable to resolve browser client file: ${relativePath}`);
|
|
1232
|
-
};
|
|
1233
|
-
|
|
1234
|
-
const resolveContainerDist = (): string => {
|
|
1235
|
-
// When running from built dist: browser-container is in the same dist folder
|
|
1236
|
-
const distPath = resolve(__dirname, 'browser-container');
|
|
1237
|
-
if (existsSync(distPath)) {
|
|
1238
|
-
return distPath;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
throw new Error(
|
|
1242
|
-
`Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`,
|
|
1243
|
-
);
|
|
1244
|
-
};
|
|
1245
|
-
|
|
1246
|
-
// ============================================================================
|
|
1247
|
-
// Manifest Generation
|
|
1248
|
-
// ============================================================================
|
|
1249
|
-
|
|
1250
|
-
/**
|
|
1251
|
-
* Format environment name to a valid JavaScript identifier.
|
|
1252
|
-
* Replaces non-alphanumeric characters with underscores.
|
|
1253
|
-
*/
|
|
1254
|
-
const toSafeVarName = (name: string): string => {
|
|
1255
|
-
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
1256
|
-
};
|
|
1257
|
-
|
|
1258
|
-
// Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
|
|
1259
|
-
// `./<path-relative-to-project-root>` with forward slashes. The runtime derives
|
|
1260
|
-
// the same key from the target test file, so the non-watch import map below must
|
|
1261
|
-
// key by the identical form for `loadTest(key)` to resolve.
|
|
1262
|
-
export const toContextKey = (
|
|
1263
|
-
filePath: string,
|
|
1264
|
-
projectRootPosix: string,
|
|
1265
|
-
): string => {
|
|
1266
|
-
const posixPath = normalize(filePath);
|
|
1267
|
-
// Only strip the root at a path boundary: a bare `startsWith` would mangle a
|
|
1268
|
-
// sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
|
|
1269
|
-
const withinRoot =
|
|
1270
|
-
posixPath === projectRootPosix ||
|
|
1271
|
-
posixPath.startsWith(`${projectRootPosix}/`);
|
|
1272
|
-
if (!withinRoot) {
|
|
1273
|
-
// Test file outside the project root: use the absolute path as the key so
|
|
1274
|
-
// the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
|
|
1275
|
-
// key would be re-rooted under projectRoot and point at a nonexistent file.
|
|
1276
|
-
return posixPath;
|
|
1277
|
-
}
|
|
1278
|
-
const rel = posixPath.slice(projectRootPosix.length);
|
|
1279
|
-
return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
|
|
1280
|
-
};
|
|
1281
|
-
|
|
1282
|
-
const generateManifestModule = ({
|
|
1283
|
-
manifestPath,
|
|
1284
|
-
entries,
|
|
1285
|
-
isWatchMode,
|
|
1286
|
-
}: {
|
|
1287
|
-
manifestPath: string;
|
|
1288
|
-
entries: BrowserProjectEntries[];
|
|
1289
|
-
isWatchMode: boolean;
|
|
1290
|
-
}): string => {
|
|
1291
|
-
const manifestDirPosix = normalize(dirname(manifestPath));
|
|
1292
|
-
|
|
1293
|
-
const toRelativeImport = (filePath: string): string => {
|
|
1294
|
-
const posixPath = normalize(filePath);
|
|
1295
|
-
let relativePath = relative(manifestDirPosix, posixPath);
|
|
1296
|
-
if (!relativePath.startsWith('.')) {
|
|
1297
|
-
relativePath = `./${relativePath}`;
|
|
1298
|
-
}
|
|
1299
|
-
return relativePath;
|
|
1300
|
-
};
|
|
1301
|
-
|
|
1302
|
-
const lines: string[] = [];
|
|
1303
|
-
|
|
1304
|
-
// 1. Export all projects configuration
|
|
1305
|
-
lines.push('// All projects configuration');
|
|
1306
|
-
lines.push('export const projects = [');
|
|
1307
|
-
for (const { project } of entries) {
|
|
1308
|
-
lines.push(' {');
|
|
1309
|
-
lines.push(` name: ${JSON.stringify(project.name)},`);
|
|
1310
|
-
lines.push(
|
|
1311
|
-
` environmentName: ${JSON.stringify(project.environmentName)},`,
|
|
1312
|
-
);
|
|
1313
|
-
lines.push(
|
|
1314
|
-
` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
|
|
1315
|
-
);
|
|
1316
|
-
lines.push(' },');
|
|
1317
|
-
}
|
|
1318
|
-
lines.push('];');
|
|
1319
|
-
lines.push('');
|
|
1320
|
-
|
|
1321
|
-
// 2. Setup loaders for each project
|
|
1322
|
-
lines.push('// Setup loaders for each project');
|
|
1323
|
-
lines.push('export const projectSetupLoaders = {');
|
|
1324
|
-
for (const { project, setupFiles } of entries) {
|
|
1325
|
-
lines.push(` ${JSON.stringify(project.name)}: [`);
|
|
1326
|
-
for (const filePath of setupFiles) {
|
|
1327
|
-
const relativePath = toRelativeImport(filePath);
|
|
1328
|
-
lines.push(` () => import(${JSON.stringify(relativePath)}),`);
|
|
1329
|
-
}
|
|
1330
|
-
lines.push(' ],');
|
|
1331
|
-
}
|
|
1332
|
-
lines.push('};');
|
|
1333
|
-
lines.push('');
|
|
1334
|
-
|
|
1335
|
-
// 3. Test context for each project. Both branches expose the same shape as a
|
|
1336
|
-
// webpackContext (callable by key, plus `keys()`), consumed in section 4.
|
|
1337
|
-
lines.push('// Test context for each project');
|
|
1338
|
-
for (const { project, testFiles } of entries) {
|
|
1339
|
-
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
1340
|
-
const projectRootPosix = normalize(project.rootPath);
|
|
1341
|
-
|
|
1342
|
-
if (isWatchMode) {
|
|
1343
|
-
// Watch mode keeps the include-glob context so newly added files are
|
|
1344
|
-
// picked up on rebuild via `keys()` without regenerating the manifest.
|
|
1345
|
-
// `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
|
|
1346
|
-
// works with or without lazyCompilation; headed watch additionally layers
|
|
1347
|
-
// lazyCompilation on top to keep the initial build cheap.
|
|
1348
|
-
const includeRegExp = globPatternsToRegExp(
|
|
1349
|
-
project.normalizedConfig.include,
|
|
1350
|
-
);
|
|
1351
|
-
const excludeRegExp = createBrowserContextExcludeRegExp(
|
|
1352
|
-
project.normalizedConfig.exclude.patterns,
|
|
1353
|
-
projectRootPosix,
|
|
1354
|
-
);
|
|
1355
|
-
const { includeSource } = project.normalizedConfig;
|
|
1356
|
-
const emitContext = (contextVarName: string, regExp: RegExp): void => {
|
|
1357
|
-
lines.push(
|
|
1358
|
-
`const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
|
|
1359
|
-
);
|
|
1360
|
-
lines.push(' recursive: true,');
|
|
1361
|
-
lines.push(` regExp: ${regExp.toString()},`);
|
|
1362
|
-
if (excludeRegExp) {
|
|
1363
|
-
lines.push(` exclude: ${excludeRegExp.toString()},`);
|
|
1364
|
-
}
|
|
1365
|
-
lines.push(" mode: 'lazy',");
|
|
1366
|
-
lines.push('});');
|
|
1367
|
-
};
|
|
1368
|
-
|
|
1369
|
-
if (includeSource.length === 0) {
|
|
1370
|
-
emitContext(varName, includeRegExp);
|
|
1371
|
-
} else {
|
|
1372
|
-
// In-source test files (`includeSource`) carry an
|
|
1373
|
-
// `if (import.meta.rstest)` block. The include context can't see them,
|
|
1374
|
-
// so a second context over the `includeSource` globs backs
|
|
1375
|
-
// host-scheduled loads, while `keys()` only unions the entry-probed
|
|
1376
|
-
// in-source files (the probe does not apply inside the bundle, so raw
|
|
1377
|
-
// source-context keys would execute never-probed files and fail with
|
|
1378
|
-
// "No test suites found"). The probed list can go stale until a
|
|
1379
|
-
// manifest refresh; scheduled-by-path loading never does.
|
|
1380
|
-
emitContext(`${varName}_include`, includeRegExp);
|
|
1381
|
-
emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
|
|
1382
|
-
const probedKeys = testFiles.map((filePath) =>
|
|
1383
|
-
toContextKey(filePath, projectRootPosix),
|
|
1384
|
-
);
|
|
1385
|
-
lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
|
|
1386
|
-
lines.push(
|
|
1387
|
-
`const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
|
|
1388
|
-
);
|
|
1389
|
-
lines.push(`const ${varName} = Object.assign(`);
|
|
1390
|
-
lines.push(
|
|
1391
|
-
` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
|
|
1392
|
-
);
|
|
1393
|
-
lines.push(' {');
|
|
1394
|
-
lines.push(
|
|
1395
|
-
` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
|
|
1396
|
-
);
|
|
1397
|
-
lines.push(' },');
|
|
1398
|
-
lines.push(');');
|
|
1399
|
-
}
|
|
1400
|
-
} else {
|
|
1401
|
-
// One-shot runs: the file set is fixed and already filtered, so emit an
|
|
1402
|
-
// explicit lazy-import map (one chunk per literal `import()`, like the
|
|
1403
|
-
// setup loaders above). The eager, non-lazyCompilation build then compiles
|
|
1404
|
-
// only the run set instead of every included test file.
|
|
1405
|
-
lines.push(`const ${varName}_modules = {`);
|
|
1406
|
-
for (const filePath of testFiles) {
|
|
1407
|
-
const key = toContextKey(filePath, projectRootPosix);
|
|
1408
|
-
const importPath = toRelativeImport(filePath);
|
|
1409
|
-
lines.push(
|
|
1410
|
-
` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
|
|
1411
|
-
);
|
|
1412
|
-
}
|
|
1413
|
-
lines.push('};');
|
|
1414
|
-
lines.push(
|
|
1415
|
-
`const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
|
|
1416
|
-
);
|
|
1417
|
-
lines.push(` keys: () => Object.keys(${varName}_modules),`);
|
|
1418
|
-
lines.push('});');
|
|
1419
|
-
}
|
|
1420
|
-
lines.push('');
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// 4. Export test contexts object
|
|
1424
|
-
lines.push('export const projectTestContexts = {');
|
|
1425
|
-
for (const { project } of entries) {
|
|
1426
|
-
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
1427
|
-
lines.push(` ${JSON.stringify(project.name)}: {`);
|
|
1428
|
-
lines.push(` getTestKeys: () => ${varName}.keys(),`);
|
|
1429
|
-
lines.push(` loadTest: (key) => ${varName}(key),`);
|
|
1430
|
-
lines.push(
|
|
1431
|
-
` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
|
|
1432
|
-
);
|
|
1433
|
-
lines.push(' },');
|
|
1434
|
-
}
|
|
1435
|
-
lines.push('};');
|
|
1436
|
-
lines.push('');
|
|
1437
|
-
|
|
1438
|
-
// 5. Backward compatibility exports (use first project as default)
|
|
1439
|
-
lines.push('// Backward compatibility: export first project as default');
|
|
1440
|
-
lines.push('export const projectConfig = projects[0];');
|
|
1441
|
-
lines.push(
|
|
1442
|
-
'export const setupLoaders = projectSetupLoaders[projects[0].name] || [];',
|
|
1443
|
-
);
|
|
1444
|
-
lines.push('const _defaultCtx = projectTestContexts[projects[0].name];');
|
|
1445
|
-
lines.push(
|
|
1446
|
-
'export const getTestKeys = () => _defaultCtx ? _defaultCtx.getTestKeys() : [];',
|
|
1447
|
-
);
|
|
1448
|
-
lines.push(
|
|
1449
|
-
'export const loadTest = (key) => _defaultCtx ? _defaultCtx.loadTest(key) : Promise.reject(new Error("No project found"));',
|
|
1450
|
-
);
|
|
1451
|
-
|
|
1452
|
-
return `${lines.join('\n')}\n`;
|
|
1453
|
-
};
|
|
1454
|
-
|
|
1455
|
-
const htmlTemplate = `<!DOCTYPE html>
|
|
1456
|
-
<html lang="en">
|
|
1457
|
-
<head>
|
|
1458
|
-
<meta charset="UTF-8" />
|
|
1459
|
-
<title>Rstest Browser Runner</title>
|
|
1460
|
-
</head>
|
|
1461
|
-
<body>
|
|
1462
|
-
<script type="module" src="/static/js/runner.js"></script>
|
|
1463
|
-
</body>
|
|
1464
|
-
</html>
|
|
1465
|
-
`;
|
|
1466
|
-
|
|
1467
|
-
// Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
|
|
1468
|
-
// Rsbuild suppresses the removed-file log if all removed paths include "virtual":
|
|
1469
|
-
// https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
|
|
1470
|
-
const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
|
|
1471
|
-
|
|
1472
|
-
// ============================================================================
|
|
1473
|
-
// Browser Runtime Lifecycle
|
|
1474
|
-
// ============================================================================
|
|
1475
|
-
|
|
1476
|
-
const closeAllProjectServers = (
|
|
1477
|
-
servers: Iterable<BrowserProjectServer>,
|
|
1478
|
-
): Promise<unknown> =>
|
|
1479
|
-
Promise.allSettled([...servers].map((server) => server.devServer.close()));
|
|
1480
|
-
|
|
1481
|
-
// Copy a proxied fetch Response's status + headers onto the Node response,
|
|
1482
|
-
// dropping content-length (the body is re-sent, so the original length may not
|
|
1483
|
-
// match).
|
|
1484
|
-
const copyProxyResponseHeaders = (
|
|
1485
|
-
response: Response,
|
|
1486
|
-
res: ServerResponse,
|
|
1487
|
-
): void => {
|
|
1488
|
-
res.statusCode = response.status;
|
|
1489
|
-
response.headers.forEach((value, key) => {
|
|
1490
|
-
if (key.toLowerCase() === 'content-length') {
|
|
1491
|
-
return;
|
|
1492
|
-
}
|
|
1493
|
-
res.setHeader(key, value);
|
|
1494
|
-
});
|
|
1495
|
-
};
|
|
1496
|
-
|
|
1497
|
-
const destroyBrowserRuntime = async (
|
|
1498
|
-
runtime: BrowserRuntime,
|
|
1499
|
-
): Promise<void> => {
|
|
1500
|
-
try {
|
|
1501
|
-
await runtime.browser?.close?.();
|
|
1502
|
-
} catch {
|
|
1503
|
-
// ignore
|
|
1504
|
-
}
|
|
1505
|
-
await closeAllProjectServers(runtime.projectServers.values());
|
|
1506
|
-
try {
|
|
1507
|
-
runtime.wss?.close();
|
|
1508
|
-
} catch {
|
|
1509
|
-
// ignore
|
|
1510
|
-
}
|
|
1511
|
-
await fs
|
|
1512
|
-
.rm(runtime.tempDir, { recursive: true, force: true })
|
|
1513
|
-
.catch(() => {});
|
|
1514
|
-
};
|
|
1515
|
-
|
|
1516
|
-
const cleanupWatchRuntime = (): Promise<void> => {
|
|
1517
|
-
if (watchContext.cleanupPromise) {
|
|
1518
|
-
return watchContext.cleanupPromise;
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
watchContext.cleanupPromise = (async () => {
|
|
1522
|
-
if (!watchContext.runtime) {
|
|
1523
|
-
return;
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
await destroyBrowserRuntime(watchContext.runtime);
|
|
1527
|
-
watchContext.runtime = null;
|
|
1528
|
-
})();
|
|
1529
|
-
|
|
1530
|
-
return watchContext.cleanupPromise;
|
|
1531
|
-
};
|
|
1532
|
-
|
|
1533
|
-
const registerWatchCleanup = (embedded: boolean): void => {
|
|
1534
|
-
if (watchContext.cleanupRegistered) {
|
|
1535
|
-
return;
|
|
1536
|
-
}
|
|
1537
|
-
watchContext.cleanupRegistered = true;
|
|
1538
|
-
|
|
1539
|
-
// Embedded (programmatic) hosts own the process lifecycle; they tear the
|
|
1540
|
-
// session down through the watch handles' `close` instead of signals.
|
|
1541
|
-
if (embedded) {
|
|
1542
|
-
return;
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// Cleanup-only nets: core's watch loop owns the signal → exit-code path
|
|
1546
|
-
// (`registerBrowserWatchSignalExit` / the mixed watch handler) and awaits
|
|
1547
|
-
// the same idempotent `cleanupWatchRuntime` promise through `watch.close`.
|
|
1548
|
-
for (const signal of FATAL_SIGNALS) {
|
|
1549
|
-
process.once(signal, () => {
|
|
1550
|
-
void cleanupWatchRuntime();
|
|
1551
|
-
});
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
process.once('exit', () => {
|
|
1555
|
-
void cleanupWatchRuntime();
|
|
1556
|
-
});
|
|
1557
|
-
};
|
|
1558
|
-
|
|
1559
|
-
const createBrowserRuntime = async ({
|
|
1560
|
-
context,
|
|
1561
|
-
projectEntries: initialProjectEntries,
|
|
1562
|
-
browserProjects,
|
|
1563
|
-
shardedEntries,
|
|
1564
|
-
freezeShardedEntries,
|
|
1565
|
-
tempDir,
|
|
1566
|
-
isWatchMode,
|
|
1567
|
-
onTriggerRerun,
|
|
1568
|
-
containerDistPath,
|
|
1569
|
-
containerDevServer,
|
|
1570
|
-
forceHeadless,
|
|
1571
|
-
skipProviderLaunch,
|
|
1572
|
-
appliedModifyRstestConfigEnvironments,
|
|
1573
|
-
}: {
|
|
1574
|
-
context: RstestContext;
|
|
1575
|
-
projectEntries: BrowserProjectEntries[];
|
|
1576
|
-
/**
|
|
1577
|
-
* The explicit browser-project subset (plan output). Drives launch-option
|
|
1578
|
-
* consistency and the container origin (`browserProjects[0]`).
|
|
1579
|
-
*/
|
|
1580
|
-
browserProjects: ProjectContext[];
|
|
1581
|
-
shardedEntries?: Map<string, { entries: Record<string, string> }>;
|
|
1582
|
-
freezeShardedEntries?: boolean;
|
|
1583
|
-
tempDir: string;
|
|
1584
|
-
isWatchMode: boolean;
|
|
1585
|
-
onTriggerRerun?: () => Promise<void>;
|
|
1586
|
-
containerDistPath?: string;
|
|
1587
|
-
containerDevServer?: string;
|
|
1588
|
-
/** Force headless mode regardless of user config (used for list command) */
|
|
1589
|
-
forceHeadless?: boolean;
|
|
1590
|
-
skipProviderLaunch?: boolean;
|
|
1591
|
-
appliedModifyRstestConfigEnvironments?: Set<string>;
|
|
1592
|
-
}): Promise<BrowserRuntime> => {
|
|
1593
|
-
// ---- Shared singletons (created once, wired into every project server) ----
|
|
1594
|
-
const containerHtmlTemplate = containerDistPath
|
|
1595
|
-
? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
|
|
1596
|
-
: null;
|
|
1597
|
-
|
|
1598
|
-
let injectedContainerHtml: string | null = null;
|
|
1599
|
-
let serializedOptions = 'null';
|
|
1600
|
-
// Reserved extension seam for future browser-side capabilities.
|
|
1601
|
-
const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
|
|
1602
|
-
|
|
1603
|
-
const setContainerOptions = (options: BrowserHostConfig): void => {
|
|
1604
|
-
serializedOptions = serializeForInlineScript(options);
|
|
1605
|
-
if (containerHtmlTemplate) {
|
|
1606
|
-
injectedContainerHtml = containerHtmlTemplate.replace(
|
|
1607
|
-
OPTIONS_PLACEHOLDER,
|
|
1608
|
-
serializedOptions,
|
|
1609
|
-
);
|
|
1610
|
-
}
|
|
1611
|
-
};
|
|
1612
|
-
|
|
1613
|
-
let browserLaunchOptions =
|
|
1614
|
-
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1615
|
-
let projectEntries = initialProjectEntries;
|
|
1616
|
-
// Created with the runtime so the per-project watch plugins and the
|
|
1617
|
-
// controller's rerun closures share one state whose lifetime matches the
|
|
1618
|
-
// compilers holding the diffed chunks.
|
|
1619
|
-
const watchState = createBrowserWatchState();
|
|
1620
|
-
const manifestModules: Array<{
|
|
1621
|
-
manifestPath: string;
|
|
1622
|
-
project: ProjectContext;
|
|
1623
|
-
modules: Record<string, string>;
|
|
1624
|
-
}> = [];
|
|
1625
|
-
|
|
1626
|
-
const createRuntimeWithoutProvider = (): BrowserRuntime => {
|
|
1627
|
-
const firstProject = browserProjects[0]!;
|
|
1628
|
-
return {
|
|
1629
|
-
projectServers: new Map(),
|
|
1630
|
-
containerServer: {
|
|
1631
|
-
projectName: firstProject.name,
|
|
1632
|
-
environmentName: firstProject.environmentName,
|
|
1633
|
-
rsbuildInstance: undefined as unknown as RsbuildInstance,
|
|
1634
|
-
devServer: {
|
|
1635
|
-
close: async () => undefined,
|
|
1636
|
-
} as RsbuildDevServer,
|
|
1637
|
-
port: 0,
|
|
1638
|
-
manifestPath: '',
|
|
1639
|
-
},
|
|
1640
|
-
browser: undefined as unknown as BrowserProviderBrowser,
|
|
1641
|
-
browserLaunchOptions,
|
|
1642
|
-
wsPort: 0,
|
|
1643
|
-
tempDir,
|
|
1644
|
-
setContainerOptions,
|
|
1645
|
-
dispatchHandlers,
|
|
1646
|
-
wss: undefined as unknown as WebSocketServer,
|
|
1647
|
-
projectEntries,
|
|
1648
|
-
watchState,
|
|
1649
|
-
};
|
|
1650
|
-
};
|
|
1651
|
-
|
|
1652
|
-
const getProjectEntry = (project: ProjectContext) =>
|
|
1653
|
-
projectEntries.find(
|
|
1654
|
-
(item) => item.project.environmentName === project.environmentName,
|
|
1655
|
-
);
|
|
1656
|
-
|
|
1657
|
-
const refreshManifestModule = (manifestModule: {
|
|
1658
|
-
manifestPath: string;
|
|
1659
|
-
project: ProjectContext;
|
|
1660
|
-
modules: Record<string, string>;
|
|
1661
|
-
}): void => {
|
|
1662
|
-
const entry = getProjectEntry(manifestModule.project);
|
|
1663
|
-
manifestModule.modules[manifestModule.manifestPath] =
|
|
1664
|
-
generateManifestModule({
|
|
1665
|
-
manifestPath: manifestModule.manifestPath,
|
|
1666
|
-
entries: [
|
|
1667
|
-
{
|
|
1668
|
-
project: manifestModule.project,
|
|
1669
|
-
testFiles: entry?.testFiles ?? [],
|
|
1670
|
-
setupFiles: entry?.setupFiles ?? [],
|
|
1671
|
-
},
|
|
1672
|
-
],
|
|
1673
|
-
isWatchMode,
|
|
1674
|
-
});
|
|
1675
|
-
};
|
|
1676
|
-
|
|
1677
|
-
const refreshProjectEntries = async (): Promise<void> => {
|
|
1678
|
-
validateBrowserConfig(context);
|
|
1679
|
-
browserLaunchOptions =
|
|
1680
|
-
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1681
|
-
const updatedShardedEntries = freezeShardedEntries
|
|
1682
|
-
? shardedEntries
|
|
1683
|
-
: context.normalizedConfig.shard
|
|
1684
|
-
? await resolveShardedEntries(context, { silent: true })
|
|
1685
|
-
: shardedEntries;
|
|
1686
|
-
projectEntries = await resolveProjectEntries(
|
|
1687
|
-
context,
|
|
1688
|
-
updatedShardedEntries,
|
|
1689
|
-
browserProjects,
|
|
1690
|
-
);
|
|
1691
|
-
for (const manifestModule of manifestModules) {
|
|
1692
|
-
refreshManifestModule(manifestModule);
|
|
1693
|
-
}
|
|
1694
|
-
};
|
|
1695
|
-
|
|
1696
|
-
// Rstest internal aliases that must not be overridden by user config
|
|
1697
|
-
const browserRuntimePath = fileURLToPath(
|
|
1698
|
-
import.meta.resolve('@rstest/core/internal/browser-runtime'),
|
|
1699
|
-
);
|
|
1700
|
-
|
|
1701
|
-
// Shared by every project — only the per-project `@rstest/browser-manifest`
|
|
1702
|
-
// alias varies (one virtual manifest per server).
|
|
1703
|
-
const staticRstestAliases = {
|
|
1704
|
-
// User test code `import { describe, it } from '@rstest/core'` is NOT
|
|
1705
|
-
// aliased: `applyWebMockRspackConfig` keeps the request external against
|
|
1706
|
-
// `globalThis['@rstest/core']` (node parity), which also keeps the mock
|
|
1707
|
-
// hoister's provider-import ordering correct for `rs.hoisted` callbacks.
|
|
1708
|
-
// User test code: import { page } from '@rstest/browser'
|
|
1709
|
-
'@rstest/browser': resolveBrowserFile('browser.ts'),
|
|
1710
|
-
// Browser runtime APIs for entry.ts
|
|
1711
|
-
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
1712
|
-
'@rstest/core/internal/browser-runtime': browserRuntimePath,
|
|
1713
|
-
};
|
|
1714
|
-
|
|
1715
|
-
// rspack `define` replaces `process.env` / `import.meta.env` with this literal
|
|
1716
|
-
// expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
|
|
1717
|
-
// text, so the owned key can never drift from the runtime
|
|
1718
|
-
// `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
|
|
1719
|
-
const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
|
|
1720
|
-
RSTEST_ENV_SYMBOL_KEY,
|
|
1721
|
-
)})]`;
|
|
1722
|
-
|
|
1723
|
-
// Serve prebuilt container assets (SPA) via sirv (container origin only)
|
|
1724
|
-
const serveContainer = containerDistPath
|
|
1725
|
-
? sirv(containerDistPath, {
|
|
1726
|
-
dev: false,
|
|
1727
|
-
single: 'index.html',
|
|
1728
|
-
})
|
|
1729
|
-
: null;
|
|
1730
|
-
|
|
1731
|
-
const containerDevBase = containerDevServer
|
|
1732
|
-
? new URL(containerDevServer)
|
|
1733
|
-
: null;
|
|
1734
|
-
|
|
1735
|
-
const respondWithDevServerHtml = async (
|
|
1736
|
-
url: URL,
|
|
1737
|
-
res: ServerResponse,
|
|
1738
|
-
): Promise<boolean> => {
|
|
1739
|
-
if (!containerDevBase) {
|
|
1740
|
-
return false;
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
try {
|
|
1744
|
-
const target = new URL(url.pathname + url.search, containerDevBase);
|
|
1745
|
-
const response = await fetch(target);
|
|
1746
|
-
if (!response.ok) {
|
|
1747
|
-
return false;
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
let html = await response.text();
|
|
1751
|
-
html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
|
|
1752
|
-
|
|
1753
|
-
copyProxyResponseHeaders(response, res);
|
|
1754
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1755
|
-
res.end(html);
|
|
1756
|
-
return true;
|
|
1757
|
-
} catch (error) {
|
|
1758
|
-
logger.debug(
|
|
1759
|
-
`[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
|
|
1760
|
-
);
|
|
1761
|
-
return false;
|
|
1762
|
-
}
|
|
1763
|
-
};
|
|
1764
|
-
|
|
1765
|
-
const proxyDevServerAsset = async (
|
|
1766
|
-
req: IncomingMessage,
|
|
1767
|
-
res: ServerResponse,
|
|
1768
|
-
): Promise<boolean> => {
|
|
1769
|
-
if (!containerDevBase || !req.url) {
|
|
1770
|
-
return false;
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
try {
|
|
1774
|
-
const target = new URL(req.url, containerDevBase);
|
|
1775
|
-
const response = await fetch(target);
|
|
1776
|
-
if (!response.ok) {
|
|
1777
|
-
return false;
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1781
|
-
copyProxyResponseHeaders(response, res);
|
|
1782
|
-
res.end(buffer);
|
|
1783
|
-
return true;
|
|
1784
|
-
} catch (error) {
|
|
1785
|
-
logger.debug(
|
|
1786
|
-
`[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
|
|
1787
|
-
);
|
|
1788
|
-
return false;
|
|
1789
|
-
}
|
|
1790
|
-
};
|
|
1791
|
-
|
|
1792
|
-
const serveContainerRoute = async (
|
|
1793
|
-
req: IncomingMessage,
|
|
1794
|
-
res: ServerResponse,
|
|
1795
|
-
next: () => void,
|
|
1796
|
-
): Promise<void> => {
|
|
1797
|
-
if (!req.url) {
|
|
1798
|
-
next();
|
|
1799
|
-
return;
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
const url = new URL(req.url, 'http://localhost');
|
|
1803
|
-
if (url.pathname === '/') {
|
|
1804
|
-
if (await respondWithDevServerHtml(url, res)) {
|
|
1805
|
-
return;
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
const html =
|
|
1809
|
-
injectedContainerHtml ||
|
|
1810
|
-
containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
|
|
1811
|
-
|
|
1812
|
-
if (html) {
|
|
1813
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1814
|
-
res.end(html);
|
|
1815
|
-
return;
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
res.statusCode = 502;
|
|
1819
|
-
res.end('Container UI is not available.');
|
|
1820
|
-
return;
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
if (url.pathname.startsWith('/container-static/')) {
|
|
1824
|
-
if (await proxyDevServerAsset(req, res)) {
|
|
1825
|
-
return;
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
if (serveContainer) {
|
|
1829
|
-
serveContainer(req, res, next);
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
res.statusCode = 502;
|
|
1834
|
-
res.end('Container assets are not available.');
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
next();
|
|
1839
|
-
};
|
|
1840
|
-
|
|
1841
|
-
// ---- Build one isolated rsbuild instance + dev server per project ----
|
|
1842
|
-
const buildProjectServer = async (
|
|
1843
|
-
project: ProjectContext,
|
|
1844
|
-
isContainerServer: boolean,
|
|
1845
|
-
): Promise<BrowserProjectServer> => {
|
|
1846
|
-
const manifestPath = join(
|
|
1847
|
-
tempDir,
|
|
1848
|
-
toSafeVarName(project.environmentName),
|
|
1849
|
-
VIRTUAL_MANIFEST_FILENAME,
|
|
1850
|
-
);
|
|
1851
|
-
const entry = getProjectEntry(project);
|
|
1852
|
-
const virtualManifestModules = {
|
|
1853
|
-
[manifestPath]: generateManifestModule({
|
|
1854
|
-
manifestPath,
|
|
1855
|
-
entries: [
|
|
1856
|
-
{
|
|
1857
|
-
project,
|
|
1858
|
-
testFiles: entry?.testFiles ?? [],
|
|
1859
|
-
setupFiles: entry?.setupFiles ?? [],
|
|
1860
|
-
},
|
|
1861
|
-
],
|
|
1862
|
-
isWatchMode,
|
|
1863
|
-
}),
|
|
1864
|
-
};
|
|
1865
|
-
const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
|
|
1866
|
-
virtualManifestModules,
|
|
1867
|
-
);
|
|
1868
|
-
manifestModules.push({
|
|
1869
|
-
manifestPath,
|
|
1870
|
-
project,
|
|
1871
|
-
modules: virtualManifestModules,
|
|
1872
|
-
});
|
|
1873
|
-
|
|
1874
|
-
const rstestInternalAliases = {
|
|
1875
|
-
'@rstest/browser-manifest': manifestPath,
|
|
1876
|
-
...staticRstestAliases,
|
|
1877
|
-
};
|
|
1878
|
-
|
|
1879
|
-
const isHeadless =
|
|
1880
|
-
forceHeadless || project.normalizedConfig.browser.headless;
|
|
1881
|
-
const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
|
|
1882
|
-
|
|
1883
|
-
const rsbuildInstance = await createRsbuild({
|
|
1884
|
-
callerName: 'rstest',
|
|
1885
|
-
rsbuildConfig: {
|
|
1886
|
-
root: context.rootPath,
|
|
1887
|
-
mode: 'development',
|
|
1888
|
-
server: {
|
|
1889
|
-
printUrls: false,
|
|
1890
|
-
// Each project gets its own dev server. Honor an explicitly
|
|
1891
|
-
// configured port; otherwise keep the historical 4000 default for the
|
|
1892
|
-
// container server and let the OS assign free ports for the rest, so
|
|
1893
|
-
// multiple projects never collide on one port.
|
|
1894
|
-
port:
|
|
1895
|
-
project.normalizedConfig.browser.port ??
|
|
1896
|
-
(isContainerServer ? 4000 : 0),
|
|
1897
|
-
strictPort: project.normalizedConfig.browser.strictPort,
|
|
1898
|
-
// User plugins may emit index.html; register before Rsbuild's HTML
|
|
1899
|
-
// completion middleware so `/` remains owned by the Browser UI.
|
|
1900
|
-
setup: isContainerServer
|
|
1901
|
-
? ({ server }) => {
|
|
1902
|
-
server.middlewares.use(serveContainerRoute);
|
|
1903
|
-
}
|
|
1904
|
-
: undefined,
|
|
1905
|
-
},
|
|
1906
|
-
dev: createBrowserRsbuildDevConfig(enableHmr),
|
|
1907
|
-
environments: {
|
|
1908
|
-
[project.environmentName]:
|
|
1909
|
-
getBrowserRsbuildEnvironmentConfig(project),
|
|
1910
|
-
},
|
|
1911
|
-
},
|
|
1912
|
-
});
|
|
1913
|
-
|
|
1914
|
-
initModifyRstestConfigHooks(
|
|
1915
|
-
context,
|
|
1916
|
-
rsbuildInstance,
|
|
1917
|
-
[project],
|
|
1918
|
-
[project],
|
|
1919
|
-
{
|
|
1920
|
-
getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
|
|
1921
|
-
onModifyRstestConfigApplied: refreshProjectEntries,
|
|
1922
|
-
appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
|
|
1923
|
-
},
|
|
1924
|
-
);
|
|
1925
|
-
|
|
1926
|
-
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1927
|
-
rsbuildInstance.addPlugins([
|
|
1928
|
-
// Same mock runtime as the node build (importActual doppelganger rule +
|
|
1929
|
-
// mock webpack runtime module); order-insensitive and self-contained.
|
|
1930
|
-
pluginMockRuntime,
|
|
1931
|
-
{
|
|
1932
|
-
name: 'rstest:browser-user-config',
|
|
1933
|
-
setup(api) {
|
|
1934
|
-
// Internal extension entry: register host dispatch handlers without
|
|
1935
|
-
// coupling scheduling to individual capability implementations.
|
|
1936
|
-
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1937
|
-
'rstest:browser',
|
|
1938
|
-
{
|
|
1939
|
-
registerDispatchHandler: (
|
|
1940
|
-
namespace: string,
|
|
1941
|
-
handler: BrowserDispatchHandler,
|
|
1942
|
-
) => {
|
|
1943
|
-
dispatchHandlers.set(namespace, handler);
|
|
1944
|
-
},
|
|
1945
|
-
},
|
|
1946
|
-
);
|
|
1947
|
-
|
|
1948
|
-
api.modifyEnvironmentConfig({
|
|
1949
|
-
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1950
|
-
if (name !== project.environmentName) {
|
|
1951
|
-
return config;
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
const userRsbuildConfig = project.normalizedConfig;
|
|
1955
|
-
const buildCache = resolveProjectBuildCache({
|
|
1956
|
-
context,
|
|
1957
|
-
project,
|
|
1958
|
-
});
|
|
1959
|
-
const setupFiles = Object.values(
|
|
1960
|
-
getSetupFiles(
|
|
1961
|
-
project.normalizedConfig.setupFiles,
|
|
1962
|
-
project.rootPath,
|
|
1963
|
-
),
|
|
1964
|
-
);
|
|
1965
|
-
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
1966
|
-
const merged = mergeEnvironmentConfig(
|
|
1967
|
-
config,
|
|
1968
|
-
{
|
|
1969
|
-
...userRsbuildConfig,
|
|
1970
|
-
performance: buildCache
|
|
1971
|
-
? {
|
|
1972
|
-
...userRsbuildConfig.performance,
|
|
1973
|
-
buildCache,
|
|
1974
|
-
}
|
|
1975
|
-
: userRsbuildConfig.performance,
|
|
1976
|
-
},
|
|
1977
|
-
{
|
|
1978
|
-
resolve: {
|
|
1979
|
-
alias: rstestInternalAliases,
|
|
1980
|
-
},
|
|
1981
|
-
source: {
|
|
1982
|
-
define: {
|
|
1983
|
-
'process.env': rstestEnvDefine,
|
|
1984
|
-
'import.meta.env': rstestEnvDefine,
|
|
1985
|
-
// In-source `if (import.meta.rstest)` blocks read the
|
|
1986
|
-
// per-file runtime API the client entry publishes on
|
|
1987
|
-
// `globalThis` (node parity: `global['@rstest/core']`).
|
|
1988
|
-
'import.meta.rstest': importMetaRstestDefine('web'),
|
|
1989
|
-
},
|
|
1990
|
-
},
|
|
1991
|
-
output: {
|
|
1992
|
-
target: 'web',
|
|
1993
|
-
// Enable source map for inline snapshot support
|
|
1994
|
-
sourceMap: {
|
|
1995
|
-
js: 'source-map',
|
|
1996
|
-
},
|
|
1997
|
-
// Every project server compiles the same asset names
|
|
1998
|
-
// (`static/js/runner.js`, ...). With `dev.writeToDisk`
|
|
1999
|
-
// (debug mode) the middleware serves from disk, so a
|
|
2000
|
-
// shared dist dir would be last-writer-wins and one
|
|
2001
|
-
// project's server would deliver another project's
|
|
2002
|
-
// bundle — keep each project's output isolated, inside
|
|
2003
|
-
// the run's temp dir so teardown removes it.
|
|
2004
|
-
distPath: {
|
|
2005
|
-
root: join(
|
|
2006
|
-
tempDir,
|
|
2007
|
-
'server',
|
|
2008
|
-
toSafeVarName(project.environmentName),
|
|
2009
|
-
),
|
|
2010
|
-
},
|
|
2011
|
-
},
|
|
2012
|
-
tools: {
|
|
2013
|
-
swc: (swcConfig) => {
|
|
2014
|
-
// Fixture dependency discovery reads callback parameters
|
|
2015
|
-
// through Function#toString(). Playwright's supported
|
|
2016
|
-
// browsers all support parameter destructuring, so keep
|
|
2017
|
-
// that syntax intact in the browser test bundle.
|
|
2018
|
-
swcConfig.env ??= {};
|
|
2019
|
-
swcConfig.env.exclude = Array.from(
|
|
2020
|
-
new Set([
|
|
2021
|
-
...(swcConfig.env.exclude ?? []),
|
|
2022
|
-
'transform-parameters',
|
|
2023
|
-
]),
|
|
2024
|
-
);
|
|
2025
|
-
},
|
|
2026
|
-
rspack: (rspackConfig) => {
|
|
2027
|
-
rspackConfig.mode = 'development';
|
|
2028
|
-
// Web parameterization of the node mock transform:
|
|
2029
|
-
// RstestPlugin (hoist + path injection), the
|
|
2030
|
-
// `@rstest/core` global external, and
|
|
2031
|
-
// `exportsPresence: 'warn'`.
|
|
2032
|
-
applyWebMockRspackConfig(rspackConfig, {
|
|
2033
|
-
rspack,
|
|
2034
|
-
rootPath: project.rootPath,
|
|
2035
|
-
});
|
|
2036
|
-
// lazyCompilation's only delivery transport is the HMR
|
|
2037
|
-
// runtime, so it follows the same gate as HMR (see
|
|
2038
|
-
// `shouldEnableBrowserHmr`): headed watch only, everything
|
|
2039
|
-
// else compiles eagerly.
|
|
2040
|
-
rspackConfig.lazyCompilation = enableHmr
|
|
2041
|
-
? createBrowserLazyCompilationConfig(setupFiles)
|
|
2042
|
-
: false;
|
|
2043
|
-
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
2044
|
-
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
2045
|
-
|
|
2046
|
-
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
2047
|
-
|
|
2048
|
-
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
2049
|
-
// This preserves the sourcemap chain for inline snapshot support
|
|
2050
|
-
// See: https://rspack.rs/config/module-rules#rulesextractsourcemap
|
|
2051
|
-
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
2052
|
-
rspackConfig.module = rspackConfig.module || {};
|
|
2053
|
-
rspackConfig.module.rules =
|
|
2054
|
-
rspackConfig.module.rules || [];
|
|
2055
|
-
rspackConfig.module.rules.unshift({
|
|
2056
|
-
test: /\.js$/,
|
|
2057
|
-
include: browserRuntimeDir,
|
|
2058
|
-
extractSourceMap: true,
|
|
2059
|
-
});
|
|
2060
|
-
|
|
2061
|
-
if (isDebug()) {
|
|
2062
|
-
logger.log(
|
|
2063
|
-
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
2064
|
-
);
|
|
2065
|
-
}
|
|
2066
|
-
},
|
|
2067
|
-
},
|
|
2068
|
-
},
|
|
2069
|
-
);
|
|
2070
|
-
|
|
2071
|
-
// Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
|
|
2072
|
-
// In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
|
|
2073
|
-
// This must be done after mergeEnvironmentConfig to ensure highest priority.
|
|
2074
|
-
merged.source = merged.source || {};
|
|
2075
|
-
merged.source.entry = {
|
|
2076
|
-
runner: resolveBrowserFile('client/entry.ts'),
|
|
2077
|
-
};
|
|
2078
|
-
|
|
2079
|
-
return merged;
|
|
2080
|
-
},
|
|
2081
|
-
// Execute after all other plugins to ensure rstest's entry config has the highest priority
|
|
2082
|
-
order: 'post',
|
|
2083
|
-
});
|
|
2084
|
-
},
|
|
2085
|
-
},
|
|
2086
|
-
]);
|
|
2087
|
-
|
|
2088
|
-
// Register watch plugin if in watch mode
|
|
2089
|
-
if (isWatchMode && onTriggerRerun) {
|
|
2090
|
-
rsbuildInstance.addPlugins([
|
|
2091
|
-
{
|
|
2092
|
-
name: 'rstest:browser-watch',
|
|
2093
|
-
setup(api) {
|
|
2094
|
-
api.onBeforeDevCompile(() => {
|
|
2095
|
-
watchState.compileStartTimes.set(project.name, Date.now());
|
|
2096
|
-
if (!watchState.hooksEnabled) {
|
|
2097
|
-
return;
|
|
2098
|
-
}
|
|
2099
|
-
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
2100
|
-
});
|
|
2101
|
-
|
|
2102
|
-
api.onAfterDevCompile(async ({ stats }) => {
|
|
2103
|
-
const compileStart = watchState.compileStartTimes.get(
|
|
2104
|
-
project.name,
|
|
2105
|
-
);
|
|
2106
|
-
if (compileStart !== undefined) {
|
|
2107
|
-
watchState.compileStartTimes.delete(project.name);
|
|
2108
|
-
// Only change-triggered compiles feed the pending rerun's
|
|
2109
|
-
// build phase (the initial build is the initial run's
|
|
2110
|
-
// buildTime). Parallel project compiles overlap; the longest
|
|
2111
|
-
// one bounds the rerun's build phase.
|
|
2112
|
-
if (watchState.hooksEnabled) {
|
|
2113
|
-
watchState.pendingBuildTimeMs = Math.max(
|
|
2114
|
-
watchState.pendingBuildTimeMs,
|
|
2115
|
-
Date.now() - compileStart,
|
|
2116
|
-
);
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
// Collect hashes even during initial build to establish baseline
|
|
2120
|
-
if (stats) {
|
|
2121
|
-
// This compiler only ever holds this project's entries; the
|
|
2122
|
-
// diff baseline is keyed per project accordingly.
|
|
2123
|
-
const [projectEntry] = await collectProjectEntries(context, [
|
|
2124
|
-
project,
|
|
2125
|
-
]);
|
|
2126
|
-
const entryTestFiles = new Set<string>(
|
|
2127
|
-
collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
|
|
2128
|
-
(file) => file.testPath,
|
|
2129
|
-
),
|
|
2130
|
-
);
|
|
2131
|
-
const setupFiles = new Set<string>(
|
|
2132
|
-
(projectEntry?.setupFiles ?? []).map((file) =>
|
|
2133
|
-
normalize(file),
|
|
2134
|
-
),
|
|
2135
|
-
);
|
|
2136
|
-
|
|
2137
|
-
let state = watchState.invalidation.get(project.name);
|
|
2138
|
-
if (!state) {
|
|
2139
|
-
state = {};
|
|
2140
|
-
watchState.invalidation.set(project.name, state);
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
const statsJson = stats.toJson({ all: true });
|
|
2144
|
-
const affected = getAffectedTestFiles({
|
|
2145
|
-
chunks: statsJson.chunks,
|
|
2146
|
-
entryTestFiles,
|
|
2147
|
-
setupFiles,
|
|
2148
|
-
state,
|
|
2149
|
-
});
|
|
2150
|
-
|
|
2151
|
-
if (affected.length > 0) {
|
|
2152
|
-
const pending =
|
|
2153
|
-
watchState.pendingAffectedTestFiles.get(project.name) ??
|
|
2154
|
-
new Set<string>();
|
|
2155
|
-
for (const file of affected) {
|
|
2156
|
-
pending.add(file);
|
|
2157
|
-
}
|
|
2158
|
-
watchState.pendingAffectedTestFiles.set(
|
|
2159
|
-
project.name,
|
|
2160
|
-
pending,
|
|
2161
|
-
);
|
|
2162
|
-
logger.debug(
|
|
2163
|
-
`[Watch] Affected test files: ${affected.join(', ')}`,
|
|
2164
|
-
);
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
if (!watchState.hooksEnabled) {
|
|
2169
|
-
return;
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
await onTriggerRerun();
|
|
2173
|
-
});
|
|
2174
|
-
},
|
|
2175
|
-
},
|
|
2176
|
-
]);
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
if (skipProviderLaunch) {
|
|
2180
|
-
await rsbuildInstance.initConfigs({ action: 'dev' });
|
|
2181
|
-
return {
|
|
2182
|
-
projectName: project.name,
|
|
2183
|
-
environmentName: project.environmentName,
|
|
2184
|
-
rsbuildInstance,
|
|
2185
|
-
devServer: {
|
|
2186
|
-
close: async () => undefined,
|
|
2187
|
-
} as RsbuildDevServer,
|
|
2188
|
-
port: 0,
|
|
2189
|
-
manifestPath,
|
|
2190
|
-
};
|
|
2191
|
-
}
|
|
2192
|
-
|
|
2193
|
-
// Register coverage plugin if this project enables coverage
|
|
2194
|
-
const coverage = project.normalizedConfig.coverage;
|
|
2195
|
-
if (coverage?.enabled && context.command !== 'list') {
|
|
2196
|
-
const { pluginCoverage } = await loadCoverageProvider(
|
|
2197
|
-
coverage,
|
|
2198
|
-
context.rootPath,
|
|
2199
|
-
);
|
|
2200
|
-
rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
const devServer = await rsbuildInstance.createDevServer({
|
|
2204
|
-
getPortSilently: true,
|
|
2205
|
-
});
|
|
2206
|
-
|
|
2207
|
-
if (isDebug()) {
|
|
2208
|
-
await rsbuildInstance.inspectConfig({
|
|
2209
|
-
writeToDisk: true,
|
|
2210
|
-
// The server's own distPath is isolated per project inside the run's
|
|
2211
|
-
// temp dir (removed at teardown); keep the debug artifacts at the
|
|
2212
|
-
// project's stable dist root so they survive the run and stay where
|
|
2213
|
-
// the docs point users to.
|
|
2214
|
-
outputPath: resolve(
|
|
2215
|
-
context.rootPath,
|
|
2216
|
-
context.normalizedConfig.output.distPath.root,
|
|
2217
|
-
'.rsbuild',
|
|
2218
|
-
),
|
|
2219
|
-
extraConfigs: {
|
|
2220
|
-
rstest: {
|
|
2221
|
-
...context.normalizedConfig,
|
|
2222
|
-
projects: [project.normalizedConfig],
|
|
2223
|
-
},
|
|
2224
|
-
},
|
|
2225
|
-
});
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
|
-
devServer.middlewares.use(
|
|
2229
|
-
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
2230
|
-
if (!req.url) {
|
|
2231
|
-
next();
|
|
2232
|
-
return;
|
|
2233
|
-
}
|
|
2234
|
-
const url = new URL(req.url, 'http://localhost');
|
|
2235
|
-
if (url.pathname === '/__open-in-editor') {
|
|
2236
|
-
const file = url.searchParams.get('file');
|
|
2237
|
-
if (!file) {
|
|
2238
|
-
res.statusCode = 400;
|
|
2239
|
-
res.end('Missing file');
|
|
2240
|
-
return;
|
|
2241
|
-
}
|
|
2242
|
-
try {
|
|
2243
|
-
await openEditor([{ file }]);
|
|
2244
|
-
res.statusCode = 204;
|
|
2245
|
-
res.end();
|
|
2246
|
-
} catch (error) {
|
|
2247
|
-
logger.debug(
|
|
2248
|
-
`[Browser UI] Failed to open editor: ${String(error)}`,
|
|
2249
|
-
);
|
|
2250
|
-
res.statusCode = 500;
|
|
2251
|
-
res.end('Failed to open editor');
|
|
2252
|
-
}
|
|
2253
|
-
return;
|
|
2254
|
-
}
|
|
2255
|
-
if (url.pathname === '/runner.html') {
|
|
2256
|
-
res.setHeader('Content-Type', 'text/html');
|
|
2257
|
-
res.end(htmlTemplate);
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
next();
|
|
2261
|
-
},
|
|
2262
|
-
);
|
|
2263
|
-
|
|
2264
|
-
const { port: listenPort } = await devServer.listen();
|
|
2265
|
-
const port = resolveListenPort(listenPort, devServer.httpServer);
|
|
2266
|
-
|
|
2267
|
-
return {
|
|
2268
|
-
projectName: project.name,
|
|
2269
|
-
environmentName: project.environmentName,
|
|
2270
|
-
rsbuildInstance,
|
|
2271
|
-
devServer,
|
|
2272
|
-
port,
|
|
2273
|
-
manifestPath,
|
|
2274
|
-
};
|
|
2275
|
-
};
|
|
2276
|
-
|
|
2277
|
-
// Build each project's server sequentially. Servers must bind ports one at a
|
|
2278
|
-
// time: projects may share a configured port and rely on strictPort:false
|
|
2279
|
-
// bumping to the next free one, which races under concurrent listen().
|
|
2280
|
-
const projectServers = new Map<string, BrowserProjectServer>();
|
|
2281
|
-
try {
|
|
2282
|
-
for (const [index, project] of browserProjects.entries()) {
|
|
2283
|
-
const server = await buildProjectServer(project, index === 0);
|
|
2284
|
-
projectServers.set(server.projectName, server);
|
|
2285
|
-
}
|
|
2286
|
-
} catch (error) {
|
|
2287
|
-
await closeAllProjectServers(projectServers.values());
|
|
2288
|
-
throw error;
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
if (skipProviderLaunch) {
|
|
2292
|
-
return createRuntimeWithoutProvider();
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
|
-
// browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
|
|
2296
|
-
// otherwise) and index 0 is the designated container origin.
|
|
2297
|
-
const containerServer = projectServers.get(browserProjects[0]!.name)!;
|
|
2298
|
-
|
|
2299
|
-
// Create WebSocket server on an available port
|
|
2300
|
-
// Using port: 0 lets the OS assign an available port, avoiding conflicts
|
|
2301
|
-
// when the fixed port (e.g., container port + 1) is already in use
|
|
2302
|
-
const wss = new WebSocketServer({ port: 0 });
|
|
2303
|
-
await new Promise<void>((resolve, reject) => {
|
|
2304
|
-
wss.once('listening', resolve);
|
|
2305
|
-
wss.once('error', reject);
|
|
2306
|
-
});
|
|
2307
|
-
const wsPort = (wss.address() as AddressInfo).port;
|
|
2308
|
-
logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
|
|
2309
|
-
|
|
2310
|
-
const browserName = browserLaunchOptions.browser ?? 'chromium';
|
|
2311
|
-
try {
|
|
2312
|
-
const providerImplementation = getBrowserProviderImplementation(
|
|
2313
|
-
browserLaunchOptions.provider,
|
|
2314
|
-
);
|
|
2315
|
-
const runtime = await providerImplementation.launchRuntime({
|
|
2316
|
-
browserName,
|
|
2317
|
-
headless: forceHeadless ?? browserLaunchOptions.headless,
|
|
2318
|
-
providerOptions: browserLaunchOptions.providerOptions,
|
|
2319
|
-
});
|
|
2320
|
-
return {
|
|
2321
|
-
projectServers,
|
|
2322
|
-
containerServer,
|
|
2323
|
-
browser: runtime.browser,
|
|
2324
|
-
browserLaunchOptions,
|
|
2325
|
-
wsPort,
|
|
2326
|
-
tempDir,
|
|
2327
|
-
setContainerOptions,
|
|
2328
|
-
dispatchHandlers,
|
|
2329
|
-
wss,
|
|
2330
|
-
projectEntries,
|
|
2331
|
-
watchState,
|
|
2332
|
-
};
|
|
2333
|
-
} catch (error) {
|
|
2334
|
-
wss.close();
|
|
2335
|
-
await closeAllProjectServers(projectServers.values());
|
|
2336
|
-
throw error;
|
|
2337
|
-
}
|
|
2338
|
-
};
|
|
2339
|
-
|
|
2340
|
-
async function resolveProjectEntries(
|
|
2341
|
-
context: RstestContext,
|
|
2342
|
-
shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
|
|
2343
|
-
browserProjects: ProjectContext[],
|
|
2344
|
-
): Promise<BrowserProjectEntries[]> {
|
|
2345
|
-
if (shardedEntries) {
|
|
2346
|
-
const projectEntries: BrowserProjectEntries[] = [];
|
|
2347
|
-
for (const project of browserProjects) {
|
|
2348
|
-
const entryInfo = shardedEntries.get(project.environmentName);
|
|
2349
|
-
if (entryInfo && Object.keys(entryInfo.entries).length > 0) {
|
|
2350
|
-
const setup = getSetupFiles(
|
|
2351
|
-
project.normalizedConfig.setupFiles,
|
|
2352
|
-
project.rootPath,
|
|
2353
|
-
);
|
|
2354
|
-
projectEntries.push({
|
|
2355
|
-
project,
|
|
2356
|
-
setupFiles: Object.values(setup),
|
|
2357
|
-
testFiles: Object.values(entryInfo.entries),
|
|
2358
|
-
});
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
return projectEntries;
|
|
2362
|
-
}
|
|
2363
|
-
return collectProjectEntries(context, browserProjects);
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// ============================================================================
|
|
2367
|
-
// Main Entry Point
|
|
2368
|
-
// ============================================================================
|
|
2369
|
-
|
|
2370
|
-
export const runBrowserController = async (
|
|
2371
|
-
context: RstestContext,
|
|
2372
|
-
options?: BrowserTestRunOptions,
|
|
2373
|
-
): Promise<BrowserTestRunResult | void> => {
|
|
2374
|
-
const {
|
|
2375
|
-
allowEmptyRun = false,
|
|
2376
|
-
filesOnly = false,
|
|
2377
|
-
onTraceEvents,
|
|
2378
|
-
env,
|
|
2379
|
-
} = options ?? {};
|
|
2380
|
-
const buildStart = Date.now();
|
|
2381
|
-
// Non-watch vs watch is the live switch for self-finalize: in non-watch runs
|
|
2382
|
-
// core owns the unified finalize (reporters, exit code, coverage) through
|
|
2383
|
-
// `finalizeRunCycle`, so the host never self-finalizes and always returns a
|
|
2384
|
-
// fully-populated result with `close`. Watch reruns keep their host-driven
|
|
2385
|
-
// per-rerun finalize.
|
|
2386
|
-
const isWatchMode = context.command === 'watch';
|
|
2387
|
-
|
|
2388
|
-
// Per-file PhaseTrackers, populated only when `--trace` is on (caller
|
|
2389
|
-
// passes `onTraceEvents`). The browser host shares one Node process across
|
|
2390
|
-
// every test file, so each tracker is assigned a synthetic per-file pid
|
|
2391
|
-
// (`nextBrowserFilePid`) that lets Perfetto render each file as its own
|
|
2392
|
-
// process track with the file path as the title. Keyed by project + path so
|
|
2393
|
-
// concurrent projects running the same file keep separate trackers.
|
|
2394
|
-
const phaseTrackers = onTraceEvents
|
|
2395
|
-
? new Map<string, PhaseTracker>()
|
|
2396
|
-
: undefined;
|
|
2397
|
-
const trackerKey = (project: string, testPath: string) =>
|
|
2398
|
-
`${project}\u0000${testPath}`;
|
|
2399
|
-
// Explicit projects input (plan output) replaces re-deriving `browser.enabled`
|
|
2400
|
-
// projects from `context`, whose `projects` array is mutated during planning.
|
|
2401
|
-
// Falls back to re-derivation only when the caller passes no list at all —
|
|
2402
|
-
// an explicit empty subset must stay empty, not widen to every project.
|
|
2403
|
-
const browserProjects = options?.projects ?? getBrowserProjects(context);
|
|
2404
|
-
const useHeadlessDirect = browserProjects.every(
|
|
2405
|
-
(project) => project.normalizedConfig.browser.headless,
|
|
2406
|
-
);
|
|
2407
|
-
|
|
2408
|
-
const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
|
|
2409
|
-
|
|
2410
|
-
const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
|
|
2411
|
-
|
|
2412
|
-
const resolveBrowserSourcemap = async (sourcePath: string) => {
|
|
2413
|
-
if (!isHttpLikeFile(sourcePath)) {
|
|
2414
|
-
return {
|
|
2415
|
-
handled: false,
|
|
2416
|
-
sourcemap: null,
|
|
2417
|
-
};
|
|
2418
|
-
}
|
|
2419
|
-
|
|
2420
|
-
const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
|
|
2421
|
-
if (!normalizedUrl) {
|
|
2422
|
-
return {
|
|
2423
|
-
handled: true,
|
|
2424
|
-
sourcemap: null,
|
|
2425
|
-
};
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
if (browserSourceMapCache.has(normalizedUrl)) {
|
|
2429
|
-
return {
|
|
2430
|
-
handled: true,
|
|
2431
|
-
sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
|
|
2432
|
-
};
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
return {
|
|
2436
|
-
handled: true,
|
|
2437
|
-
sourcemap: await loadSourceMapWithCache({
|
|
2438
|
-
jsUrl: normalizedUrl,
|
|
2439
|
-
cache: browserSourceMapCache,
|
|
2440
|
-
}),
|
|
2441
|
-
};
|
|
2442
|
-
};
|
|
2443
|
-
|
|
2444
|
-
const getBrowserSourcemap = async (
|
|
2445
|
-
sourcePath: string,
|
|
2446
|
-
): Promise<SourceMapPayload | null> => {
|
|
2447
|
-
const result = await resolveBrowserSourcemap(sourcePath);
|
|
2448
|
-
return result.handled ? result.sourcemap : null;
|
|
2449
|
-
};
|
|
2450
|
-
|
|
2451
|
-
/**
|
|
2452
|
-
* Build an error BrowserTestRunResult and call onTestRunEnd if needed.
|
|
2453
|
-
* Used for early-exit error paths to ensure errors reach the summary report.
|
|
2454
|
-
*/
|
|
2455
|
-
const buildErrorResult = async (
|
|
2456
|
-
error: Error,
|
|
2457
|
-
close?: () => Promise<void>,
|
|
2458
|
-
): Promise<BrowserTestRunResult> => {
|
|
2459
|
-
const elapsed = Math.max(0, Date.now() - buildStart);
|
|
2460
|
-
const errorResult = {
|
|
2461
|
-
results: [],
|
|
2462
|
-
testResults: [],
|
|
2463
|
-
duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
|
|
2464
|
-
hasFailure: true,
|
|
2465
|
-
unhandledErrors: [error],
|
|
2466
|
-
getSourcemap: getBrowserSourcemap,
|
|
2467
|
-
resolveSourcemap: resolveBrowserSourcemap,
|
|
2468
|
-
close,
|
|
2469
|
-
};
|
|
2470
|
-
|
|
2471
|
-
if (isWatchMode) {
|
|
2472
|
-
for (const reporter of context.reporters) {
|
|
2473
|
-
await (reporter as Reporter).onTestRunEnd?.({
|
|
2474
|
-
results: [],
|
|
2475
|
-
testResults: [],
|
|
2476
|
-
duration: errorResult.duration,
|
|
2477
|
-
snapshotSummary: context.snapshotManager.summary,
|
|
2478
|
-
getSourcemap: getBrowserSourcemap,
|
|
2479
|
-
unhandledErrors: errorResult.unhandledErrors,
|
|
2480
|
-
});
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
return errorResult;
|
|
2485
|
-
};
|
|
2486
|
-
|
|
2487
|
-
const toError = (error: unknown): Error => {
|
|
2488
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
2489
|
-
};
|
|
2490
|
-
|
|
2491
|
-
const failWithError = async (
|
|
2492
|
-
error: unknown,
|
|
2493
|
-
cleanup?: () => Promise<void>,
|
|
2494
|
-
): Promise<BrowserTestRunResult> => {
|
|
2495
|
-
// Non-watch runs defer the exit code to core's `finalizeRunCycle`, which
|
|
2496
|
-
// raises it from the returned outcome's `errors`. Watch reruns keep owning
|
|
2497
|
-
// their own exit code.
|
|
2498
|
-
if (isWatchMode) {
|
|
2499
|
-
ensureProcessExitCode(1);
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
const normalizedError = toError(error);
|
|
2503
|
-
|
|
2504
|
-
if (cleanup && !isWatchMode) {
|
|
2505
|
-
return buildErrorResult(normalizedError, cleanup);
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
try {
|
|
2509
|
-
return await buildErrorResult(normalizedError);
|
|
2510
|
-
} finally {
|
|
2511
|
-
await cleanup?.();
|
|
2512
|
-
}
|
|
2513
|
-
};
|
|
2514
|
-
|
|
2515
|
-
const collectDeletedTestPaths = (
|
|
2516
|
-
previous: TestFileInfo[],
|
|
2517
|
-
current: TestFileInfo[],
|
|
2518
|
-
): string[] => {
|
|
2519
|
-
const currentPathSet = new Set(current.map((file) => file.testPath));
|
|
2520
|
-
return previous
|
|
2521
|
-
.map((file) => file.testPath)
|
|
2522
|
-
.filter((testPath) => !currentPathSet.has(testPath));
|
|
2523
|
-
};
|
|
2524
|
-
|
|
2525
|
-
const notifyTestRunStart = async (): Promise<void> => {
|
|
2526
|
-
if (!isWatchMode) {
|
|
2527
|
-
return;
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
for (const reporter of context.reporters) {
|
|
2531
|
-
await reporter.onTestRunStart?.();
|
|
2532
|
-
}
|
|
2533
|
-
};
|
|
2534
|
-
|
|
2535
|
-
const coverageConfig = browserProjects.find(
|
|
2536
|
-
(project) => project.normalizedConfig.coverage?.enabled,
|
|
2537
|
-
)?.normalizedConfig.coverage;
|
|
2538
|
-
const coverageProvider = coverageConfig?.enabled
|
|
2539
|
-
? await createCoverageProvider(coverageConfig, context.rootPath)
|
|
2540
|
-
: null;
|
|
2541
|
-
|
|
2542
|
-
const notifyTestRunEnd = async ({
|
|
2543
|
-
duration,
|
|
2544
|
-
coverage,
|
|
2545
|
-
}: {
|
|
2546
|
-
duration: {
|
|
2547
|
-
totalTime: number;
|
|
2548
|
-
buildTime: number;
|
|
2549
|
-
testTime: number;
|
|
2550
|
-
};
|
|
2551
|
-
coverage?: CoverageMapData;
|
|
2552
|
-
}): Promise<void> => {
|
|
2553
|
-
if (!isWatchMode) {
|
|
2554
|
-
return;
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
for (const reporter of context.reporters) {
|
|
2558
|
-
await reporter.onTestRunEnd?.({
|
|
2559
|
-
results: context.reporterResults.results,
|
|
2560
|
-
coverage,
|
|
2561
|
-
testResults: context.reporterResults.testResults,
|
|
2562
|
-
duration,
|
|
2563
|
-
snapshotSummary: context.snapshotManager.summary,
|
|
2564
|
-
getSourcemap: getBrowserSourcemap,
|
|
2565
|
-
});
|
|
2566
|
-
}
|
|
2567
|
-
};
|
|
2568
|
-
|
|
2569
|
-
const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
|
|
2570
|
-
let containerDevServer: string | undefined;
|
|
2571
|
-
let containerDistPath: string | undefined;
|
|
2572
|
-
|
|
2573
|
-
if (!useHeadlessDirect) {
|
|
2574
|
-
if (containerDevServerEnv) {
|
|
2575
|
-
try {
|
|
2576
|
-
containerDevServer = new URL(containerDevServerEnv).toString();
|
|
2577
|
-
logger.debug(
|
|
2578
|
-
`[Browser UI] Using dev server for container: ${containerDevServer}`,
|
|
2579
|
-
);
|
|
2580
|
-
} catch (error) {
|
|
2581
|
-
const originalError = toError(error);
|
|
2582
|
-
originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
|
|
2583
|
-
return failWithError(originalError);
|
|
2584
|
-
}
|
|
2585
|
-
}
|
|
2586
|
-
|
|
2587
|
-
if (!containerDevServer) {
|
|
2588
|
-
try {
|
|
2589
|
-
containerDistPath = resolveContainerDist();
|
|
2590
|
-
} catch (error) {
|
|
2591
|
-
return failWithError(error);
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
let projectEntries = await resolveProjectEntries(
|
|
2597
|
-
context,
|
|
2598
|
-
options?.shardedEntries,
|
|
2599
|
-
browserProjects,
|
|
2600
|
-
);
|
|
2601
|
-
let totalTests = projectEntries.reduce(
|
|
2602
|
-
(total, item) => total + item.testFiles.length,
|
|
2603
|
-
0,
|
|
2604
|
-
);
|
|
2605
|
-
const shouldInitializeEmptyBrowserHooks =
|
|
2606
|
-
totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
|
|
2607
|
-
|
|
2608
|
-
const createEmptyRunResult = (): BrowserTestRunResult => {
|
|
2609
|
-
const elapsed = Math.max(0, Date.now() - buildStart);
|
|
2610
|
-
return {
|
|
2611
|
-
results: [],
|
|
2612
|
-
testResults: [],
|
|
2613
|
-
duration: {
|
|
2614
|
-
totalTime: elapsed,
|
|
2615
|
-
buildTime: elapsed,
|
|
2616
|
-
testTime: 0,
|
|
2617
|
-
},
|
|
2618
|
-
hasFailure: false,
|
|
2619
|
-
getSourcemap: getBrowserSourcemap,
|
|
2620
|
-
resolveSourcemap: resolveBrowserSourcemap,
|
|
2621
|
-
};
|
|
2622
|
-
};
|
|
2623
|
-
|
|
2624
|
-
const reportEmptyTestSet = (): void => {
|
|
2625
|
-
const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
|
|
2626
|
-
if (isWatchMode || !allowEmptyRun) {
|
|
2627
|
-
const message = getNoTestFilesMessage({
|
|
2628
|
-
context,
|
|
2629
|
-
code,
|
|
2630
|
-
defaultMessage: `No test files found, exiting with code ${code}.`,
|
|
2631
|
-
});
|
|
2632
|
-
if (code === 0) {
|
|
2633
|
-
logger.log(color.yellow(message));
|
|
2634
|
-
} else {
|
|
2635
|
-
logger.error(color.red(message));
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
if (context.relatedFilters?.length) {
|
|
2639
|
-
logger.log(
|
|
2640
|
-
color.gray('related: '),
|
|
2641
|
-
context.relatedFilters.join(color.gray(', ')),
|
|
2642
|
-
);
|
|
2643
|
-
} else if (context.fileFilters?.length) {
|
|
2644
|
-
logger.log(
|
|
2645
|
-
color.gray('filter: '),
|
|
2646
|
-
context.fileFilters.join(color.gray(', ')),
|
|
2647
|
-
);
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
// In non-watch runs the host returns a void outcome and core's
|
|
2652
|
-
// `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
|
|
2653
|
-
// the host must not set the code itself. Watch keeps its own exit code.
|
|
2654
|
-
if (isWatchMode && code !== 0 && !allowEmptyRun) {
|
|
2655
|
-
ensureProcessExitCode(code);
|
|
2656
|
-
}
|
|
2657
|
-
};
|
|
2658
|
-
|
|
2659
|
-
if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
|
|
2660
|
-
reportEmptyTestSet();
|
|
2661
|
-
return allowEmptyRun ? createEmptyRunResult() : undefined;
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
if (!filesOnly) {
|
|
2665
|
-
await notifyTestRunStart();
|
|
2666
|
-
}
|
|
2667
|
-
|
|
2668
|
-
const enableCliShortcuts = isWatchMode && isTTY('stdin');
|
|
2669
|
-
const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
|
|
2670
|
-
const tempDir =
|
|
2671
|
-
isWatchMode && watchContext.runtime
|
|
2672
|
-
? watchContext.runtime.tempDir
|
|
2673
|
-
: isWatchMode
|
|
2674
|
-
? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch')
|
|
2675
|
-
: join(
|
|
2676
|
-
context.rootPath,
|
|
2677
|
-
browserTempOutputRoot,
|
|
2678
|
-
'browser',
|
|
2679
|
-
Date.now().toString(),
|
|
2680
|
-
);
|
|
2681
|
-
|
|
2682
|
-
let runtime = isWatchMode ? watchContext.runtime : null;
|
|
2683
|
-
|
|
2684
|
-
// Define rerun callback for watch mode (will be populated later)
|
|
2685
|
-
let triggerRerun: (() => Promise<void>) | undefined;
|
|
2686
|
-
// Headless reruns complete asynchronously in the scheduler's drain loop;
|
|
2687
|
-
// the watch handles await this so callers observe rerun completion.
|
|
2688
|
-
let awaitHeadlessRerunIdle: (() => Promise<void>) | undefined;
|
|
2689
|
-
|
|
2690
|
-
if (!runtime) {
|
|
2691
|
-
try {
|
|
2692
|
-
runtime = await createBrowserRuntime({
|
|
2693
|
-
context,
|
|
2694
|
-
projectEntries,
|
|
2695
|
-
browserProjects,
|
|
2696
|
-
shardedEntries: options?.shardedEntries,
|
|
2697
|
-
freezeShardedEntries: options?.freezeShardedEntries,
|
|
2698
|
-
tempDir,
|
|
2699
|
-
isWatchMode,
|
|
2700
|
-
onTriggerRerun: isWatchMode
|
|
2701
|
-
? async () => {
|
|
2702
|
-
await triggerRerun?.();
|
|
2703
|
-
}
|
|
2704
|
-
: undefined,
|
|
2705
|
-
containerDistPath,
|
|
2706
|
-
containerDevServer,
|
|
2707
|
-
skipProviderLaunch: filesOnly,
|
|
2708
|
-
appliedModifyRstestConfigEnvironments:
|
|
2709
|
-
options?.appliedModifyRstestConfigEnvironments,
|
|
2710
|
-
});
|
|
2711
|
-
} catch (error) {
|
|
2712
|
-
return failWithError(error, async () => {
|
|
2713
|
-
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
2714
|
-
});
|
|
2715
|
-
}
|
|
2716
|
-
|
|
2717
|
-
if (isWatchMode) {
|
|
2718
|
-
watchContext.runtime = runtime;
|
|
2719
|
-
registerWatchCleanup(context.embedded);
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
|
|
2723
|
-
const watchState = runtime.watchState;
|
|
2724
|
-
|
|
2725
|
-
// Track initial test files for watch mode (from this controller's freshly
|
|
2726
|
-
// collected entries, before adopting the runtime's entry snapshot below).
|
|
2727
|
-
if (isWatchMode) {
|
|
2728
|
-
watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
|
-
// Mark files as pending-affected so the next `triggerRerun` reruns them
|
|
2732
|
-
// through the normal plan/schedule/finalize pipeline (used by the watch
|
|
2733
|
-
// handles' explicit reruns; omitted paths = all current files). Returns the
|
|
2734
|
-
// number of seeded files so callers can skip the rerun when a path-scoped
|
|
2735
|
-
// request matches no browser test file (mixed watch 'u' with node-only
|
|
2736
|
-
// snapshot updates).
|
|
2737
|
-
const seedPendingRerun = (testPaths?: string[]): number => {
|
|
2738
|
-
const wanted = testPaths
|
|
2739
|
-
? new Set(testPaths.map((testPath) => normalize(testPath)))
|
|
2740
|
-
: null;
|
|
2741
|
-
let seeded = 0;
|
|
2742
|
-
for (const file of watchState.lastTestFiles) {
|
|
2743
|
-
if (wanted && !wanted.has(file.testPath)) {
|
|
2744
|
-
continue;
|
|
2745
|
-
}
|
|
2746
|
-
const pending =
|
|
2747
|
-
watchState.pendingAffectedTestFiles.get(file.projectName) ??
|
|
2748
|
-
new Set<string>();
|
|
2749
|
-
pending.add(file.testPath);
|
|
2750
|
-
watchState.pendingAffectedTestFiles.set(file.projectName, pending);
|
|
2751
|
-
seeded += 1;
|
|
2752
|
-
}
|
|
2753
|
-
return seeded;
|
|
2754
|
-
};
|
|
2755
|
-
|
|
2756
|
-
const watchHandles: BrowserWatchHandles | undefined = isWatchMode
|
|
2757
|
-
? {
|
|
2758
|
-
rerun: async (testPaths) => {
|
|
2759
|
-
const seeded = seedPendingRerun(testPaths);
|
|
2760
|
-
if (testPaths && seeded === 0) {
|
|
2761
|
-
return;
|
|
2762
|
-
}
|
|
2763
|
-
await triggerRerun?.();
|
|
2764
|
-
await awaitHeadlessRerunIdle?.();
|
|
2765
|
-
},
|
|
2766
|
-
close: cleanupWatchRuntime,
|
|
2767
|
-
}
|
|
2768
|
-
: undefined;
|
|
2769
|
-
|
|
2770
|
-
/**
|
|
2771
|
-
* Per-rerun finalize for watch mode: fold the rerun into a synthetic
|
|
2772
|
-
* `ExecutorCycleOutcome` and hand it to core's `finalizeRunCycle`, so
|
|
2773
|
-
* reporter payloads, exit-code never-downgrade semantics, and coverage
|
|
2774
|
-
* reports match the node watch cycle. The trace buffer stays session-owned
|
|
2775
|
-
* (no `traceRun` here); `buildTime` is the drained duration of the
|
|
2776
|
-
* change-triggered compile(s), not a hardcoded zero.
|
|
2777
|
-
*/
|
|
2778
|
-
const finalizeWatchRerun = async ({
|
|
2779
|
-
rerunTestPaths,
|
|
2780
|
-
testTime,
|
|
2781
|
-
unhandledErrors,
|
|
2782
|
-
}: {
|
|
2783
|
-
rerunTestPaths: string[];
|
|
2784
|
-
testTime: number;
|
|
2785
|
-
unhandledErrors?: Error[];
|
|
2786
|
-
}): Promise<void> => {
|
|
2787
|
-
const rerunPathSet = new Set(rerunTestPaths);
|
|
2788
|
-
const rerunResults = context.reporterResults.results.filter((result) =>
|
|
2789
|
-
rerunPathSet.has(result.testPath),
|
|
2790
|
-
);
|
|
2791
|
-
// Watch coverage is per-cycle on both transports: only the files this
|
|
2792
|
-
// rerun executed are reported.
|
|
2793
|
-
let rerunCoverage: CoverageMapData | undefined;
|
|
2794
|
-
const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
|
|
2795
|
-
if (coverageMap && coverageMap.files().length > 0) {
|
|
2796
|
-
rerunCoverage = coverageMap.toJSON();
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
const outcome: ExecutorCycleOutcome = {
|
|
2800
|
-
results: rerunResults,
|
|
2801
|
-
testResults: context.reporterResults.testResults.filter((result) =>
|
|
2802
|
-
rerunPathSet.has(result.testPath),
|
|
2803
|
-
),
|
|
2804
|
-
errors: unhandledErrors ?? [],
|
|
2805
|
-
testPaths: rerunTestPaths,
|
|
2806
|
-
duration: {
|
|
2807
|
-
buildTime: drainPendingBuildTime(watchState),
|
|
2808
|
-
testTime,
|
|
2809
|
-
},
|
|
2810
|
-
coverage: rerunCoverage ? { map: rerunCoverage } : undefined,
|
|
2811
|
-
resolveSourcemap: resolveBrowserSourcemap,
|
|
2812
|
-
};
|
|
2813
|
-
|
|
2814
|
-
await finalizeRunCycle(context, {
|
|
2815
|
-
outcomes: [outcome],
|
|
2816
|
-
mode: 'on-demand',
|
|
2817
|
-
isWatchMode: true,
|
|
2818
|
-
coverageProvider,
|
|
2819
|
-
reportOnFailure: coverageConfig?.reportOnFailure ?? false,
|
|
2820
|
-
});
|
|
2821
|
-
};
|
|
2822
|
-
|
|
2823
|
-
projectEntries = runtime.projectEntries;
|
|
2824
|
-
totalTests = projectEntries.reduce(
|
|
2825
|
-
(total, item) => total + item.testFiles.length,
|
|
2826
|
-
0,
|
|
2827
|
-
);
|
|
2828
|
-
|
|
2829
|
-
const buildTime = Date.now() - buildStart;
|
|
2830
|
-
|
|
2831
|
-
if (filesOnly) {
|
|
2832
|
-
return {
|
|
2833
|
-
results: [],
|
|
2834
|
-
testResults: [],
|
|
2835
|
-
duration: {
|
|
2836
|
-
totalTime: buildTime,
|
|
2837
|
-
buildTime,
|
|
2838
|
-
testTime: 0,
|
|
2839
|
-
},
|
|
2840
|
-
hasFailure: false,
|
|
2841
|
-
getSourcemap: getBrowserSourcemap,
|
|
2842
|
-
resolveSourcemap: resolveBrowserSourcemap,
|
|
2843
|
-
close: () => destroyBrowserRuntime(runtime),
|
|
2844
|
-
};
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
if (totalTests === 0) {
|
|
2848
|
-
reportEmptyTestSet();
|
|
2849
|
-
await destroyBrowserRuntime(runtime);
|
|
2850
|
-
return allowEmptyRun ? createEmptyRunResult() : undefined;
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
const { browser, browserLaunchOptions, wsPort, wss } = runtime;
|
|
2854
|
-
|
|
2855
|
-
// Collect all test files from project entries with project info
|
|
2856
|
-
// Normalize paths to posix format for cross-platform compatibility
|
|
2857
|
-
const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
|
|
2858
|
-
entry.testFiles.map((testPath) => ({
|
|
2859
|
-
testPath: normalize(testPath),
|
|
2860
|
-
projectName: entry.project.name,
|
|
2861
|
-
})),
|
|
2862
|
-
);
|
|
2863
|
-
|
|
2864
|
-
// Only include browser mode projects in runtime configs
|
|
2865
|
-
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
2866
|
-
const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
|
|
2867
|
-
(project: ProjectContext) => ({
|
|
2868
|
-
name: project.name,
|
|
2869
|
-
environmentName: project.environmentName,
|
|
2870
|
-
projectRoot: normalize(project.rootPath),
|
|
2871
|
-
runtimeConfig: serializableConfig(
|
|
2872
|
-
// `env` is the post-globalSetup change-set from the core pre-cycle
|
|
2873
|
-
// stage; the projection layers it between the static base and the
|
|
2874
|
-
// user `test.env` config.
|
|
2875
|
-
projectRuntimeConfig(project, { envMode: 'static', envOverlay: env }),
|
|
2876
|
-
),
|
|
2877
|
-
viewport: project.normalizedConfig.browser.viewport,
|
|
2878
|
-
}),
|
|
2879
|
-
);
|
|
2880
|
-
|
|
2881
|
-
const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
|
|
2882
|
-
|
|
2883
|
-
const projectRunnerUrls = Object.fromEntries(
|
|
2884
|
-
[...runtime.projectServers].map(([name, server]) => [
|
|
2885
|
-
name,
|
|
2886
|
-
`http://localhost:${server.port}`,
|
|
2887
|
-
]),
|
|
2888
|
-
);
|
|
2889
|
-
|
|
2890
|
-
const hostOptions: BrowserHostConfig = {
|
|
2891
|
-
rootPath: normalize(context.rootPath),
|
|
2892
|
-
projects: projectRuntimeConfigs,
|
|
2893
|
-
snapshot: {
|
|
2894
|
-
updateSnapshot:
|
|
2895
|
-
options?.updateSnapshot ??
|
|
2896
|
-
context.snapshotManager.options.updateSnapshot,
|
|
2897
|
-
},
|
|
2898
|
-
// Container origin (fallback). Per-project runner origins below.
|
|
2899
|
-
runnerUrl: `http://localhost:${runtime.containerServer.port}`,
|
|
2900
|
-
projectRunnerUrls,
|
|
2901
|
-
wsPort,
|
|
2902
|
-
debug: isDebug(),
|
|
2903
|
-
rpcTimeout: maxTestTimeoutForRpc,
|
|
2904
|
-
};
|
|
2905
|
-
|
|
2906
|
-
const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
|
|
2907
|
-
(project) => ({
|
|
2908
|
-
rootPath: normalize(project.rootPath),
|
|
2909
|
-
provider: project.normalizedConfig.browser.provider,
|
|
2910
|
-
}),
|
|
2911
|
-
);
|
|
2912
|
-
const implementationByProvider = new Map<
|
|
2913
|
-
BrowserProvider,
|
|
2914
|
-
BrowserProviderImplementation
|
|
2915
|
-
>();
|
|
2916
|
-
for (const browserProject of browserProviderProjects) {
|
|
2917
|
-
if (!implementationByProvider.has(browserProject.provider)) {
|
|
2918
|
-
implementationByProvider.set(
|
|
2919
|
-
browserProject.provider,
|
|
2920
|
-
getBrowserProviderImplementation(browserProject.provider),
|
|
2921
|
-
);
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
let activeContainerPage: BrowserProviderPage | null = null;
|
|
2926
|
-
let getHeadlessRunnerPageBySessionId:
|
|
2927
|
-
((sessionId: string) => BrowserProviderPage | undefined) | undefined;
|
|
2928
|
-
|
|
2929
|
-
const dispatchBrowserRpcRequest = async ({
|
|
2930
|
-
request,
|
|
2931
|
-
target,
|
|
2932
|
-
}: {
|
|
2933
|
-
request: BrowserRpcRequest;
|
|
2934
|
-
target?: BrowserDispatchRequest['target'];
|
|
2935
|
-
}): Promise<unknown> => {
|
|
2936
|
-
const timeoutFallbackMs = maxTestTimeoutForRpc;
|
|
2937
|
-
const provider = resolveProviderForTestPath({
|
|
2938
|
-
testPath: request.testPath,
|
|
2939
|
-
browserProjects: browserProviderProjects,
|
|
2940
|
-
});
|
|
2941
|
-
const implementation = implementationByProvider.get(provider);
|
|
2942
|
-
if (!implementation) {
|
|
2943
|
-
throw new Error(`Browser provider implementation not found: ${provider}`);
|
|
2944
|
-
}
|
|
2945
|
-
|
|
2946
|
-
const runnerPage = target?.sessionId
|
|
2947
|
-
? getHeadlessRunnerPageBySessionId?.(target.sessionId)
|
|
2948
|
-
: undefined;
|
|
2949
|
-
|
|
2950
|
-
if (target?.sessionId && !runnerPage) {
|
|
2951
|
-
throw new Error(
|
|
2952
|
-
`Runner page session not found for browser dispatch: ${target.sessionId}`,
|
|
2953
|
-
);
|
|
2954
|
-
}
|
|
2955
|
-
|
|
2956
|
-
if (!runnerPage && !activeContainerPage) {
|
|
2957
|
-
throw new Error('Browser container page is not initialized');
|
|
2958
|
-
}
|
|
2959
|
-
|
|
2960
|
-
try {
|
|
2961
|
-
return await implementation.dispatchRpc({
|
|
2962
|
-
containerPage: runnerPage
|
|
2963
|
-
? undefined
|
|
2964
|
-
: (activeContainerPage ?? undefined),
|
|
2965
|
-
runnerPage,
|
|
2966
|
-
request,
|
|
2967
|
-
timeoutFallbackMs,
|
|
2968
|
-
});
|
|
2969
|
-
} catch (error) {
|
|
2970
|
-
// birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
|
|
2971
|
-
if (error instanceof Error) {
|
|
2972
|
-
throw error.message;
|
|
2973
|
-
}
|
|
2974
|
-
throw String(error);
|
|
2975
|
-
}
|
|
2976
|
-
};
|
|
2977
|
-
|
|
2978
|
-
runtime.dispatchHandlers.set(
|
|
2979
|
-
DISPATCH_NAMESPACE_BROWSER,
|
|
2980
|
-
async (dispatchRequest) => {
|
|
2981
|
-
const request = validateBrowserRpcRequest(dispatchRequest.args);
|
|
2982
|
-
return dispatchBrowserRpcRequest({
|
|
2983
|
-
request,
|
|
2984
|
-
target: dispatchRequest.target,
|
|
2985
|
-
});
|
|
2986
|
-
},
|
|
2987
|
-
);
|
|
2988
|
-
|
|
2989
|
-
runtime.setContainerOptions(hostOptions);
|
|
2990
|
-
|
|
2991
|
-
// Track test results from browser runners
|
|
2992
|
-
const reporterResults: TestFileResult[] = [];
|
|
2993
|
-
const caseResults: TestResult[] = [];
|
|
2994
|
-
let fatalError: Error | null = null;
|
|
2995
|
-
|
|
2996
|
-
// Runner lifecycle events flow through the shared RunnerEventSink (the same
|
|
2997
|
-
// pump the node pool uses), so browser mode feeds stateManager and fans out to
|
|
2998
|
-
// reporters via one implementation. One sink is bound per browser project up
|
|
2999
|
-
// front from the executor's own project plan (`browserProjects`) — never from
|
|
3000
|
-
// `context.projects`, which planning mutates to also contain node projects. The
|
|
3001
|
-
// previous lazy resolver fell back to `context.projects[0]`, so a browser event
|
|
3002
|
-
// could be attributed to a *node* project's config; binding per browser project
|
|
3003
|
-
// here removes that fallback and keeps per-project `onConsoleLog` filtering and
|
|
3004
|
-
// `resolveSnapshotPath` correct across browser projects that share a relative
|
|
3005
|
-
// test path.
|
|
3006
|
-
const runnerSinks = new Map<string, RunnerEventSink>(
|
|
3007
|
-
browserProjects.map((project) => [
|
|
3008
|
-
project.name,
|
|
3009
|
-
createRunnerEventSink(context, project.normalizedConfig),
|
|
3010
|
-
]),
|
|
3011
|
-
);
|
|
3012
|
-
// Every per-file wire payload carries its owning project name, so routing
|
|
3013
|
-
// never derives a project from a test path — concurrent projects can run the
|
|
3014
|
-
// same file, and a path-keyed lookup would attribute events to the wrong one.
|
|
3015
|
-
// The client resolves its project from the host's own manifest, so a miss is
|
|
3016
|
-
// a protocol bug; fail loudly rather than route through another project's
|
|
3017
|
-
// config.
|
|
3018
|
-
const sinkForProjectName = (projectName: string): RunnerEventSink => {
|
|
3019
|
-
const sink = runnerSinks.get(projectName);
|
|
3020
|
-
if (!sink) {
|
|
3021
|
-
throw new Error(`No runner event sink for project "${projectName}"`);
|
|
3022
|
-
}
|
|
3023
|
-
return sink;
|
|
3024
|
-
};
|
|
3025
|
-
|
|
3026
|
-
// Silent-console buffering runs through the shared controller — the same
|
|
3027
|
-
// engine the node worker uses — so `silent: 'passed-only'` buffers logs and
|
|
3028
|
-
// replays only the failing tasks'. Intercepted replays route through the
|
|
3029
|
-
// owning project's sink, so they honor per-project `onConsoleLog` and
|
|
3030
|
-
// `disableConsoleIntercept` (the browser host previously flushed straight to
|
|
3031
|
-
// reporters, bypassing both). `writeOriginalLog` is a host-side no-op: page
|
|
3032
|
-
// logs have no host "original stream" — the page console and headed terminal
|
|
3033
|
-
// forwarding already show them, so re-emitting here would double-print.
|
|
3034
|
-
const silentConsoleController = createSilentConsoleController({
|
|
3035
|
-
runtimeConfig: {
|
|
3036
|
-
silent: context.normalizedConfig.silent,
|
|
3037
|
-
disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
|
|
3038
|
-
},
|
|
3039
|
-
emitInterceptedLog: (log) =>
|
|
3040
|
-
sinkForProjectName(log.project).onConsoleLog(log),
|
|
3041
|
-
writeOriginalLog: () => {},
|
|
3042
|
-
});
|
|
3043
|
-
|
|
3044
|
-
const snapshotRpcMethods = {
|
|
3045
|
-
async resolveSnapshotPath(testPath: string): Promise<string> {
|
|
3046
|
-
return resolveSnapshotPathDefault(
|
|
3047
|
-
testPath,
|
|
3048
|
-
context.normalizedConfig.resolveSnapshotPath,
|
|
3049
|
-
);
|
|
3050
|
-
},
|
|
3051
|
-
async readSnapshotFile(filepath: string): Promise<string | null> {
|
|
3052
|
-
try {
|
|
3053
|
-
return await fs.readFile(filepath, 'utf-8');
|
|
3054
|
-
} catch {
|
|
3055
|
-
return null;
|
|
3056
|
-
}
|
|
3057
|
-
},
|
|
3058
|
-
async saveSnapshotFile(filepath: string, content: string): Promise<void> {
|
|
3059
|
-
const dir = dirname(filepath);
|
|
3060
|
-
await fs.mkdir(dir, { recursive: true });
|
|
3061
|
-
await fs.writeFile(filepath, content, 'utf-8');
|
|
3062
|
-
},
|
|
3063
|
-
async removeSnapshotFile(filepath: string): Promise<void> {
|
|
3064
|
-
try {
|
|
3065
|
-
await fs.unlink(filepath);
|
|
3066
|
-
} catch {
|
|
3067
|
-
// ignore if file doesn't exist
|
|
3068
|
-
}
|
|
3069
|
-
},
|
|
3070
|
-
};
|
|
3071
|
-
|
|
3072
|
-
const handleTestFileStart = async (
|
|
3073
|
-
payload: TestFileStartPayload,
|
|
3074
|
-
): Promise<void> => {
|
|
3075
|
-
if (phaseTrackers) {
|
|
3076
|
-
const tracker = new PhaseTracker({
|
|
3077
|
-
trace: {
|
|
3078
|
-
testPath: payload.testPath,
|
|
3079
|
-
project: payload.projectName,
|
|
3080
|
-
},
|
|
3081
|
-
pid: nextBrowserFilePid++,
|
|
3082
|
-
});
|
|
3083
|
-
tracker.transition('prepare');
|
|
3084
|
-
phaseTrackers.set(
|
|
3085
|
-
trackerKey(payload.projectName, payload.testPath),
|
|
3086
|
-
tracker,
|
|
3087
|
-
);
|
|
3088
|
-
}
|
|
3089
|
-
// The client sends `{ testPath, projectName }`; the sink adapter builds the
|
|
3090
|
-
// `TestFileInfo` the reporters and stateManager expect.
|
|
3091
|
-
await sinkForProjectName(payload.projectName).onTestFileStart({
|
|
3092
|
-
testId: getFileTaskId(payload.testPath),
|
|
3093
|
-
testPath: payload.testPath,
|
|
3094
|
-
project: payload.projectName,
|
|
3095
|
-
tests: [],
|
|
3096
|
-
});
|
|
3097
|
-
};
|
|
3098
|
-
|
|
3099
|
-
const handleTestFileReady = async (
|
|
3100
|
-
payload: TestFileReadyPayload,
|
|
3101
|
-
): Promise<void> => {
|
|
3102
|
-
phaseTrackers
|
|
3103
|
-
?.get(trackerKey(payload.project, payload.testPath))
|
|
3104
|
-
?.transition('tests');
|
|
3105
|
-
await sinkForProjectName(payload.project).onTestFileReady(payload);
|
|
3106
|
-
};
|
|
3107
|
-
|
|
3108
|
-
const handleTestSuiteStart = async (
|
|
3109
|
-
payload: TestSuiteStartPayload,
|
|
3110
|
-
): Promise<void> => {
|
|
3111
|
-
phaseTrackers
|
|
3112
|
-
?.get(trackerKey(payload.project, payload.testPath))
|
|
3113
|
-
?.recordSuiteStart(payload);
|
|
3114
|
-
await sinkForProjectName(payload.project).onTestSuiteStart(payload);
|
|
3115
|
-
};
|
|
3116
|
-
|
|
3117
|
-
const handleTestSuiteResult = async (
|
|
3118
|
-
payload: TestSuiteResultPayload,
|
|
3119
|
-
): Promise<void> => {
|
|
3120
|
-
phaseTrackers
|
|
3121
|
-
?.get(trackerKey(payload.project, payload.testPath))
|
|
3122
|
-
?.recordSuiteResult(payload);
|
|
3123
|
-
await sinkForProjectName(payload.project).onTestSuiteResult(payload);
|
|
3124
|
-
|
|
3125
|
-
if (context.normalizedConfig.silent === 'passed-only') {
|
|
3126
|
-
silentConsoleController.flushBufferedLogsForTask({
|
|
3127
|
-
taskId: payload.testId,
|
|
3128
|
-
status: payload.status,
|
|
3129
|
-
taskParentNames: payload.parentNames,
|
|
3130
|
-
taskType: 'suite',
|
|
3131
|
-
testPath: payload.testPath,
|
|
3132
|
-
});
|
|
3133
|
-
}
|
|
3134
|
-
};
|
|
3135
|
-
|
|
3136
|
-
const handleTestCaseStart = async (
|
|
3137
|
-
payload: TestCaseStartPayload,
|
|
3138
|
-
): Promise<void> => {
|
|
3139
|
-
phaseTrackers
|
|
3140
|
-
?.get(trackerKey(payload.project, payload.testPath))
|
|
3141
|
-
?.recordCaseStart(payload);
|
|
3142
|
-
// Fire-and-forget on both transports (the sink does not await case-start).
|
|
3143
|
-
sinkForProjectName(payload.project).onTestCaseStart(payload);
|
|
3144
|
-
};
|
|
3145
|
-
|
|
3146
|
-
const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
|
|
3147
|
-
caseResults.push(payload);
|
|
3148
|
-
phaseTrackers
|
|
3149
|
-
?.get(trackerKey(payload.project, payload.testPath))
|
|
3150
|
-
?.recordCaseResult(payload);
|
|
3151
|
-
await sinkForProjectName(payload.project).onTestCaseResult(payload);
|
|
3152
|
-
|
|
3153
|
-
if (context.normalizedConfig.silent === 'passed-only') {
|
|
3154
|
-
silentConsoleController.flushBufferedLogsForTask({
|
|
3155
|
-
taskId: payload.testId,
|
|
3156
|
-
status: payload.status,
|
|
3157
|
-
taskParentNames: payload.parentNames,
|
|
3158
|
-
taskType: 'case',
|
|
3159
|
-
testPath: payload.testPath,
|
|
3160
|
-
});
|
|
3161
|
-
}
|
|
3162
|
-
};
|
|
3163
|
-
|
|
3164
|
-
const handleTestFileComplete = async (
|
|
3165
|
-
payload: TestFileResult,
|
|
3166
|
-
): Promise<void> => {
|
|
3167
|
-
reporterResults.push(payload);
|
|
3168
|
-
context.updateReporterResultState([payload], payload.results);
|
|
3169
|
-
|
|
3170
|
-
if (phaseTrackers) {
|
|
3171
|
-
const key = trackerKey(payload.project, payload.testPath);
|
|
3172
|
-
const tracker = phaseTrackers.get(key);
|
|
3173
|
-
if (tracker) {
|
|
3174
|
-
tracker.end();
|
|
3175
|
-
const events = tracker.getTraceEvents();
|
|
3176
|
-
if (events) onTraceEvents?.(events);
|
|
3177
|
-
phaseTrackers.delete(key);
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
|
|
3181
|
-
if (context.normalizedConfig.silent === 'passed-only') {
|
|
3182
|
-
silentConsoleController.flushBufferedLogsForTask({
|
|
3183
|
-
taskId: payload.testId,
|
|
3184
|
-
status: payload.status,
|
|
3185
|
-
taskParentNames: payload.parentNames,
|
|
3186
|
-
taskType: 'file',
|
|
3187
|
-
testPath: payload.testPath,
|
|
3188
|
-
});
|
|
3189
|
-
}
|
|
3190
|
-
|
|
3191
|
-
// Feeds stateManager, fans out onTestFileResult to reporters, and ingests
|
|
3192
|
-
// payload.snapshotResult (the snapshotManager.add moved into the sink).
|
|
3193
|
-
await sinkForProjectName(payload.project).onTestFileResult(payload);
|
|
3194
|
-
// In non-watch runs core owns the exit code via `finalizeRunCycle` (the
|
|
3195
|
-
// failing file rides the returned outcome); watch reruns set it here.
|
|
3196
|
-
if (isWatchMode && payload.status === 'fail') {
|
|
3197
|
-
ensureProcessExitCode(1);
|
|
3198
|
-
}
|
|
3199
|
-
};
|
|
3200
|
-
|
|
3201
|
-
const handleLog = async (payload: LogPayload): Promise<void> => {
|
|
3202
|
-
const log: UserConsoleLog = {
|
|
3203
|
-
content: payload.content,
|
|
3204
|
-
// Same colored level label as the node worker's CustomConsole.
|
|
3205
|
-
name: getPrettyConsoleName(payload.level),
|
|
3206
|
-
taskId: payload.taskId,
|
|
3207
|
-
taskName: payload.taskName,
|
|
3208
|
-
taskParentNames: payload.taskParentNames,
|
|
3209
|
-
taskType: payload.taskType,
|
|
3210
|
-
testPath: payload.testPath,
|
|
3211
|
-
project: payload.projectName,
|
|
3212
|
-
type: payload.type,
|
|
3213
|
-
trace: payload.trace,
|
|
3214
|
-
};
|
|
3215
|
-
silentConsoleController.onConsoleLog(log);
|
|
3216
|
-
};
|
|
3217
|
-
|
|
3218
|
-
const handleFatal = async (payload: FatalPayload): Promise<void> => {
|
|
3219
|
-
const error = new Error(payload.message);
|
|
3220
|
-
error.stack = payload.stack;
|
|
3221
|
-
fatalError = error;
|
|
3222
|
-
// Non-watch runs surface the fatal error through the returned outcome and
|
|
3223
|
-
// let core's `finalizeRunCycle` set the exit code; watch reruns set it here.
|
|
3224
|
-
if (isWatchMode) {
|
|
3225
|
-
ensureProcessExitCode(1);
|
|
3226
|
-
}
|
|
3227
|
-
};
|
|
3228
|
-
|
|
3229
|
-
const runSnapshotRpc = async (
|
|
3230
|
-
request: SnapshotRpcRequest,
|
|
3231
|
-
): Promise<unknown> => {
|
|
3232
|
-
switch (request.method) {
|
|
3233
|
-
case 'resolveSnapshotPath':
|
|
3234
|
-
return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
|
|
3235
|
-
case 'readSnapshotFile':
|
|
3236
|
-
return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
|
|
3237
|
-
case 'saveSnapshotFile':
|
|
3238
|
-
return snapshotRpcMethods.saveSnapshotFile(
|
|
3239
|
-
request.args.filepath,
|
|
3240
|
-
request.args.content,
|
|
3241
|
-
);
|
|
3242
|
-
case 'removeSnapshotFile':
|
|
3243
|
-
return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
|
|
3244
|
-
default: {
|
|
3245
|
-
// Exhaustiveness guard: a new SnapshotRpcRequest method without a case
|
|
3246
|
-
// here fails to compile rather than silently returning undefined.
|
|
3247
|
-
const _exhaustive: never = request;
|
|
3248
|
-
return _exhaustive;
|
|
3249
|
-
}
|
|
3250
|
-
}
|
|
3251
|
-
};
|
|
3252
|
-
|
|
3253
|
-
const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
|
|
3254
|
-
return createHostDispatchRouter({
|
|
3255
|
-
routerOptions: options,
|
|
3256
|
-
runnerCallbacks: {
|
|
3257
|
-
onTestFileStart: handleTestFileStart,
|
|
3258
|
-
onTestFileReady: handleTestFileReady,
|
|
3259
|
-
onTestSuiteStart: handleTestSuiteStart,
|
|
3260
|
-
onTestSuiteResult: handleTestSuiteResult,
|
|
3261
|
-
onTestCaseStart: handleTestCaseStart,
|
|
3262
|
-
onTestCaseResult: handleTestCaseResult,
|
|
3263
|
-
onTestFileComplete: handleTestFileComplete,
|
|
3264
|
-
onLog: handleLog,
|
|
3265
|
-
onFatal: handleFatal,
|
|
3266
|
-
},
|
|
3267
|
-
runSnapshotRpc,
|
|
3268
|
-
extensionHandlers: runtime.dispatchHandlers,
|
|
3269
|
-
onDuplicateNamespace: (namespace) => {
|
|
3270
|
-
logger.debug(
|
|
3271
|
-
`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
|
|
3272
|
-
);
|
|
3273
|
-
},
|
|
3274
|
-
});
|
|
3275
|
-
};
|
|
3276
|
-
|
|
3277
|
-
if (useHeadlessDirect) {
|
|
3278
|
-
// Session-based scheduling path: lifecycle + session index + dispatch routing.
|
|
3279
|
-
type ActiveHeadlessRun = RunSession & {
|
|
3280
|
-
contexts: Set<BrowserProviderContext>;
|
|
3281
|
-
};
|
|
3282
|
-
|
|
3283
|
-
const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
|
|
3284
|
-
const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
|
|
3285
|
-
const sessionRegistry = new RunnerSessionRegistry();
|
|
3286
|
-
getHeadlessRunnerPageBySessionId = (sessionId) => {
|
|
3287
|
-
return sessionRegistry.getById(sessionId)?.page;
|
|
3288
|
-
};
|
|
3289
|
-
let dispatchRequestCounter = 0;
|
|
3290
|
-
|
|
3291
|
-
const nextDispatchRequestId = (namespace: string): string => {
|
|
3292
|
-
return `${namespace}-${++dispatchRequestCounter}`;
|
|
3293
|
-
};
|
|
3294
|
-
|
|
3295
|
-
const closeContextSafely = async (
|
|
3296
|
-
browserContext: BrowserProviderContext,
|
|
3297
|
-
): Promise<void> => {
|
|
3298
|
-
try {
|
|
3299
|
-
await browserContext.close();
|
|
3300
|
-
} catch {
|
|
3301
|
-
// ignore
|
|
3302
|
-
}
|
|
3303
|
-
};
|
|
3304
|
-
|
|
3305
|
-
const cancelRun = async (
|
|
3306
|
-
run: ActiveHeadlessRun,
|
|
3307
|
-
waitForDone = true,
|
|
3308
|
-
): Promise<void> => {
|
|
3309
|
-
await runLifecycle.cancel(run, {
|
|
3310
|
-
waitForDone,
|
|
3311
|
-
onCancel: async (session) => {
|
|
3312
|
-
await Promise.all(
|
|
3313
|
-
Array.from(session.contexts).map((browserContext) =>
|
|
3314
|
-
closeContextSafely(browserContext),
|
|
3315
|
-
),
|
|
3316
|
-
);
|
|
3317
|
-
},
|
|
3318
|
-
});
|
|
3319
|
-
};
|
|
3320
|
-
|
|
3321
|
-
const dispatchRouter = createDispatchRouter({
|
|
3322
|
-
isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
|
|
3323
|
-
onStale: (request) => {
|
|
3324
|
-
if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
|
|
3325
|
-
logger.debug(
|
|
3326
|
-
`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
|
|
3327
|
-
);
|
|
3328
|
-
}
|
|
3329
|
-
},
|
|
3330
|
-
});
|
|
3331
|
-
|
|
3332
|
-
const dispatchRunnerMessage = async (
|
|
3333
|
-
run: ActiveHeadlessRun,
|
|
3334
|
-
file: TestFileInfo,
|
|
3335
|
-
sessionId: string,
|
|
3336
|
-
message: BrowserClientMessage,
|
|
3337
|
-
): Promise<void> => {
|
|
3338
|
-
const response = await dispatchRouter.dispatch({
|
|
3339
|
-
requestId: nextDispatchRequestId(DISPATCH_NAMESPACE_RUNNER),
|
|
3340
|
-
runToken: run.token,
|
|
3341
|
-
namespace: DISPATCH_NAMESPACE_RUNNER,
|
|
3342
|
-
method: message.type,
|
|
3343
|
-
args: 'payload' in message ? message.payload : undefined,
|
|
3344
|
-
target: {
|
|
3345
|
-
sessionId,
|
|
3346
|
-
testFile: file.testPath,
|
|
3347
|
-
projectName: file.projectName,
|
|
3348
|
-
},
|
|
3349
|
-
});
|
|
3350
|
-
|
|
3351
|
-
if (response.stale) {
|
|
3352
|
-
return;
|
|
3353
|
-
}
|
|
3354
|
-
|
|
3355
|
-
if (response.error) {
|
|
3356
|
-
throw new Error(response.error);
|
|
3357
|
-
}
|
|
3358
|
-
};
|
|
3359
|
-
|
|
3360
|
-
const runSingleFile = async (
|
|
3361
|
-
run: ActiveHeadlessRun,
|
|
3362
|
-
file: TestFileInfo,
|
|
3363
|
-
): Promise<void> => {
|
|
3364
|
-
if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
|
|
3365
|
-
return;
|
|
3366
|
-
}
|
|
3367
|
-
|
|
3368
|
-
const viewport = viewportByProject.get(file.projectName);
|
|
3369
|
-
const browserContext = await browser.newContext({
|
|
3370
|
-
providerOptions: browserLaunchOptions.providerOptions,
|
|
3371
|
-
viewport: viewport ?? null,
|
|
3372
|
-
});
|
|
3373
|
-
run.contexts.add(browserContext);
|
|
3374
|
-
|
|
3375
|
-
let page: BrowserProviderPage | null = null;
|
|
3376
|
-
let sessionId: string | null = null;
|
|
3377
|
-
let settled = false;
|
|
3378
|
-
let resolveDone: (() => void) | null = null;
|
|
3379
|
-
|
|
3380
|
-
const markDone = (): void => {
|
|
3381
|
-
if (!settled) {
|
|
3382
|
-
settled = true;
|
|
3383
|
-
resolveDone?.();
|
|
3384
|
-
}
|
|
3385
|
-
};
|
|
3386
|
-
|
|
3387
|
-
const donePromise = new Promise<void>((resolve) => {
|
|
3388
|
-
resolveDone = resolve;
|
|
3389
|
-
});
|
|
3390
|
-
|
|
3391
|
-
// Event-driven death detection (vitest-style): a renderer crash or an
|
|
3392
|
-
// unexpected page close produces no further messages, so fail the file at
|
|
3393
|
-
// once. Per-test/hook timeouts are enforced inside the runner, so the host
|
|
3394
|
-
// deliberately keeps no execution-duration watchdog. Our own teardown
|
|
3395
|
-
// close is ignored because `settled`/`run.cancelled` are set by then.
|
|
3396
|
-
const crashDeferred = createDeferredPromise<string>();
|
|
3397
|
-
const onPageDead = (reason: string): void => {
|
|
3398
|
-
if (
|
|
3399
|
-
settled ||
|
|
3400
|
-
run.cancelled ||
|
|
3401
|
-
!runLifecycle.isTokenActive(run.token)
|
|
3402
|
-
) {
|
|
3403
|
-
return;
|
|
3404
|
-
}
|
|
3405
|
-
settled = true;
|
|
3406
|
-
crashDeferred.resolve(reason);
|
|
3407
|
-
};
|
|
3408
|
-
|
|
3409
|
-
try {
|
|
3410
|
-
page = await browserContext.newPage();
|
|
3411
|
-
page.on('crash', () =>
|
|
3412
|
-
onPageDead(`Browser page crashed while running ${file.testPath}.`),
|
|
3413
|
-
);
|
|
3414
|
-
page.on('close', () =>
|
|
3415
|
-
onPageDead(
|
|
3416
|
-
`Browser page closed unexpectedly while running ${file.testPath}.`,
|
|
3417
|
-
),
|
|
3418
|
-
);
|
|
3419
|
-
|
|
3420
|
-
const session = sessionRegistry.register({
|
|
3421
|
-
testFile: file.testPath,
|
|
3422
|
-
projectName: file.projectName,
|
|
3423
|
-
runToken: run.token,
|
|
3424
|
-
mode: 'headless-page',
|
|
3425
|
-
context: browserContext,
|
|
3426
|
-
page,
|
|
3427
|
-
});
|
|
3428
|
-
sessionId = session.id;
|
|
3429
|
-
|
|
3430
|
-
await attachHeadlessRunnerTransport(page, {
|
|
3431
|
-
onDispatchMessage: async (message) => {
|
|
3432
|
-
try {
|
|
3433
|
-
await dispatchRunnerMessage(run, file, session.id, message);
|
|
3434
|
-
if (
|
|
3435
|
-
message.type === 'file-complete' ||
|
|
3436
|
-
message.type === 'complete'
|
|
3437
|
-
) {
|
|
3438
|
-
markDone();
|
|
3439
|
-
} else if (message.type === 'fatal') {
|
|
3440
|
-
markDone();
|
|
3441
|
-
await cancelRun(run, false);
|
|
3442
|
-
}
|
|
3443
|
-
} catch (error) {
|
|
3444
|
-
const formatted = toError(error);
|
|
3445
|
-
await handleFatal({
|
|
3446
|
-
message: formatted.message,
|
|
3447
|
-
stack: formatted.stack,
|
|
3448
|
-
});
|
|
3449
|
-
markDone();
|
|
3450
|
-
await cancelRun(run, false);
|
|
3451
|
-
}
|
|
3452
|
-
},
|
|
3453
|
-
onDispatchRpc: async (request) => {
|
|
3454
|
-
return dispatchRouter.dispatch({
|
|
3455
|
-
...request,
|
|
3456
|
-
runToken: run.token,
|
|
3457
|
-
target: {
|
|
3458
|
-
sessionId: session.id,
|
|
3459
|
-
testFile: file.testPath,
|
|
3460
|
-
projectName: file.projectName,
|
|
3461
|
-
...request.target,
|
|
3462
|
-
},
|
|
3463
|
-
});
|
|
3464
|
-
},
|
|
3465
|
-
});
|
|
3466
|
-
|
|
3467
|
-
const inlineOptions: BrowserHostConfig = {
|
|
3468
|
-
...hostOptions,
|
|
3469
|
-
// Read live per page load, not from the construction-time
|
|
3470
|
-
// `hostOptions` value: the 'u' shortcut flips
|
|
3471
|
-
// `snapshotManager.options` between reruns.
|
|
3472
|
-
snapshot: {
|
|
3473
|
-
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
3474
|
-
},
|
|
3475
|
-
testFile: file.testPath,
|
|
3476
|
-
runId: `${run.token}:${session.id}`,
|
|
3477
|
-
};
|
|
3478
|
-
const serializedOptions = serializeForInlineScript(inlineOptions);
|
|
3479
|
-
await page.addInitScript(
|
|
3480
|
-
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
3481
|
-
);
|
|
3482
|
-
|
|
3483
|
-
const projectServer = runtime.projectServers.get(file.projectName);
|
|
3484
|
-
if (!projectServer) {
|
|
3485
|
-
throw new Error(
|
|
3486
|
-
`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`,
|
|
3487
|
-
);
|
|
3488
|
-
}
|
|
3489
|
-
await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
|
|
3490
|
-
waitUntil: 'load',
|
|
3491
|
-
});
|
|
3492
|
-
|
|
3493
|
-
const state = await Promise.race([
|
|
3494
|
-
donePromise.then(() => ({ type: 'done' as const })),
|
|
3495
|
-
crashDeferred.promise.then((reason) => ({
|
|
3496
|
-
type: 'crash' as const,
|
|
3497
|
-
reason,
|
|
3498
|
-
})),
|
|
3499
|
-
run.cancelSignal.then(() => ({ type: 'cancelled' as const })),
|
|
3500
|
-
]);
|
|
3501
|
-
|
|
3502
|
-
if (state.type === 'cancelled') {
|
|
3503
|
-
return;
|
|
3504
|
-
}
|
|
3505
|
-
|
|
3506
|
-
if (
|
|
3507
|
-
state.type === 'crash' &&
|
|
3508
|
-
runLifecycle.isTokenActive(run.token) &&
|
|
3509
|
-
!run.cancelled
|
|
3510
|
-
) {
|
|
3511
|
-
await handleFatal({ message: state.reason });
|
|
3512
|
-
await cancelRun(run, false);
|
|
3513
|
-
}
|
|
3514
|
-
} catch (error) {
|
|
3515
|
-
if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
|
|
3516
|
-
const formatted = toError(error);
|
|
3517
|
-
await handleFatal({
|
|
3518
|
-
message: formatted.message,
|
|
3519
|
-
stack: formatted.stack,
|
|
3520
|
-
});
|
|
3521
|
-
await cancelRun(run, false);
|
|
3522
|
-
}
|
|
3523
|
-
} finally {
|
|
3524
|
-
if (page) {
|
|
3525
|
-
try {
|
|
3526
|
-
await page.close();
|
|
3527
|
-
} catch {
|
|
3528
|
-
// ignore
|
|
3529
|
-
}
|
|
3530
|
-
}
|
|
3531
|
-
if (sessionId) {
|
|
3532
|
-
sessionRegistry.deleteById(sessionId);
|
|
3533
|
-
}
|
|
3534
|
-
run.contexts.delete(browserContext);
|
|
3535
|
-
await closeContextSafely(browserContext);
|
|
3536
|
-
}
|
|
3537
|
-
};
|
|
3538
|
-
|
|
3539
|
-
// Bailed files never run, so they carry no case results — mirror the node
|
|
3540
|
-
// pool's skip result (`runInPool.ts`) so the summary reports them as skipped
|
|
3541
|
-
// rather than dropping them silently.
|
|
3542
|
-
const makeSkippedFileResult = (file: TestFileInfo): TestFileResult => ({
|
|
3543
|
-
testId: getFileTaskId(file.testPath),
|
|
3544
|
-
status: 'skip',
|
|
3545
|
-
name: '',
|
|
3546
|
-
testPath: file.testPath,
|
|
3547
|
-
project: file.projectName,
|
|
3548
|
-
results: [],
|
|
3549
|
-
});
|
|
3550
|
-
|
|
3551
|
-
const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
|
|
3552
|
-
if (files.length === 0) {
|
|
3553
|
-
return;
|
|
3554
|
-
}
|
|
3555
|
-
|
|
3556
|
-
const previous = runLifecycle.activeSession;
|
|
3557
|
-
if (previous) {
|
|
3558
|
-
await cancelRun(previous);
|
|
3559
|
-
}
|
|
3560
|
-
|
|
3561
|
-
const run = runLifecycle.createSession((token) => ({
|
|
3562
|
-
...createRunSession(token),
|
|
3563
|
-
contexts: new Set<BrowserProviderContext>(),
|
|
3564
|
-
}));
|
|
3565
|
-
|
|
3566
|
-
const queue = [...files];
|
|
3567
|
-
const concurrency = getHeadlessConcurrency(context, queue.length);
|
|
3568
|
-
const bail = context.normalizedConfig.bail;
|
|
3569
|
-
|
|
3570
|
-
const worker = async (): Promise<void> => {
|
|
3571
|
-
while (
|
|
3572
|
-
queue.length > 0 &&
|
|
3573
|
-
!run.cancelled &&
|
|
3574
|
-
runLifecycle.isTokenActive(run.token)
|
|
3575
|
-
) {
|
|
3576
|
-
// Cross-file bail gate (parity with the node pool's pickup-time skip
|
|
3577
|
-
// at `runInPool.ts`): once the cycle-wide failed count reaches `bail`,
|
|
3578
|
-
// drain the remaining files as skipped instead of running them. The
|
|
3579
|
-
// count is cycle-scoped because `stateManager` is reset at the top of
|
|
3580
|
-
// every run/rerun (initial run and `prepareWatchRerunState`).
|
|
3581
|
-
if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
|
|
3582
|
-
let skipped = queue.shift();
|
|
3583
|
-
while (skipped) {
|
|
3584
|
-
await handleTestFileComplete(makeSkippedFileResult(skipped));
|
|
3585
|
-
skipped = queue.shift();
|
|
3586
|
-
}
|
|
3587
|
-
return;
|
|
3588
|
-
}
|
|
3589
|
-
const next = queue.shift();
|
|
3590
|
-
if (!next) {
|
|
3591
|
-
return;
|
|
3592
|
-
}
|
|
3593
|
-
await runSingleFile(run, next);
|
|
3594
|
-
}
|
|
3595
|
-
};
|
|
3596
|
-
|
|
3597
|
-
run.done = Promise.all(
|
|
3598
|
-
Array.from(
|
|
3599
|
-
{ length: Math.min(queue.length, Math.max(concurrency, 1)) },
|
|
3600
|
-
() => worker(),
|
|
3601
|
-
),
|
|
3602
|
-
).then(() => {});
|
|
3603
|
-
|
|
3604
|
-
await run.done;
|
|
3605
|
-
runLifecycle.clearIfActive(run);
|
|
3606
|
-
};
|
|
3607
|
-
|
|
3608
|
-
const latestRerunScheduler = createHeadlessLatestRerunScheduler<
|
|
3609
|
-
TestFileInfo,
|
|
3610
|
-
ActiveHeadlessRun
|
|
3611
|
-
>({
|
|
3612
|
-
getActiveRun: () => runLifecycle.activeSession,
|
|
3613
|
-
isRunCancelled: (run) => run.cancelled,
|
|
3614
|
-
invalidateActiveRun: () => {
|
|
3615
|
-
runLifecycle.invalidateActiveToken();
|
|
3616
|
-
},
|
|
3617
|
-
interruptActiveRun: async (run) => {
|
|
3618
|
-
await cancelRun(run, false);
|
|
3619
|
-
},
|
|
3620
|
-
runFiles: async (files) => {
|
|
3621
|
-
// Clear the previous cycle's stateManager/snapshotManager before the
|
|
3622
|
-
// rerun streams new events through the shared sink — otherwise failed
|
|
3623
|
-
// counts (bail) and snapshot summaries accumulate across reruns. The
|
|
3624
|
-
// initial run does not reach here (it calls `runFilesWithPool`
|
|
3625
|
-
// directly), so only reruns reset.
|
|
3626
|
-
prepareWatchRerunState(context);
|
|
3627
|
-
await notifyTestRunStart();
|
|
3628
|
-
|
|
3629
|
-
const rerunStartTime = Date.now();
|
|
3630
|
-
const fatalErrorBeforeRun = fatalError;
|
|
3631
|
-
let rerunError: Error | undefined;
|
|
3632
|
-
|
|
3633
|
-
try {
|
|
3634
|
-
await runFilesWithPool(files);
|
|
3635
|
-
} catch (error) {
|
|
3636
|
-
rerunError = toError(error);
|
|
3637
|
-
throw error;
|
|
3638
|
-
} finally {
|
|
3639
|
-
const testTime = Math.max(0, Date.now() - rerunStartTime);
|
|
3640
|
-
const rerunFatalError =
|
|
3641
|
-
fatalError && fatalError !== fatalErrorBeforeRun
|
|
3642
|
-
? fatalError
|
|
3643
|
-
: undefined;
|
|
3644
|
-
await finalizeWatchRerun({
|
|
3645
|
-
rerunTestPaths: files.map((file) => file.testPath),
|
|
3646
|
-
testTime,
|
|
3647
|
-
unhandledErrors: rerunError
|
|
3648
|
-
? [rerunError]
|
|
3649
|
-
: rerunFatalError
|
|
3650
|
-
? [rerunFatalError]
|
|
3651
|
-
: undefined,
|
|
3652
|
-
});
|
|
3653
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
3654
|
-
}
|
|
3655
|
-
},
|
|
3656
|
-
onError: async (error) => {
|
|
3657
|
-
const formatted = toError(error);
|
|
3658
|
-
await handleFatal({
|
|
3659
|
-
message: formatted.message,
|
|
3660
|
-
stack: formatted.stack,
|
|
3661
|
-
});
|
|
3662
|
-
},
|
|
3663
|
-
onInterrupt: (run) => {
|
|
3664
|
-
logger.debug(
|
|
3665
|
-
`[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`,
|
|
3666
|
-
);
|
|
3667
|
-
},
|
|
3668
|
-
});
|
|
3669
|
-
|
|
3670
|
-
awaitHeadlessRerunIdle = () => latestRerunScheduler.whenIdle();
|
|
3671
|
-
|
|
3672
|
-
const testStart = Date.now();
|
|
3673
|
-
await runFilesWithPool(allTestFiles);
|
|
3674
|
-
const testTime = Date.now() - testStart;
|
|
3675
|
-
|
|
3676
|
-
if (isWatchMode) {
|
|
3677
|
-
triggerRerun = async () => {
|
|
3678
|
-
const newProjectEntries = await collectProjectEntries(context);
|
|
3679
|
-
const rerunPlan = planWatchRerun({
|
|
3680
|
-
projectEntries: newProjectEntries,
|
|
3681
|
-
previousTestFiles: watchState.lastTestFiles,
|
|
3682
|
-
affectedTestFiles: drainPendingAffectedTestFiles(watchState),
|
|
3683
|
-
});
|
|
3684
|
-
|
|
3685
|
-
if (rerunPlan.filesChanged) {
|
|
3686
|
-
const deletedTestPaths = collectDeletedTestPaths(
|
|
3687
|
-
watchState.lastTestFiles,
|
|
3688
|
-
rerunPlan.currentTestFiles,
|
|
3689
|
-
);
|
|
3690
|
-
if (deletedTestPaths.length > 0) {
|
|
3691
|
-
context.updateReporterResultState([], [], deletedTestPaths);
|
|
3692
|
-
}
|
|
3693
|
-
watchState.lastTestFiles = rerunPlan.currentTestFiles;
|
|
3694
|
-
if (rerunPlan.currentTestFiles.length === 0) {
|
|
3695
|
-
await latestRerunScheduler.enqueueLatest([]);
|
|
3696
|
-
logger.log(
|
|
3697
|
-
color.cyan('No browser test files remain after update.\n'),
|
|
3698
|
-
);
|
|
3699
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
3700
|
-
return;
|
|
3701
|
-
}
|
|
3702
|
-
|
|
3703
|
-
logger.log(
|
|
3704
|
-
color.cyan(
|
|
3705
|
-
`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
|
|
3706
|
-
),
|
|
3707
|
-
);
|
|
3708
|
-
await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
|
|
3709
|
-
return;
|
|
3710
|
-
}
|
|
3711
|
-
|
|
3712
|
-
if (rerunPlan.affectedTestFiles.length === 0) {
|
|
3713
|
-
logger.log(
|
|
3714
|
-
color.cyan(
|
|
3715
|
-
'No affected browser test files detected, skipping re-run.\n',
|
|
3716
|
-
),
|
|
3717
|
-
);
|
|
3718
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
3719
|
-
return;
|
|
3720
|
-
}
|
|
3721
|
-
|
|
3722
|
-
logger.log(
|
|
3723
|
-
color.cyan(
|
|
3724
|
-
`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
|
|
3725
|
-
),
|
|
3726
|
-
);
|
|
3727
|
-
await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
|
|
3728
|
-
};
|
|
3729
|
-
}
|
|
3730
|
-
|
|
3731
|
-
const closeHeadlessRuntime = !isWatchMode
|
|
3732
|
-
? async () => {
|
|
3733
|
-
sessionRegistry.clear();
|
|
3734
|
-
await destroyBrowserRuntime(runtime);
|
|
3735
|
-
}
|
|
3736
|
-
: undefined;
|
|
3737
|
-
|
|
3738
|
-
if (fatalError) {
|
|
3739
|
-
return failWithError(fatalError, closeHeadlessRuntime);
|
|
3740
|
-
}
|
|
3741
|
-
|
|
3742
|
-
const duration = {
|
|
3743
|
-
totalTime: buildTime + testTime,
|
|
3744
|
-
buildTime,
|
|
3745
|
-
testTime,
|
|
3746
|
-
};
|
|
3747
|
-
|
|
3748
|
-
context.updateReporterResultState(reporterResults, caseResults);
|
|
3749
|
-
|
|
3750
|
-
const isFailure = reporterResults.some(
|
|
3751
|
-
(result: TestFileResult) => result.status === 'fail',
|
|
3752
|
-
);
|
|
3753
|
-
// Non-watch runs let core's `finalizeRunCycle` own the exit code from the
|
|
3754
|
-
// returned outcome; watch reruns set it here.
|
|
3755
|
-
if (isWatchMode && isFailure) {
|
|
3756
|
-
ensureProcessExitCode(1);
|
|
3757
|
-
}
|
|
3758
|
-
|
|
3759
|
-
// Fold and strip the initial cycle's coverage (the same per-cycle fold
|
|
3760
|
-
// reruns get in `finalizeWatchRerun`); the map rides on the result so the
|
|
3761
|
-
// browser-only watch path can report it without re-merging. Non-watch runs
|
|
3762
|
-
// keep `result.coverage` intact for the executor's outcome fold.
|
|
3763
|
-
const cycleCoverageMap =
|
|
3764
|
-
isWatchMode && coverageProvider
|
|
3765
|
-
? buildBrowserCoverageMap(reporterResults, coverageProvider)
|
|
3766
|
-
: undefined;
|
|
3767
|
-
|
|
3768
|
-
const result = {
|
|
3769
|
-
results: reporterResults,
|
|
3770
|
-
testResults: caseResults,
|
|
3771
|
-
duration,
|
|
3772
|
-
hasFailure: isFailure,
|
|
3773
|
-
getSourcemap: getBrowserSourcemap,
|
|
3774
|
-
resolveSourcemap: resolveBrowserSourcemap,
|
|
3775
|
-
// `closeHeadlessRuntime` is already `undefined` in watch mode, so the
|
|
3776
|
-
// non-watch caller (core) receives the deferred close and watch does not.
|
|
3777
|
-
close: closeHeadlessRuntime,
|
|
3778
|
-
coverage: cycleCoverageMap,
|
|
3779
|
-
watch: watchHandles,
|
|
3780
|
-
};
|
|
3781
|
-
|
|
3782
|
-
if (isWatchMode) {
|
|
3783
|
-
try {
|
|
3784
|
-
await notifyTestRunEnd({
|
|
3785
|
-
duration,
|
|
3786
|
-
coverage: cycleCoverageMap?.files().length
|
|
3787
|
-
? cycleCoverageMap.toJSON()
|
|
3788
|
-
: undefined,
|
|
3789
|
-
});
|
|
3790
|
-
} finally {
|
|
3791
|
-
await closeHeadlessRuntime?.();
|
|
3792
|
-
}
|
|
3793
|
-
}
|
|
3794
|
-
|
|
3795
|
-
if (isWatchMode && triggerRerun) {
|
|
3796
|
-
watchState.hooksEnabled = true;
|
|
3797
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
3798
|
-
}
|
|
3799
|
-
|
|
3800
|
-
return result;
|
|
3801
|
-
}
|
|
3802
|
-
|
|
3803
|
-
let currentTestFiles = allTestFiles;
|
|
3804
|
-
// Coincidentally equal to the runner-side CONFIG_WAIT_TIMEOUT_MS and
|
|
3805
|
-
// DEFAULT_RPC_TIMEOUT_MS (client/entry.ts, client/dispatchTransport.ts) but
|
|
3806
|
-
// semantically distinct and in a different runtime, so deliberately NOT shared
|
|
3807
|
-
// with them. Invariant worth preserving: a runner must be able to receive its
|
|
3808
|
-
// config (config-wait) before the host declares its frames un-ready, i.e.
|
|
3809
|
-
// CONFIG_WAIT_TIMEOUT_MS <= RUNNER_FRAMES_READY_TIMEOUT_MS.
|
|
3810
|
-
const RUNNER_FRAMES_READY_TIMEOUT_MS = 30_000;
|
|
3811
|
-
let currentRunnerFramesSignature: string | null = null;
|
|
3812
|
-
const runnerFramesWaiters = new Map<string, Set<() => void>>();
|
|
3813
|
-
|
|
3814
|
-
const createTestFilesSignature = (testFiles: readonly string[]): string => {
|
|
3815
|
-
return JSON.stringify(testFiles.map((testFile) => normalize(testFile)));
|
|
3816
|
-
};
|
|
3817
|
-
|
|
3818
|
-
const markRunnerFramesReady = (testFiles: string[]): void => {
|
|
3819
|
-
const signature = createTestFilesSignature(testFiles);
|
|
3820
|
-
currentRunnerFramesSignature = signature;
|
|
3821
|
-
const waiters = runnerFramesWaiters.get(signature);
|
|
3822
|
-
if (!waiters) {
|
|
3823
|
-
return;
|
|
3824
|
-
}
|
|
3825
|
-
runnerFramesWaiters.delete(signature);
|
|
3826
|
-
for (const waiter of waiters) {
|
|
3827
|
-
waiter();
|
|
3828
|
-
}
|
|
3829
|
-
};
|
|
3830
|
-
|
|
3831
|
-
const waitForRunnerFramesReady = async (
|
|
3832
|
-
testFiles: readonly string[],
|
|
3833
|
-
): Promise<void> => {
|
|
3834
|
-
const signature = createTestFilesSignature(testFiles);
|
|
3835
|
-
if (currentRunnerFramesSignature === signature) {
|
|
3836
|
-
return;
|
|
3837
|
-
}
|
|
3838
|
-
|
|
3839
|
-
await new Promise<void>((resolve, reject) => {
|
|
3840
|
-
const waiters =
|
|
3841
|
-
runnerFramesWaiters.get(signature) ?? new Set<() => void>();
|
|
3842
|
-
|
|
3843
|
-
const cleanup = () => {
|
|
3844
|
-
const currentWaiters = runnerFramesWaiters.get(signature);
|
|
3845
|
-
if (!currentWaiters) {
|
|
3846
|
-
return;
|
|
3847
|
-
}
|
|
3848
|
-
currentWaiters.delete(onReady);
|
|
3849
|
-
if (currentWaiters.size === 0) {
|
|
3850
|
-
runnerFramesWaiters.delete(signature);
|
|
3851
|
-
}
|
|
3852
|
-
};
|
|
3853
|
-
|
|
3854
|
-
const onReady = () => {
|
|
3855
|
-
if (timeoutId) {
|
|
3856
|
-
clearTimeout(timeoutId);
|
|
3857
|
-
}
|
|
3858
|
-
cleanup();
|
|
3859
|
-
resolve();
|
|
3860
|
-
};
|
|
3861
|
-
|
|
3862
|
-
const timeoutId = setTimeout(() => {
|
|
3863
|
-
cleanup();
|
|
3864
|
-
reject(
|
|
3865
|
-
new Error(
|
|
3866
|
-
`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`,
|
|
3867
|
-
),
|
|
3868
|
-
);
|
|
3869
|
-
}, RUNNER_FRAMES_READY_TIMEOUT_MS);
|
|
3870
|
-
|
|
3871
|
-
waiters.add(onReady);
|
|
3872
|
-
runnerFramesWaiters.set(signature, waiters);
|
|
3873
|
-
|
|
3874
|
-
if (currentRunnerFramesSignature === signature) {
|
|
3875
|
-
onReady();
|
|
3876
|
-
}
|
|
3877
|
-
});
|
|
3878
|
-
};
|
|
3879
|
-
|
|
3880
|
-
const getTestFileInfo = (testFile: string): TestFileInfo => {
|
|
3881
|
-
const normalizedTestFile = normalize(testFile);
|
|
3882
|
-
const fileInfo = currentTestFiles.find(
|
|
3883
|
-
(file) => file.testPath === normalizedTestFile,
|
|
3884
|
-
);
|
|
3885
|
-
if (!fileInfo) {
|
|
3886
|
-
throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
|
|
3887
|
-
}
|
|
3888
|
-
return fileInfo;
|
|
3889
|
-
};
|
|
3890
|
-
|
|
3891
|
-
// Open a container page for user to view (reuse in watch mode)
|
|
3892
|
-
let containerContext: BrowserProviderContext;
|
|
3893
|
-
let containerPage: BrowserProviderPage;
|
|
3894
|
-
let isNewPage = false;
|
|
3895
|
-
|
|
3896
|
-
if (isWatchMode && runtime.containerPage && runtime.containerContext) {
|
|
3897
|
-
containerContext = runtime.containerContext;
|
|
3898
|
-
containerPage = runtime.containerPage;
|
|
3899
|
-
logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
|
|
3900
|
-
} else {
|
|
3901
|
-
isNewPage = true;
|
|
3902
|
-
containerContext = await browser.newContext({
|
|
3903
|
-
providerOptions: browserLaunchOptions.providerOptions,
|
|
3904
|
-
viewport: null,
|
|
3905
|
-
});
|
|
3906
|
-
containerPage = await containerContext.newPage();
|
|
3907
|
-
|
|
3908
|
-
// Prevent popup windows from being created
|
|
3909
|
-
containerPage.on('popup', async (popup: BrowserProviderPage) => {
|
|
3910
|
-
await popup.close().catch(() => {});
|
|
3911
|
-
});
|
|
3912
|
-
|
|
3913
|
-
containerContext.on('page', async (page: BrowserProviderPage) => {
|
|
3914
|
-
if (page !== containerPage) {
|
|
3915
|
-
await page.close().catch(() => {});
|
|
3916
|
-
}
|
|
3917
|
-
});
|
|
3918
|
-
|
|
3919
|
-
if (isWatchMode) {
|
|
3920
|
-
runtime.containerPage = containerPage;
|
|
3921
|
-
runtime.containerContext = containerContext;
|
|
3922
|
-
}
|
|
3923
|
-
|
|
3924
|
-
// Forward browser console to terminal
|
|
3925
|
-
containerPage.on('console', (msg) => {
|
|
3926
|
-
const text = msg.text();
|
|
3927
|
-
if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
|
|
3928
|
-
logger.log(color.gray(`[Browser Console] ${text}`));
|
|
3929
|
-
}
|
|
3930
|
-
});
|
|
3931
|
-
}
|
|
3932
|
-
|
|
3933
|
-
activeContainerPage = containerPage;
|
|
3934
|
-
|
|
3935
|
-
const dispatchRouter = createDispatchRouter();
|
|
3936
|
-
const headedReloadQueue = createHeadedSerialTaskQueue();
|
|
3937
|
-
const pendingHeadedReloads = new Map<
|
|
3938
|
-
string,
|
|
3939
|
-
{
|
|
3940
|
-
runId: string;
|
|
3941
|
-
deferred: DeferredPromise<void>;
|
|
3942
|
-
}
|
|
3943
|
-
>();
|
|
3944
|
-
let enqueueHeadedReload = async (
|
|
3945
|
-
_file: TestFileInfo,
|
|
3946
|
-
_testNamePattern?: string,
|
|
3947
|
-
): Promise<void> => {
|
|
3948
|
-
throw new Error('Headed reload queue is not initialized');
|
|
3949
|
-
};
|
|
3950
|
-
|
|
3951
|
-
const rejectPendingHeadedReload = (
|
|
3952
|
-
testPath: string,
|
|
3953
|
-
error: Error,
|
|
3954
|
-
runId?: string,
|
|
3955
|
-
): void => {
|
|
3956
|
-
const pending = pendingHeadedReloads.get(testPath);
|
|
3957
|
-
if (!pending) {
|
|
3958
|
-
return;
|
|
3959
|
-
}
|
|
3960
|
-
if (runId && pending.runId !== runId) {
|
|
3961
|
-
return;
|
|
3962
|
-
}
|
|
3963
|
-
pendingHeadedReloads.delete(testPath);
|
|
3964
|
-
pending.deferred.reject(error);
|
|
3965
|
-
};
|
|
3966
|
-
|
|
3967
|
-
const rejectAllPendingHeadedReloads = (error: Error): void => {
|
|
3968
|
-
for (const [testPath, pending] of pendingHeadedReloads) {
|
|
3969
|
-
pendingHeadedReloads.delete(testPath);
|
|
3970
|
-
pending.deferred.reject(error);
|
|
3971
|
-
}
|
|
3972
|
-
};
|
|
3973
|
-
|
|
3974
|
-
const registerPendingHeadedReload = (
|
|
3975
|
-
testPath: string,
|
|
3976
|
-
runId: string,
|
|
3977
|
-
): Promise<void> => {
|
|
3978
|
-
const previousPending = pendingHeadedReloads.get(testPath);
|
|
3979
|
-
if (previousPending) {
|
|
3980
|
-
previousPending.deferred.reject(
|
|
3981
|
-
new Error(
|
|
3982
|
-
`Reload for "${testPath}" was superseded by a newer request.`,
|
|
3983
|
-
),
|
|
3984
|
-
);
|
|
3985
|
-
pendingHeadedReloads.delete(testPath);
|
|
3986
|
-
}
|
|
3987
|
-
|
|
3988
|
-
const deferred = createDeferredPromise<void>();
|
|
3989
|
-
pendingHeadedReloads.set(testPath, {
|
|
3990
|
-
runId,
|
|
3991
|
-
deferred,
|
|
3992
|
-
});
|
|
3993
|
-
|
|
3994
|
-
return deferred.promise;
|
|
3995
|
-
};
|
|
3996
|
-
|
|
3997
|
-
const resolvePendingHeadedReload = (
|
|
3998
|
-
testPath: string,
|
|
3999
|
-
runId?: string,
|
|
4000
|
-
): void => {
|
|
4001
|
-
const pending = pendingHeadedReloads.get(testPath);
|
|
4002
|
-
if (!pending) {
|
|
4003
|
-
return;
|
|
4004
|
-
}
|
|
4005
|
-
if (runId && pending.runId !== runId) {
|
|
4006
|
-
logger.debug(
|
|
4007
|
-
`[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`,
|
|
4008
|
-
);
|
|
4009
|
-
return;
|
|
4010
|
-
}
|
|
4011
|
-
pendingHeadedReloads.delete(testPath);
|
|
4012
|
-
pending.deferred.resolve();
|
|
4013
|
-
};
|
|
4014
|
-
|
|
4015
|
-
// No execution-duration watchdog: per-test/hook timeouts are enforced inside
|
|
4016
|
-
// the runner, and a dead container is caught event-driven by the WebSocket
|
|
4017
|
-
// `close` handler, which rejects every pending reload via `onDisconnect`.
|
|
4018
|
-
const reloadTestFileAndWait = async (
|
|
4019
|
-
file: TestFileInfo,
|
|
4020
|
-
testNamePattern?: string,
|
|
4021
|
-
): Promise<void> => {
|
|
4022
|
-
let reloadAck: ReloadTestFileAck | undefined;
|
|
4023
|
-
|
|
4024
|
-
try {
|
|
4025
|
-
reloadAck = await rpcManager.reloadTestFile(
|
|
4026
|
-
file.testPath,
|
|
4027
|
-
testNamePattern,
|
|
4028
|
-
);
|
|
4029
|
-
await registerPendingHeadedReload(file.testPath, reloadAck.runId);
|
|
4030
|
-
} catch (error) {
|
|
4031
|
-
if (reloadAck?.runId) {
|
|
4032
|
-
rejectPendingHeadedReload(
|
|
4033
|
-
file.testPath,
|
|
4034
|
-
toError(error),
|
|
4035
|
-
reloadAck.runId,
|
|
4036
|
-
);
|
|
4037
|
-
}
|
|
4038
|
-
throw error;
|
|
4039
|
-
}
|
|
4040
|
-
};
|
|
4041
|
-
|
|
4042
|
-
// Create RPC methods that can access test state variables
|
|
4043
|
-
const createRpcMethods = (): HostRpcMethods => ({
|
|
4044
|
-
async rerunTest(testFile: string, testNamePattern?: string) {
|
|
4045
|
-
const projectName = context.normalizedConfig.name || 'project';
|
|
4046
|
-
const relativePath = relative(context.rootPath, testFile);
|
|
4047
|
-
const displayPath = `<${projectName}>/${relativePath}`;
|
|
4048
|
-
logger.log(
|
|
4049
|
-
color.cyan(
|
|
4050
|
-
`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
|
|
4051
|
-
),
|
|
4052
|
-
);
|
|
4053
|
-
await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
|
|
4054
|
-
},
|
|
4055
|
-
async getTestFiles() {
|
|
4056
|
-
return currentTestFiles;
|
|
4057
|
-
},
|
|
4058
|
-
async onRunnerFramesReady(testFiles: string[]) {
|
|
4059
|
-
markRunnerFramesReady(testFiles);
|
|
4060
|
-
},
|
|
4061
|
-
async onTestFileStart(payload: TestFileStartPayload) {
|
|
4062
|
-
await handleTestFileStart(payload);
|
|
4063
|
-
},
|
|
4064
|
-
async onTestCaseResult(payload: TestResult) {
|
|
4065
|
-
await handleTestCaseResult(payload);
|
|
4066
|
-
},
|
|
4067
|
-
async onTestFileComplete(payload: HeadedTestFileCompletePayload) {
|
|
4068
|
-
try {
|
|
4069
|
-
await handleTestFileComplete(payload);
|
|
4070
|
-
resolvePendingHeadedReload(payload.testPath, payload.runId);
|
|
4071
|
-
} catch (error) {
|
|
4072
|
-
rejectPendingHeadedReload(
|
|
4073
|
-
payload.testPath,
|
|
4074
|
-
toError(error),
|
|
4075
|
-
payload.runId,
|
|
4076
|
-
);
|
|
4077
|
-
throw error;
|
|
4078
|
-
}
|
|
4079
|
-
},
|
|
4080
|
-
async onLog(payload: LogPayload) {
|
|
4081
|
-
await handleLog(payload);
|
|
4082
|
-
},
|
|
4083
|
-
async onFatal(payload: FatalPayload) {
|
|
4084
|
-
const error = new Error(payload.message);
|
|
4085
|
-
error.stack = payload.stack;
|
|
4086
|
-
rejectAllPendingHeadedReloads(error);
|
|
4087
|
-
await handleFatal(payload);
|
|
4088
|
-
},
|
|
4089
|
-
async dispatch(request: BrowserDispatchRequest) {
|
|
4090
|
-
// Headed/container path now shares the same dispatch contract as headless.
|
|
4091
|
-
return dispatchRouter.dispatch(request);
|
|
4092
|
-
},
|
|
4093
|
-
});
|
|
4094
|
-
|
|
4095
|
-
// Setup RPC manager
|
|
4096
|
-
let rpcManager: ContainerRpcManager;
|
|
4097
|
-
|
|
4098
|
-
if (isWatchMode && runtime.rpcManager) {
|
|
4099
|
-
rpcManager = runtime.rpcManager;
|
|
4100
|
-
// Update methods with new test state (caseResults, completedTests, etc.)
|
|
4101
|
-
rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
|
|
4102
|
-
// Reattach if we have an existing WebSocket
|
|
4103
|
-
const existingWs = rpcManager.currentWebSocket;
|
|
4104
|
-
if (existingWs) {
|
|
4105
|
-
rpcManager.reattach(existingWs);
|
|
4106
|
-
}
|
|
4107
|
-
} else {
|
|
4108
|
-
rpcManager = new ContainerRpcManager(
|
|
4109
|
-
wss,
|
|
4110
|
-
createRpcMethods(),
|
|
4111
|
-
rejectAllPendingHeadedReloads,
|
|
4112
|
-
);
|
|
4113
|
-
|
|
4114
|
-
if (isWatchMode) {
|
|
4115
|
-
runtime.rpcManager = rpcManager;
|
|
4116
|
-
}
|
|
4117
|
-
}
|
|
4118
|
-
|
|
4119
|
-
// Only navigate on first creation
|
|
4120
|
-
if (isNewPage) {
|
|
4121
|
-
const pagePath = '/';
|
|
4122
|
-
const containerPort = runtime.containerServer.port;
|
|
4123
|
-
await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
|
|
4124
|
-
waitUntil: 'load',
|
|
4125
|
-
});
|
|
4126
|
-
|
|
4127
|
-
logger.log(
|
|
4128
|
-
color.cyan(
|
|
4129
|
-
`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
|
|
4130
|
-
),
|
|
4131
|
-
);
|
|
4132
|
-
}
|
|
4133
|
-
|
|
4134
|
-
enqueueHeadedReload = async (
|
|
4135
|
-
file: TestFileInfo,
|
|
4136
|
-
testNamePattern?: string,
|
|
4137
|
-
): Promise<void> => {
|
|
4138
|
-
return headedReloadQueue.enqueue(async () => {
|
|
4139
|
-
if (fatalError) {
|
|
4140
|
-
return;
|
|
4141
|
-
}
|
|
4142
|
-
await reloadTestFileAndWait(file, testNamePattern);
|
|
4143
|
-
});
|
|
4144
|
-
};
|
|
4145
|
-
|
|
4146
|
-
let testTime = 0;
|
|
4147
|
-
if (currentTestFiles.length > 0) {
|
|
4148
|
-
const testStart = Date.now();
|
|
4149
|
-
try {
|
|
4150
|
-
await waitForRunnerFramesReady(
|
|
4151
|
-
currentTestFiles.map((file) => file.testPath),
|
|
4152
|
-
);
|
|
4153
|
-
|
|
4154
|
-
for (const file of currentTestFiles) {
|
|
4155
|
-
await enqueueHeadedReload(file);
|
|
4156
|
-
if (fatalError) {
|
|
4157
|
-
break;
|
|
4158
|
-
}
|
|
4159
|
-
}
|
|
4160
|
-
} catch (error) {
|
|
4161
|
-
fatalError = fatalError ?? toError(error);
|
|
4162
|
-
// Non-watch: the fatal error rides the returned outcome and core owns the
|
|
4163
|
-
// exit code; watch reruns set it here.
|
|
4164
|
-
if (isWatchMode) {
|
|
4165
|
-
ensureProcessExitCode(1);
|
|
4166
|
-
}
|
|
4167
|
-
}
|
|
4168
|
-
|
|
4169
|
-
testTime = Date.now() - testStart;
|
|
4170
|
-
}
|
|
4171
|
-
|
|
4172
|
-
// Define rerun logic for watch mode
|
|
4173
|
-
if (isWatchMode) {
|
|
4174
|
-
triggerRerun = async () => {
|
|
4175
|
-
// Re-deliver the host config so runner iframes reloaded by this rerun
|
|
4176
|
-
// observe live per-rerun values ('u' flips updateSnapshot between
|
|
4177
|
-
// reruns); `setContainerOptions` keeps full container reloads in sync.
|
|
4178
|
-
const refreshedHostOptions: BrowserHostConfig = {
|
|
4179
|
-
...hostOptions,
|
|
4180
|
-
snapshot: {
|
|
4181
|
-
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
4182
|
-
},
|
|
4183
|
-
};
|
|
4184
|
-
runtime.setContainerOptions(refreshedHostOptions);
|
|
4185
|
-
// Independent: config push to the container vs. local entry collection.
|
|
4186
|
-
const [, newProjectEntries] = await Promise.all([
|
|
4187
|
-
rpcManager.updateHostConfig(refreshedHostOptions),
|
|
4188
|
-
collectProjectEntries(context),
|
|
4189
|
-
]);
|
|
4190
|
-
const rerunPlan = planWatchRerun({
|
|
4191
|
-
projectEntries: newProjectEntries,
|
|
4192
|
-
previousTestFiles: watchState.lastTestFiles,
|
|
4193
|
-
affectedTestFiles: drainPendingAffectedTestFiles(watchState),
|
|
4194
|
-
});
|
|
4195
|
-
|
|
4196
|
-
if (rerunPlan.filesChanged) {
|
|
4197
|
-
const deletedTestPaths = collectDeletedTestPaths(
|
|
4198
|
-
watchState.lastTestFiles,
|
|
4199
|
-
rerunPlan.currentTestFiles,
|
|
4200
|
-
);
|
|
4201
|
-
if (deletedTestPaths.length > 0) {
|
|
4202
|
-
context.updateReporterResultState([], [], deletedTestPaths);
|
|
4203
|
-
}
|
|
4204
|
-
watchState.lastTestFiles = rerunPlan.currentTestFiles;
|
|
4205
|
-
currentTestFiles = rerunPlan.currentTestFiles;
|
|
4206
|
-
await rpcManager.notifyTestFileUpdate(currentTestFiles);
|
|
4207
|
-
if (currentTestFiles.length === 0) {
|
|
4208
|
-
logger.log(
|
|
4209
|
-
color.cyan('No browser test files remain after update.\n'),
|
|
4210
|
-
);
|
|
4211
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
4212
|
-
return;
|
|
4213
|
-
}
|
|
4214
|
-
await waitForRunnerFramesReady(
|
|
4215
|
-
currentTestFiles.map((file) => file.testPath),
|
|
4216
|
-
);
|
|
4217
|
-
}
|
|
4218
|
-
|
|
4219
|
-
if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
|
|
4220
|
-
logger.log(
|
|
4221
|
-
color.cyan(
|
|
4222
|
-
`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
|
|
4223
|
-
),
|
|
4224
|
-
);
|
|
4225
|
-
// Match the headless path: reset per-cycle state before the rerun
|
|
4226
|
-
// streams new events, so bail counts and snapshot summaries do not
|
|
4227
|
-
// accumulate across headed reruns.
|
|
4228
|
-
prepareWatchRerunState(context);
|
|
4229
|
-
await notifyTestRunStart();
|
|
4230
|
-
|
|
4231
|
-
const rerunStartTime = Date.now();
|
|
4232
|
-
const fatalErrorBeforeRun = fatalError;
|
|
4233
|
-
let rerunError: Error | undefined;
|
|
4234
|
-
|
|
4235
|
-
try {
|
|
4236
|
-
for (const testFile of rerunPlan.normalizedAffectedTestFiles) {
|
|
4237
|
-
await enqueueHeadedReload(getTestFileInfo(testFile));
|
|
4238
|
-
}
|
|
4239
|
-
} catch (error) {
|
|
4240
|
-
rerunError = toError(error);
|
|
4241
|
-
throw error;
|
|
4242
|
-
} finally {
|
|
4243
|
-
const testTime = Math.max(0, Date.now() - rerunStartTime);
|
|
4244
|
-
const rerunFatalError =
|
|
4245
|
-
fatalError && fatalError !== fatalErrorBeforeRun
|
|
4246
|
-
? fatalError
|
|
4247
|
-
: undefined;
|
|
4248
|
-
await finalizeWatchRerun({
|
|
4249
|
-
rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
|
|
4250
|
-
testTime,
|
|
4251
|
-
unhandledErrors: rerunError
|
|
4252
|
-
? [rerunError]
|
|
4253
|
-
: rerunFatalError
|
|
4254
|
-
? [rerunFatalError]
|
|
4255
|
-
: undefined,
|
|
4256
|
-
});
|
|
4257
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
4258
|
-
}
|
|
4259
|
-
} else if (!rerunPlan.filesChanged) {
|
|
4260
|
-
logger.log(color.cyan('Tests will be re-executed automatically\n'));
|
|
4261
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
4262
|
-
} else {
|
|
4263
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
4264
|
-
}
|
|
4265
|
-
};
|
|
4266
|
-
}
|
|
4267
|
-
|
|
4268
|
-
const closeContainerRuntime = !isWatchMode
|
|
4269
|
-
? async () => {
|
|
4270
|
-
try {
|
|
4271
|
-
await containerPage.close();
|
|
4272
|
-
} catch {
|
|
4273
|
-
// ignore
|
|
4274
|
-
}
|
|
4275
|
-
try {
|
|
4276
|
-
await containerContext.close();
|
|
4277
|
-
} catch {
|
|
4278
|
-
// ignore
|
|
4279
|
-
}
|
|
4280
|
-
await destroyBrowserRuntime(runtime);
|
|
4281
|
-
}
|
|
4282
|
-
: undefined;
|
|
4283
|
-
|
|
4284
|
-
if (fatalError) {
|
|
4285
|
-
return failWithError(fatalError, closeContainerRuntime);
|
|
4286
|
-
}
|
|
4287
|
-
|
|
4288
|
-
const duration = {
|
|
4289
|
-
totalTime: buildTime + testTime,
|
|
4290
|
-
buildTime,
|
|
4291
|
-
testTime,
|
|
4292
|
-
};
|
|
4293
|
-
|
|
4294
|
-
context.updateReporterResultState(reporterResults, caseResults);
|
|
4295
|
-
|
|
4296
|
-
const isFailure = reporterResults.some(
|
|
4297
|
-
(result: TestFileResult) => result.status === 'fail',
|
|
4298
|
-
);
|
|
4299
|
-
// Non-watch runs let core's `finalizeRunCycle` own the exit code from the
|
|
4300
|
-
// returned outcome; watch reruns set it here.
|
|
4301
|
-
if (isWatchMode && isFailure) {
|
|
4302
|
-
ensureProcessExitCode(1);
|
|
4303
|
-
}
|
|
4304
|
-
|
|
4305
|
-
// Same per-cycle fold-and-strip as the headless path above.
|
|
4306
|
-
const cycleCoverageMap =
|
|
4307
|
-
isWatchMode && coverageProvider
|
|
4308
|
-
? buildBrowserCoverageMap(reporterResults, coverageProvider)
|
|
4309
|
-
: undefined;
|
|
4310
|
-
|
|
4311
|
-
const result = {
|
|
4312
|
-
results: reporterResults,
|
|
4313
|
-
testResults: caseResults,
|
|
4314
|
-
duration,
|
|
4315
|
-
hasFailure: isFailure,
|
|
4316
|
-
getSourcemap: getBrowserSourcemap,
|
|
4317
|
-
resolveSourcemap: resolveBrowserSourcemap,
|
|
4318
|
-
// `closeContainerRuntime` is already `undefined` in watch mode, so the
|
|
4319
|
-
// non-watch caller (core) receives the deferred close and watch does not.
|
|
4320
|
-
close: closeContainerRuntime,
|
|
4321
|
-
coverage: cycleCoverageMap,
|
|
4322
|
-
watch: watchHandles,
|
|
4323
|
-
};
|
|
4324
|
-
|
|
4325
|
-
if (isWatchMode) {
|
|
4326
|
-
try {
|
|
4327
|
-
await notifyTestRunEnd({
|
|
4328
|
-
duration,
|
|
4329
|
-
coverage: cycleCoverageMap?.files().length
|
|
4330
|
-
? cycleCoverageMap.toJSON()
|
|
4331
|
-
: undefined,
|
|
4332
|
-
});
|
|
4333
|
-
} finally {
|
|
4334
|
-
await closeContainerRuntime?.();
|
|
4335
|
-
}
|
|
4336
|
-
}
|
|
4337
|
-
|
|
4338
|
-
// Enable watch hooks AFTER initial test run to avoid duplicate runs
|
|
4339
|
-
if (isWatchMode && triggerRerun) {
|
|
4340
|
-
watchState.hooksEnabled = true;
|
|
4341
|
-
logWatchReadyMessage(context, enableCliShortcuts);
|
|
4342
|
-
}
|
|
4343
|
-
|
|
4344
|
-
return result;
|
|
4345
|
-
};
|
|
4346
|
-
|
|
4347
|
-
// ============================================================================
|
|
4348
|
-
// List Browser Tests
|
|
4349
|
-
// ============================================================================
|
|
4350
|
-
|
|
4351
|
-
/**
|
|
4352
|
-
* Result from collecting browser tests.
|
|
4353
|
-
* This is the return type for listBrowserTests, designed for future extraction
|
|
4354
|
-
* to a separate browser package.
|
|
4355
|
-
*/
|
|
4356
|
-
export type ListBrowserTestsResult = {
|
|
4357
|
-
list: ListCommandResult[];
|
|
4358
|
-
close: () => Promise<void>;
|
|
4359
|
-
};
|
|
4360
|
-
|
|
4361
|
-
/**
|
|
4362
|
-
* Collect test metadata from browser mode projects without running them.
|
|
4363
|
-
* This function creates a headless browser runtime, loads test files,
|
|
4364
|
-
* and collects their test structure (describe/test declarations).
|
|
4365
|
-
*/
|
|
4366
|
-
export const listBrowserTests = async (
|
|
4367
|
-
context: RstestContext,
|
|
4368
|
-
options?: ListBrowserTestsOptions,
|
|
4369
|
-
): Promise<ListBrowserTestsResult> => {
|
|
4370
|
-
const browserProjects = options?.projects ?? getBrowserProjects(context);
|
|
4371
|
-
const projectEntries = await resolveProjectEntries(
|
|
4372
|
-
context,
|
|
4373
|
-
options?.shardedEntries,
|
|
4374
|
-
browserProjects,
|
|
4375
|
-
);
|
|
4376
|
-
const totalTests = projectEntries.reduce(
|
|
4377
|
-
(total, item) => total + item.testFiles.length,
|
|
4378
|
-
0,
|
|
4379
|
-
);
|
|
4380
|
-
|
|
4381
|
-
if (totalTests === 0 && !hasUserRstestConfigPlugins(browserProjects)) {
|
|
4382
|
-
return {
|
|
4383
|
-
list: [],
|
|
4384
|
-
close: async () => {},
|
|
4385
|
-
};
|
|
4386
|
-
}
|
|
4387
|
-
|
|
4388
|
-
const tempDir = join(
|
|
4389
|
-
context.rootPath,
|
|
4390
|
-
context.normalizedConfig.output.distPath.root,
|
|
4391
|
-
'browser',
|
|
4392
|
-
`list-${Date.now()}`,
|
|
4393
|
-
);
|
|
4394
|
-
|
|
4395
|
-
// Create a simplified browser runtime for collect mode
|
|
4396
|
-
let runtime: BrowserRuntime;
|
|
4397
|
-
try {
|
|
4398
|
-
runtime = await createBrowserRuntime({
|
|
4399
|
-
context,
|
|
4400
|
-
projectEntries,
|
|
4401
|
-
browserProjects,
|
|
4402
|
-
shardedEntries: options?.shardedEntries,
|
|
4403
|
-
freezeShardedEntries: options?.freezeShardedEntries,
|
|
4404
|
-
tempDir,
|
|
4405
|
-
isWatchMode: false,
|
|
4406
|
-
containerDistPath: undefined,
|
|
4407
|
-
containerDevServer: undefined,
|
|
4408
|
-
forceHeadless: true, // Always use headless for list command
|
|
4409
|
-
skipProviderLaunch: options?.filesOnly,
|
|
4410
|
-
appliedModifyRstestConfigEnvironments:
|
|
4411
|
-
options?.appliedModifyRstestConfigEnvironments,
|
|
4412
|
-
});
|
|
4413
|
-
} catch (error) {
|
|
4414
|
-
const providers = [
|
|
4415
|
-
...new Set(
|
|
4416
|
-
browserProjects.map((p) => p.normalizedConfig.browser.provider),
|
|
4417
|
-
),
|
|
4418
|
-
];
|
|
4419
|
-
logger.error(
|
|
4420
|
-
color.red(
|
|
4421
|
-
`Failed to initialize browser provider runtime (${providers.join(', ')}).`,
|
|
4422
|
-
),
|
|
4423
|
-
error,
|
|
4424
|
-
);
|
|
4425
|
-
throw error;
|
|
4426
|
-
}
|
|
4427
|
-
|
|
4428
|
-
if (options?.filesOnly) {
|
|
4429
|
-
const list = runtime.projectEntries.flatMap((entry) =>
|
|
4430
|
-
entry.testFiles.map((testPath) => ({
|
|
4431
|
-
testPath,
|
|
4432
|
-
project: entry.project.name,
|
|
4433
|
-
tests: [],
|
|
4434
|
-
})),
|
|
4435
|
-
);
|
|
4436
|
-
await destroyBrowserRuntime(runtime);
|
|
4437
|
-
return {
|
|
4438
|
-
list,
|
|
4439
|
-
close: async () => {},
|
|
4440
|
-
};
|
|
4441
|
-
}
|
|
4442
|
-
|
|
4443
|
-
if (!runtime.projectEntries.some((entry) => entry.testFiles.length > 0)) {
|
|
4444
|
-
await destroyBrowserRuntime(runtime);
|
|
4445
|
-
return {
|
|
4446
|
-
list: [],
|
|
4447
|
-
close: async () => {},
|
|
4448
|
-
};
|
|
4449
|
-
}
|
|
4450
|
-
|
|
4451
|
-
const { browser, browserLaunchOptions } = runtime;
|
|
4452
|
-
|
|
4453
|
-
// Get browser projects for runtime config
|
|
4454
|
-
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
4455
|
-
const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
|
|
4456
|
-
(project: ProjectContext) => ({
|
|
4457
|
-
name: project.name,
|
|
4458
|
-
environmentName: project.environmentName,
|
|
4459
|
-
projectRoot: normalize(project.rootPath),
|
|
4460
|
-
runtimeConfig: serializableConfig(
|
|
4461
|
-
projectRuntimeConfig(project, { envMode: 'static' }),
|
|
4462
|
-
),
|
|
4463
|
-
viewport: project.normalizedConfig.browser.viewport,
|
|
4464
|
-
}),
|
|
4465
|
-
);
|
|
4466
|
-
|
|
4467
|
-
const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
|
|
4468
|
-
|
|
4469
|
-
const hostOptions: BrowserHostConfig = {
|
|
4470
|
-
rootPath: normalize(context.rootPath),
|
|
4471
|
-
projects: projectRuntimeConfigs,
|
|
4472
|
-
snapshot: {
|
|
4473
|
-
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
4474
|
-
},
|
|
4475
|
-
mode: 'collect', // Use collect mode
|
|
4476
|
-
debug: isDebug(),
|
|
4477
|
-
rpcTimeout: maxTestTimeoutForRpc,
|
|
4478
|
-
};
|
|
4479
|
-
|
|
4480
|
-
runtime.setContainerOptions(hostOptions);
|
|
4481
|
-
|
|
4482
|
-
// Collect results across every project's isolated dev server. Each server
|
|
4483
|
-
// serves only its own project's manifest, so collection navigates one page
|
|
4484
|
-
// per project; each page returns its own results, aggregated afterwards.
|
|
4485
|
-
const browserContext = await browser.newContext({
|
|
4486
|
-
providerOptions: browserLaunchOptions.providerOptions,
|
|
4487
|
-
viewport: null,
|
|
4488
|
-
});
|
|
4489
|
-
|
|
4490
|
-
const serializedOptions = serializeForInlineScript(hostOptions);
|
|
4491
|
-
|
|
4492
|
-
// Per-page collect watchdog: a test file whose module evaluation stalls must
|
|
4493
|
-
// not hang `rstest list` forever.
|
|
4494
|
-
const collectTimeoutMs = 30_000;
|
|
4495
|
-
|
|
4496
|
-
const collectFromServer = async (
|
|
4497
|
-
server: BrowserProjectServer,
|
|
4498
|
-
): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
|
|
4499
|
-
const results: ListCommandResult[] = [];
|
|
4500
|
-
let error: Error | null = null;
|
|
4501
|
-
let collectCompleted = false;
|
|
4502
|
-
let resolveCollect: (() => void) | undefined;
|
|
4503
|
-
const collectPromise = new Promise<void>((resolve) => {
|
|
4504
|
-
resolveCollect = resolve;
|
|
4505
|
-
});
|
|
4506
|
-
|
|
4507
|
-
const page = await browserContext.newPage();
|
|
4508
|
-
|
|
4509
|
-
// Expose dispatch function for browser client to send messages
|
|
4510
|
-
await page.exposeFunction(
|
|
4511
|
-
DISPATCH_MESSAGE_TYPE,
|
|
4512
|
-
(message: { type: string; payload?: unknown }) => {
|
|
4513
|
-
switch (message.type) {
|
|
4514
|
-
case 'collect-result': {
|
|
4515
|
-
const payload = message.payload as {
|
|
4516
|
-
testPath: string;
|
|
4517
|
-
project: string;
|
|
4518
|
-
tests: Test[];
|
|
4519
|
-
};
|
|
4520
|
-
results.push({
|
|
4521
|
-
testPath: payload.testPath,
|
|
4522
|
-
project: payload.project,
|
|
4523
|
-
tests: payload.tests,
|
|
4524
|
-
});
|
|
4525
|
-
break;
|
|
4526
|
-
}
|
|
4527
|
-
case 'collect-complete':
|
|
4528
|
-
collectCompleted = true;
|
|
4529
|
-
resolveCollect?.();
|
|
4530
|
-
break;
|
|
4531
|
-
case 'fatal': {
|
|
4532
|
-
const payload = message.payload as {
|
|
4533
|
-
message: string;
|
|
4534
|
-
stack?: string;
|
|
4535
|
-
};
|
|
4536
|
-
error = new Error(payload.message);
|
|
4537
|
-
error.stack = payload.stack;
|
|
4538
|
-
resolveCollect?.();
|
|
4539
|
-
break;
|
|
4540
|
-
}
|
|
4541
|
-
case 'ready':
|
|
4542
|
-
case 'log':
|
|
4543
|
-
// Ignore these messages during collection
|
|
4544
|
-
break;
|
|
4545
|
-
default:
|
|
4546
|
-
// Log unexpected messages for debugging
|
|
4547
|
-
logger.debug(`[List] Unexpected message: ${message.type}`);
|
|
4548
|
-
}
|
|
4549
|
-
},
|
|
4550
|
-
);
|
|
4551
|
-
|
|
4552
|
-
// Inject host options before navigation so the runner can access them
|
|
4553
|
-
await page.addInitScript(
|
|
4554
|
-
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
4555
|
-
);
|
|
4556
|
-
|
|
4557
|
-
// Navigate to this project's runner page
|
|
4558
|
-
await page.goto(`http://localhost:${server.port}/runner.html`, {
|
|
4559
|
-
waitUntil: 'load',
|
|
4560
|
-
});
|
|
4561
|
-
|
|
4562
|
-
// Wait for collection to complete with the shared collect timeout.
|
|
4563
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
4564
|
-
const timeoutPromise = new Promise<void>((resolve) => {
|
|
4565
|
-
timeoutId = setTimeout(() => {
|
|
4566
|
-
if (!collectCompleted) {
|
|
4567
|
-
logger.warn(
|
|
4568
|
-
color.yellow(
|
|
4569
|
-
`[List] Browser test collection timed out after ${collectTimeoutMs}ms`,
|
|
4570
|
-
),
|
|
4571
|
-
);
|
|
4572
|
-
}
|
|
4573
|
-
resolve();
|
|
4574
|
-
}, collectTimeoutMs);
|
|
4575
|
-
});
|
|
4576
|
-
|
|
4577
|
-
await Promise.race([collectPromise, timeoutPromise]);
|
|
4578
|
-
|
|
4579
|
-
// Clear timeout to prevent Node.js from waiting for it
|
|
4580
|
-
if (timeoutId) {
|
|
4581
|
-
clearTimeout(timeoutId);
|
|
4582
|
-
}
|
|
4583
|
-
|
|
4584
|
-
await page.close().catch(() => {});
|
|
4585
|
-
return { results, error };
|
|
4586
|
-
};
|
|
4587
|
-
|
|
4588
|
-
// Collect every project concurrently — each navigates its own page against
|
|
4589
|
-
// its own dev server and returns its own results.
|
|
4590
|
-
const collected = await Promise.all(
|
|
4591
|
-
[...runtime.projectServers.values()].map((server) =>
|
|
4592
|
-
collectFromServer(server),
|
|
4593
|
-
),
|
|
4594
|
-
);
|
|
4595
|
-
const collectResults = collected.flatMap((entry) => entry.results);
|
|
4596
|
-
const fatalError = collected.find((entry) => entry.error)?.error ?? null;
|
|
4597
|
-
|
|
4598
|
-
// Cleanup
|
|
4599
|
-
const cleanup = async () => {
|
|
4600
|
-
try {
|
|
4601
|
-
await browserContext.close();
|
|
4602
|
-
} catch {
|
|
4603
|
-
// ignore
|
|
4604
|
-
}
|
|
4605
|
-
await destroyBrowserRuntime(runtime);
|
|
4606
|
-
};
|
|
4607
|
-
|
|
4608
|
-
if (fatalError) {
|
|
4609
|
-
await cleanup();
|
|
4610
|
-
// Return error in the result format instead of throwing
|
|
4611
|
-
const errorResult: ListCommandResult = {
|
|
4612
|
-
testPath: '',
|
|
4613
|
-
project: '',
|
|
4614
|
-
tests: [],
|
|
4615
|
-
errors: [
|
|
4616
|
-
{
|
|
4617
|
-
name: 'BrowserCollectError',
|
|
4618
|
-
message: fatalError.message,
|
|
4619
|
-
stack: fatalError.stack,
|
|
4620
|
-
} as FormattedError,
|
|
4621
|
-
],
|
|
4622
|
-
};
|
|
4623
|
-
return {
|
|
4624
|
-
list: [errorResult],
|
|
4625
|
-
close: async () => {},
|
|
4626
|
-
};
|
|
4627
|
-
}
|
|
4628
|
-
|
|
4629
|
-
return {
|
|
4630
|
-
list: collectResults,
|
|
4631
|
-
close: cleanup,
|
|
4632
|
-
};
|
|
4633
|
-
};
|