@rstest/browser 0.11.4 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-container/container-static/js/683.4f821ce8f1.js +194 -0
- package/dist/browser-container/container-static/js/index.568aa7cb35.js +1 -0
- package/dist/browser-container/container-static/js/lib-react.e24a1d366b.js +2 -0
- package/dist/browser-container/index.html +1 -1
- package/dist/browserExecutor.d.ts +7 -5
- package/dist/browserRsbuild.d.ts +119 -0
- package/dist/containerRpc.d.ts +52 -0
- package/dist/dispatchCapabilities.d.ts +1 -2
- package/dist/headedScheduler.d.ts +58 -0
- package/dist/headlessScheduler.d.ts +37 -0
- package/dist/hostController.d.ts +12 -47
- package/dist/hostPayloads.d.ts +30 -0
- package/dist/index.js +1738 -1793
- package/dist/protocol.d.ts +5 -0
- package/dist/schedulerSeam.d.ts +37 -0
- package/dist/watchRerunPlanner.d.ts +5 -0
- package/dist/watchRuntime.d.ts +21 -0
- package/dist/watchSignals.d.ts +22 -0
- package/package.json +5 -5
- package/src/browserExecutor.ts +90 -8
- package/src/browserRsbuild.ts +1927 -0
- package/src/client/entry.ts +3 -0
- package/src/containerRpc.ts +206 -0
- package/src/dispatchCapabilities.ts +1 -6
- package/src/headedScheduler.ts +664 -0
- package/src/headlessScheduler.ts +566 -0
- package/src/hostController.ts +855 -4163
- package/src/hostPayloads.ts +62 -0
- package/src/protocol.ts +6 -0
- package/src/schedulerSeam.ts +49 -0
- package/src/watchRerunPlanner.ts +18 -7
- package/src/watchRuntime.ts +83 -0
- package/src/watchSignals.ts +93 -0
- package/dist/browser-container/container-static/js/243.a8eed2b9e7.js +0 -27406
- package/dist/browser-container/container-static/js/243.a8eed2b9e7.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/index.84aaafaf21.js +0 -3058
- package/dist/browser-container/container-static/js/lib-react.62b27a21db.js +0 -8454
- package/dist/browser-container/container-static/js/lib-react.62b27a21db.js.LICENSE.txt +0 -1
- package/dist/headlessLatestRerunScheduler.d.ts +0 -18
- package/src/headlessLatestRerunScheduler.ts +0 -76
|
@@ -0,0 +1,1927 @@
|
|
|
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
|
+
color,
|
|
12
|
+
type EntryHashSnapshot,
|
|
13
|
+
getSetupFiles,
|
|
14
|
+
getTestEntries,
|
|
15
|
+
importMetaRstestDefine,
|
|
16
|
+
initModifyRstestConfigHooks,
|
|
17
|
+
isDebug,
|
|
18
|
+
logger,
|
|
19
|
+
loadCoverageProvider,
|
|
20
|
+
pluginMockRuntime,
|
|
21
|
+
type ProjectContext,
|
|
22
|
+
resolveProjectBuildCache,
|
|
23
|
+
resolveShardedEntries,
|
|
24
|
+
RSTEST_ENV_SYMBOL_KEY,
|
|
25
|
+
type RstestContext,
|
|
26
|
+
rsbuild,
|
|
27
|
+
type WatchInvalidationState,
|
|
28
|
+
} from '@rstest/core/internal/browser';
|
|
29
|
+
import openEditor from 'open-editor';
|
|
30
|
+
import { dirname, join, normalize, relative, resolve } from 'pathe';
|
|
31
|
+
import picomatch from 'picomatch';
|
|
32
|
+
import sirv from 'sirv';
|
|
33
|
+
import { WebSocketServer } from 'ws';
|
|
34
|
+
import { validateBrowserConfig } from './configValidation';
|
|
35
|
+
import type { ContainerRpcManager } from './containerRpc';
|
|
36
|
+
import type {
|
|
37
|
+
BrowserDispatchHandler,
|
|
38
|
+
BrowserHostConfig,
|
|
39
|
+
BrowserProjectRuntime,
|
|
40
|
+
BrowserViewport,
|
|
41
|
+
TestFileInfo,
|
|
42
|
+
} from './protocol';
|
|
43
|
+
import type {
|
|
44
|
+
BrowserProvider,
|
|
45
|
+
BrowserProviderBrowser,
|
|
46
|
+
BrowserProviderContext,
|
|
47
|
+
BrowserProviderPage,
|
|
48
|
+
} from './providers';
|
|
49
|
+
import { getBrowserProviderImplementation } from './providers';
|
|
50
|
+
import { resolveBrowserViewportPreset } from './viewportPresets';
|
|
51
|
+
import { collectWatchTestFiles } from './watchRerunPlanner';
|
|
52
|
+
|
|
53
|
+
const { createRsbuild, rspack } = rsbuild;
|
|
54
|
+
type RsbuildDevServer = rsbuild.RsbuildDevServer;
|
|
55
|
+
type RsbuildInstance = rsbuild.RsbuildInstance;
|
|
56
|
+
type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
|
|
57
|
+
Pick<rsbuild.RsbuildConfig, 'root'>;
|
|
58
|
+
|
|
59
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
|
|
61
|
+
|
|
62
|
+
export const serializeForInlineScript = (value: unknown): string => {
|
|
63
|
+
return JSON.stringify(value)
|
|
64
|
+
.replace(/</g, '\\u003c')
|
|
65
|
+
.replace(/\u2028/g, '\\u2028')
|
|
66
|
+
.replace(/\u2029/g, '\\u2029');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Type Definitions
|
|
71
|
+
|
|
72
|
+
type BrowserProjectEntries = {
|
|
73
|
+
project: ProjectContext;
|
|
74
|
+
setupFiles: string[];
|
|
75
|
+
testFiles: string[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type BrowserProviderProject = {
|
|
79
|
+
rootPath: string;
|
|
80
|
+
provider: BrowserProvider;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type BrowserLaunchOptions = {
|
|
84
|
+
provider: BrowserProvider;
|
|
85
|
+
browser: ProjectContext['normalizedConfig']['browser']['browser'];
|
|
86
|
+
headless: ProjectContext['normalizedConfig']['browser']['headless'];
|
|
87
|
+
port: ProjectContext['normalizedConfig']['browser']['port'];
|
|
88
|
+
strictPort: ProjectContext['normalizedConfig']['browser']['strictPort'];
|
|
89
|
+
providerOptions: Record<string, unknown>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const getBrowserProviderOptions = (
|
|
93
|
+
project: ProjectContext,
|
|
94
|
+
): Record<string, unknown> => {
|
|
95
|
+
const browserConfig = project.normalizedConfig.browser as {
|
|
96
|
+
providerOptions?: Record<string, unknown>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return browserConfig.providerOptions ?? {};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type BrowserProjectServer = {
|
|
103
|
+
projectName: string;
|
|
104
|
+
environmentName: string;
|
|
105
|
+
rsbuildInstance: RsbuildInstance;
|
|
106
|
+
devServer: RsbuildDevServer;
|
|
107
|
+
port: number;
|
|
108
|
+
manifestPath: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Watch diff/rerun state. Lives on the BrowserRuntime (one per set of
|
|
112
|
+
// per-project compilers, surviving controller re-entry that reuses the
|
|
113
|
+
// runtime) instead of module scope, so its lifetime always matches the
|
|
114
|
+
// compilers whose baselines it holds.
|
|
115
|
+
type BrowserWatchState = {
|
|
116
|
+
lastTestFiles: TestFileInfo[];
|
|
117
|
+
hooksEnabled: boolean;
|
|
118
|
+
// Diff baselines keyed per project: sibling projects have isolated
|
|
119
|
+
// compilers, so a shared flat baseline would let one project's compile
|
|
120
|
+
// clobber another's (missed reruns) and collide on compiler-local chunk
|
|
121
|
+
// keys.
|
|
122
|
+
invalidation: Map<string, WatchInvalidationState>;
|
|
123
|
+
// Affected files accumulated per project until a rerun drains them, so a
|
|
124
|
+
// compile finishing while another project's rerun is being planned cannot
|
|
125
|
+
// drop pending work.
|
|
126
|
+
pendingAffectedTestFiles: Map<string, Set<string>>;
|
|
127
|
+
// Per-project compile start times and the accumulated compile duration of
|
|
128
|
+
// the pending rerun, so the rerun's finalize reports the real buildTime.
|
|
129
|
+
compileStartTimes: Map<string, number>;
|
|
130
|
+
pendingBuildTimeMs: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const createBrowserWatchState = (): BrowserWatchState => ({
|
|
134
|
+
lastTestFiles: [],
|
|
135
|
+
hooksEnabled: false,
|
|
136
|
+
invalidation: new Map(),
|
|
137
|
+
pendingAffectedTestFiles: new Map(),
|
|
138
|
+
compileStartTimes: new Map(),
|
|
139
|
+
pendingBuildTimeMs: 0,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const drainPendingBuildTime = (
|
|
143
|
+
watchState: BrowserWatchState,
|
|
144
|
+
): number => {
|
|
145
|
+
const buildTime = watchState.pendingBuildTimeMs;
|
|
146
|
+
watchState.pendingBuildTimeMs = 0;
|
|
147
|
+
return buildTime;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const drainPendingAffectedTestFiles = (
|
|
151
|
+
watchState: BrowserWatchState,
|
|
152
|
+
): string[] => {
|
|
153
|
+
const affected = new Set<string>();
|
|
154
|
+
for (const files of watchState.pendingAffectedTestFiles.values()) {
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
affected.add(file);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
watchState.pendingAffectedTestFiles.clear();
|
|
160
|
+
return Array.from(affected);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type BrowserRuntime = {
|
|
164
|
+
// Per-project servers, keyed by project name.
|
|
165
|
+
projectServers: Map<string, BrowserProjectServer>;
|
|
166
|
+
// The server that hosts the container UI HTML (headed mode). The WebSocket
|
|
167
|
+
// server below is shared and reachable from any origin.
|
|
168
|
+
containerServer: BrowserProjectServer;
|
|
169
|
+
browser: BrowserProviderBrowser;
|
|
170
|
+
browserLaunchOptions: BrowserLaunchOptions;
|
|
171
|
+
wsPort: number;
|
|
172
|
+
tempDir: string;
|
|
173
|
+
containerPage?: BrowserProviderPage;
|
|
174
|
+
containerContext?: BrowserProviderContext;
|
|
175
|
+
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
176
|
+
// Reserved extension seam for host-side dispatch capabilities.
|
|
177
|
+
dispatchHandlers: Map<string, BrowserDispatchHandler>;
|
|
178
|
+
wss: WebSocketServer;
|
|
179
|
+
rpcManager?: ContainerRpcManager;
|
|
180
|
+
projectEntries: BrowserProjectEntries[];
|
|
181
|
+
watchState: BrowserWatchState;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const resolveViewport = (
|
|
185
|
+
viewport: BrowserViewport | undefined,
|
|
186
|
+
): { width: number; height: number } | null => {
|
|
187
|
+
if (!viewport) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof viewport === 'string') {
|
|
192
|
+
return resolveBrowserViewportPreset(viewport);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
typeof viewport.width === 'number' &&
|
|
197
|
+
Number.isFinite(viewport.width) &&
|
|
198
|
+
viewport.width > 0 &&
|
|
199
|
+
typeof viewport.height === 'number' &&
|
|
200
|
+
Number.isFinite(viewport.height) &&
|
|
201
|
+
viewport.height > 0
|
|
202
|
+
) {
|
|
203
|
+
return {
|
|
204
|
+
width: viewport.width,
|
|
205
|
+
height: viewport.height,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const mapViewportByProject = (
|
|
213
|
+
projects: BrowserProjectRuntime[],
|
|
214
|
+
): Map<string, { width: number; height: number }> => {
|
|
215
|
+
const map = new Map<string, { width: number; height: number }>();
|
|
216
|
+
for (const project of projects) {
|
|
217
|
+
const viewport = resolveViewport(project.viewport);
|
|
218
|
+
if (viewport) {
|
|
219
|
+
map.set(project.name, viewport);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return map;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const castArray = <T>(arr?: T | T[]): T[] => {
|
|
226
|
+
if (arr === undefined) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
return Array.isArray(arr) ? arr : [arr];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const applyDefaultWatchOptions = (
|
|
233
|
+
rspackConfig: Rspack.Configuration,
|
|
234
|
+
isWatchMode: boolean,
|
|
235
|
+
) => {
|
|
236
|
+
rspackConfig.watchOptions ??= {};
|
|
237
|
+
|
|
238
|
+
if (!isWatchMode) {
|
|
239
|
+
rspackConfig.watchOptions.ignored = '**/**';
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
rspackConfig.watchOptions.ignored = castArray(
|
|
244
|
+
rspackConfig.watchOptions.ignored || [],
|
|
245
|
+
) as string[];
|
|
246
|
+
|
|
247
|
+
if (rspackConfig.watchOptions.ignored.length === 0) {
|
|
248
|
+
rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (rspackConfig.output?.path) {
|
|
252
|
+
rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
type LazyCompilationModule = {
|
|
257
|
+
nameForCondition?: () => string | null | undefined;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
type BrowserLazyCompilationConfig = {
|
|
261
|
+
imports: true;
|
|
262
|
+
entries: false;
|
|
263
|
+
test?: (module: LazyCompilationModule) => boolean;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolve the actual port the dev server is listening on.
|
|
268
|
+
*
|
|
269
|
+
* Rsbuild's `devServer.listen()` may return `0` when configured with
|
|
270
|
+
* `server.port: 0` because its internal `getPort` never reads back the
|
|
271
|
+
* OS-assigned ephemeral port. This helper falls back to
|
|
272
|
+
* `httpServer.address()` to obtain the real bound port.
|
|
273
|
+
*/
|
|
274
|
+
const resolveListenPort = (
|
|
275
|
+
listenPort: number,
|
|
276
|
+
httpServer: {
|
|
277
|
+
address: () => ReturnType<import('node:net').Server['address']>;
|
|
278
|
+
} | null,
|
|
279
|
+
): number => {
|
|
280
|
+
if (listenPort) {
|
|
281
|
+
return listenPort;
|
|
282
|
+
}
|
|
283
|
+
const addr = httpServer?.address();
|
|
284
|
+
if (addr && typeof addr === 'object') {
|
|
285
|
+
return addr.port;
|
|
286
|
+
}
|
|
287
|
+
return listenPort;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const createBrowserLazyCompilationConfig = (
|
|
291
|
+
setupFiles: string[],
|
|
292
|
+
): BrowserLazyCompilationConfig => {
|
|
293
|
+
const eagerSetupFiles = new Set(
|
|
294
|
+
setupFiles.map((filePath) => normalize(filePath)),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (eagerSetupFiles.size === 0) {
|
|
298
|
+
return {
|
|
299
|
+
imports: true,
|
|
300
|
+
entries: false,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
imports: true,
|
|
306
|
+
entries: false,
|
|
307
|
+
test(module: LazyCompilationModule) {
|
|
308
|
+
const filePath = module.nameForCondition?.();
|
|
309
|
+
return !filePath || !eagerSetupFiles.has(normalize(filePath));
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* HMR — and the lazyCompilation transport it carries — is wired only for headed
|
|
316
|
+
* watch, the sole path that reuses a persistent page and applies module updates
|
|
317
|
+
* in place. Headless always loads each test file in a fresh page (pulling the
|
|
318
|
+
* latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
|
|
319
|
+
* so pushing HMR updates there is dead weight that only races factory
|
|
320
|
+
* registration for chunk-split node_modules (rspack#11922) and lets
|
|
321
|
+
* lazyCompilation's accept-chain walk abort the next spec when no boundary
|
|
322
|
+
* exists (#1472). Disabling HMR does not make watch rebuilds any less
|
|
323
|
+
* incremental — HMR is only the client push transport.
|
|
324
|
+
*/
|
|
325
|
+
const shouldEnableBrowserHmr = (
|
|
326
|
+
isWatchMode: boolean,
|
|
327
|
+
isHeadless: boolean,
|
|
328
|
+
): boolean => isWatchMode && !isHeadless;
|
|
329
|
+
|
|
330
|
+
const createBrowserRsbuildDevConfig = (
|
|
331
|
+
enableHmr: boolean,
|
|
332
|
+
): {
|
|
333
|
+
writeToDisk: boolean;
|
|
334
|
+
hmr: boolean;
|
|
335
|
+
client: {
|
|
336
|
+
logLevel: 'error';
|
|
337
|
+
};
|
|
338
|
+
} => {
|
|
339
|
+
return {
|
|
340
|
+
writeToDisk: isDebug(),
|
|
341
|
+
// `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
|
|
342
|
+
// path that reuses a page. See that helper for why fresh-page runs (headless,
|
|
343
|
+
// or any one-shot) must not receive HMR pushes.
|
|
344
|
+
hmr: enableHmr,
|
|
345
|
+
client: {
|
|
346
|
+
logLevel: 'error' as const,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Convert a single glob pattern to RegExp using picomatch
|
|
353
|
+
* Based on Storybook's implementation
|
|
354
|
+
*/
|
|
355
|
+
const globToRegexp = (glob: string): RegExp => {
|
|
356
|
+
const regex = picomatch.makeRe(glob, {
|
|
357
|
+
fastpaths: false,
|
|
358
|
+
noglobstar: false,
|
|
359
|
+
bash: false,
|
|
360
|
+
dot: true,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (!regex) {
|
|
364
|
+
throw new Error(`Invalid glob pattern: ${glob}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// picomatch generates regex starting with ^
|
|
368
|
+
// For patterns starting with ./, we need special handling
|
|
369
|
+
if (!glob.startsWith('./')) {
|
|
370
|
+
return regex;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// makeRe is sort of funny. If you pass it a directory starting with `./` it
|
|
374
|
+
// creates a matcher that expects files with no prefix (e.g. `src/file.js`)
|
|
375
|
+
// but if you pass it a directory that starts with `../` it expects files that
|
|
376
|
+
// start with `../`. Let's make it consistent.
|
|
377
|
+
// Globs starting `**` need special treatment due to the regex they produce
|
|
378
|
+
return new RegExp(
|
|
379
|
+
[
|
|
380
|
+
'^\\.',
|
|
381
|
+
glob.startsWith('./**') ? '' : '[\\\\/]',
|
|
382
|
+
regex.source.substring(1),
|
|
383
|
+
].join(''),
|
|
384
|
+
);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Convert rstest include glob patterns to RegExp for import.meta.webpackContext
|
|
389
|
+
* Uses picomatch for robust glob-to-regexp conversion
|
|
390
|
+
*/
|
|
391
|
+
const globPatternsToRegExp = (patterns: string[]): RegExp => {
|
|
392
|
+
const regexParts = patterns.map((pattern) => {
|
|
393
|
+
const regex = globToRegexp(pattern);
|
|
394
|
+
// Remove ^ anchor and $ anchor to allow combining patterns
|
|
395
|
+
let source = regex.source;
|
|
396
|
+
if (source.startsWith('^')) {
|
|
397
|
+
source = source.substring(1);
|
|
398
|
+
}
|
|
399
|
+
if (source.endsWith('$')) {
|
|
400
|
+
source = source.substring(0, source.length - 1);
|
|
401
|
+
}
|
|
402
|
+
return source;
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return new RegExp(`(?:${regexParts.join('|')})$`);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
|
|
409
|
+
const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
|
|
410
|
+
const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
|
|
411
|
+
|
|
412
|
+
const escapeRegExp = (value: string): string =>
|
|
413
|
+
value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
|
|
414
|
+
|
|
415
|
+
const normalizePathForRegExp = (value: string): string =>
|
|
416
|
+
normalize(value).replaceAll('\\', '/');
|
|
417
|
+
|
|
418
|
+
const normalizeExcludePatternForRegExp = (value: string): string =>
|
|
419
|
+
value.startsWith('./')
|
|
420
|
+
? `./${normalizePathForRegExp(value.substring(2))}`
|
|
421
|
+
: normalizePathForRegExp(value);
|
|
422
|
+
|
|
423
|
+
const isAbsolutePatternForRegExp = (value: string): boolean =>
|
|
424
|
+
value.startsWith('/') || /^[A-Za-z]:\//.test(value);
|
|
425
|
+
|
|
426
|
+
const isEscapedRegExpCharacter = (source: string, index: number): boolean => {
|
|
427
|
+
let backslashCount = 0;
|
|
428
|
+
for (
|
|
429
|
+
let current = index - 1;
|
|
430
|
+
current >= 0 && source[current] === '\\';
|
|
431
|
+
current--
|
|
432
|
+
) {
|
|
433
|
+
backslashCount++;
|
|
434
|
+
}
|
|
435
|
+
return backslashCount % 2 === 1;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const replacePathSeparatorsInRegExpSource = (source: string): string => {
|
|
439
|
+
let result = '';
|
|
440
|
+
let inCharacterClass = false;
|
|
441
|
+
|
|
442
|
+
for (let index = 0; index < source.length; index++) {
|
|
443
|
+
const character = source[index];
|
|
444
|
+
const isEscaped = isEscapedRegExpCharacter(source, index);
|
|
445
|
+
|
|
446
|
+
if (character === '[' && !isEscaped) {
|
|
447
|
+
inCharacterClass = true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!inCharacterClass && character === '\\' && source[index + 1] === '/') {
|
|
451
|
+
result += PATH_SEPARATOR_SOURCE;
|
|
452
|
+
index++;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
result += character;
|
|
457
|
+
|
|
458
|
+
if (character === ']' && !isEscaped) {
|
|
459
|
+
inCharacterClass = false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return result;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
type BrowserContextExcludeSource = {
|
|
467
|
+
relative: string;
|
|
468
|
+
absolute: string;
|
|
469
|
+
isAbsolute: boolean;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const createRelativeContextExcludeSource = (
|
|
473
|
+
source: string,
|
|
474
|
+
normalizedPattern: string,
|
|
475
|
+
): string => {
|
|
476
|
+
if (normalizedPattern.startsWith('./')) {
|
|
477
|
+
return source;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return normalizedPattern.startsWith('**/')
|
|
481
|
+
? `(?:(?:${source})|\\.(?:${source}))`
|
|
482
|
+
: `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const createProjectAbsoluteExcludeSource = (
|
|
486
|
+
source: string,
|
|
487
|
+
normalizedPattern: string,
|
|
488
|
+
): string =>
|
|
489
|
+
normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/')
|
|
490
|
+
? source
|
|
491
|
+
: `${PATH_SEPARATOR_SOURCE}(?:${source})`;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
|
|
495
|
+
* This is used at compile time to filter out files during bundling
|
|
496
|
+
*
|
|
497
|
+
* Example:
|
|
498
|
+
* Input: ['**\/node_modules\/**', '**\/dist\/**']
|
|
499
|
+
* Output: a regexp matching node_modules or dist path segments.
|
|
500
|
+
*/
|
|
501
|
+
const excludePatternsToRegExpSources = (
|
|
502
|
+
patterns: string[],
|
|
503
|
+
): BrowserContextExcludeSource[] | null => {
|
|
504
|
+
const sources = patterns.map((pattern) => {
|
|
505
|
+
const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
|
|
506
|
+
const regex = globToRegexp(normalizedPattern);
|
|
507
|
+
let source = regex.source;
|
|
508
|
+
if (source.startsWith('^')) {
|
|
509
|
+
source = source.substring(1);
|
|
510
|
+
}
|
|
511
|
+
if (source.endsWith('$')) {
|
|
512
|
+
source = source.substring(0, source.length - 1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
source = replacePathSeparatorsInRegExpSource(source);
|
|
516
|
+
const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
|
|
517
|
+
const absolute = normalizedPattern.startsWith('./')
|
|
518
|
+
? source.substring(2)
|
|
519
|
+
: source;
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
relative: isAbsolute
|
|
523
|
+
? source
|
|
524
|
+
: createRelativeContextExcludeSource(source, normalizedPattern),
|
|
525
|
+
absolute: isAbsolute
|
|
526
|
+
? absolute
|
|
527
|
+
: createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
|
|
528
|
+
isAbsolute,
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
if (sources.length === 0) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return sources;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export const createBrowserContextExcludeRegExp = (
|
|
540
|
+
patterns: string[],
|
|
541
|
+
projectRoot: string,
|
|
542
|
+
): RegExp | null => {
|
|
543
|
+
const excludeSources = excludePatternsToRegExpSources(patterns);
|
|
544
|
+
if (!excludeSources) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(
|
|
549
|
+
/[\\/]$/,
|
|
550
|
+
'',
|
|
551
|
+
);
|
|
552
|
+
const projectRootSource = normalizedProjectRoot
|
|
553
|
+
.split('/')
|
|
554
|
+
.map(escapeRegExp)
|
|
555
|
+
.join(PATH_SEPARATOR_SOURCE);
|
|
556
|
+
const relativeExcludeSources = excludeSources.filter(
|
|
557
|
+
(source) => !source.isAbsolute,
|
|
558
|
+
);
|
|
559
|
+
const absoluteExcludeSources = excludeSources.filter(
|
|
560
|
+
(source) => source.isAbsolute,
|
|
561
|
+
);
|
|
562
|
+
const sourceBranches: string[] = [];
|
|
563
|
+
|
|
564
|
+
if (relativeExcludeSources.length > 0) {
|
|
565
|
+
const relativePatternSource = `(?:${relativeExcludeSources
|
|
566
|
+
.map((source) => source.relative)
|
|
567
|
+
.join('|')})`;
|
|
568
|
+
const absolutePatternSource = `(?:${relativeExcludeSources
|
|
569
|
+
.map((source) => source.absolute)
|
|
570
|
+
.join('|')})`;
|
|
571
|
+
const relativeSource = `(?:${relativePatternSource})`;
|
|
572
|
+
const absoluteSource = normalizedProjectRoot
|
|
573
|
+
? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})`
|
|
574
|
+
: `(?:${absolutePatternSource})`;
|
|
575
|
+
|
|
576
|
+
sourceBranches.push(
|
|
577
|
+
`(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`,
|
|
578
|
+
absoluteSource,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (absoluteExcludeSources.length > 0) {
|
|
583
|
+
sourceBranches.push(
|
|
584
|
+
`(?:${absoluteExcludeSources
|
|
585
|
+
.map((source) => source.relative)
|
|
586
|
+
.join('|')})`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return new RegExp(`^(?:${sourceBranches.join('|')})$`);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
type StatsModule = {
|
|
594
|
+
nameForCondition?: string;
|
|
595
|
+
children?: StatsModule[];
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
type StatsChunk = {
|
|
599
|
+
id?: string | number;
|
|
600
|
+
names?: string[];
|
|
601
|
+
hash?: string;
|
|
602
|
+
files?: string[];
|
|
603
|
+
modules?: StatsModule[];
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Find test file path from chunk modules by matching against known entry files.
|
|
608
|
+
*/
|
|
609
|
+
const findTestFileInModules = (
|
|
610
|
+
modules: StatsModule[] | undefined,
|
|
611
|
+
entryTestFiles: Set<string>,
|
|
612
|
+
): string | null => {
|
|
613
|
+
if (!modules) return null;
|
|
614
|
+
|
|
615
|
+
for (const m of modules) {
|
|
616
|
+
if (m.nameForCondition) {
|
|
617
|
+
const normalizedPath = normalize(m.nameForCondition);
|
|
618
|
+
if (entryTestFiles.has(normalizedPath)) {
|
|
619
|
+
return normalizedPath;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (m.children) {
|
|
623
|
+
const found = findTestFileInModules(m.children, entryTestFiles);
|
|
624
|
+
if (found) return found;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Get a stable identifier for a chunk.
|
|
632
|
+
* Prefers chunk.id or chunk.names[0] over file paths for stability.
|
|
633
|
+
*/
|
|
634
|
+
const getChunkKey = (chunk: StatsChunk): string | null => {
|
|
635
|
+
if (chunk.id != null) {
|
|
636
|
+
return String(chunk.id);
|
|
637
|
+
}
|
|
638
|
+
if (chunk.names && chunk.names.length > 0) {
|
|
639
|
+
return chunk.names[0]!;
|
|
640
|
+
}
|
|
641
|
+
if (chunk.files && chunk.files.length > 0) {
|
|
642
|
+
return chunk.files[0]!;
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Fold one project compile's chunks into per-entry hash snapshots and apply
|
|
649
|
+
* the shared watch-invalidation policy against that project's baseline.
|
|
650
|
+
* Chunks are attributed to a test/setup file by scanning their modules; the
|
|
651
|
+
* chunk.id/names key is only the hash-record key, never a cross-project one.
|
|
652
|
+
*/
|
|
653
|
+
const getAffectedTestFiles = ({
|
|
654
|
+
chunks,
|
|
655
|
+
entryTestFiles,
|
|
656
|
+
setupFiles,
|
|
657
|
+
state,
|
|
658
|
+
}: {
|
|
659
|
+
chunks: StatsChunk[] | undefined;
|
|
660
|
+
entryTestFiles: Set<string>;
|
|
661
|
+
setupFiles: Set<string>;
|
|
662
|
+
state: WatchInvalidationState;
|
|
663
|
+
}): string[] => {
|
|
664
|
+
const entryHashes: EntryHashSnapshot = new Map();
|
|
665
|
+
const setupHashes: EntryHashSnapshot = new Map();
|
|
666
|
+
|
|
667
|
+
const recordChunk = (
|
|
668
|
+
snapshot: EntryHashSnapshot,
|
|
669
|
+
entryPath: string,
|
|
670
|
+
chunkKey: string,
|
|
671
|
+
hash: string,
|
|
672
|
+
) => {
|
|
673
|
+
const record = snapshot.get(entryPath) ?? {};
|
|
674
|
+
record[chunkKey] = hash;
|
|
675
|
+
snapshot.set(entryPath, record);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
for (const chunk of chunks || []) {
|
|
679
|
+
if (!chunk.hash) continue;
|
|
680
|
+
|
|
681
|
+
const chunkKey = getChunkKey(chunk);
|
|
682
|
+
if (!chunkKey) continue;
|
|
683
|
+
|
|
684
|
+
const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
|
|
685
|
+
if (testFile) {
|
|
686
|
+
recordChunk(entryHashes, testFile, chunkKey, chunk.hash);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const setupFile = findTestFileInModules(chunk.modules, setupFiles);
|
|
691
|
+
if (setupFile) {
|
|
692
|
+
recordChunk(setupHashes, setupFile, chunkKey, chunk.hash);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Headed watch compiles chunks on demand (lazyCompilation), so an entry's
|
|
697
|
+
// first appearance in stats means "just loaded", not "just added": its first
|
|
698
|
+
// sighting establishes the baseline instead of marking a change. Genuinely
|
|
699
|
+
// new and deleted test files are owned by the test-file-set diff in
|
|
700
|
+
// `planWatchRerun` / `collectDeletedTestPaths`.
|
|
701
|
+
const seedFirstSeen = (
|
|
702
|
+
baseline: EntryHashSnapshot | undefined,
|
|
703
|
+
current: EntryHashSnapshot,
|
|
704
|
+
) => {
|
|
705
|
+
if (!baseline) return;
|
|
706
|
+
for (const [entryPath, record] of current) {
|
|
707
|
+
if (!baseline.has(entryPath)) {
|
|
708
|
+
baseline.set(entryPath, record);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
seedFirstSeen(state.entryHashes, entryHashes);
|
|
713
|
+
seedFirstSeen(state.setupHashes, setupHashes);
|
|
714
|
+
|
|
715
|
+
const outcome = applyWatchInvalidation(state, { entryHashes, setupHashes });
|
|
716
|
+
|
|
717
|
+
if (outcome.rerunAll) {
|
|
718
|
+
logger.debug(
|
|
719
|
+
'[Watch] Setup file changed, re-running all test files of the project',
|
|
720
|
+
);
|
|
721
|
+
return Array.from(entryTestFiles);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
for (const affected of outcome.affectedPaths) {
|
|
725
|
+
logger.debug(`[Watch] Chunk hash changed for test: ${affected}`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return outcome.affectedPaths;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
export const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
|
|
732
|
+
context.projects.filter(
|
|
733
|
+
(project) => project.normalizedConfig.browser.enabled,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
const getBrowserRsbuildEnvironmentConfig = (
|
|
737
|
+
project: ProjectContext,
|
|
738
|
+
): RsbuildEnvironmentConfig => ({
|
|
739
|
+
plugins: project.normalizedConfig.plugins,
|
|
740
|
+
root: project.rootPath,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Max testTimeout across browser projects, used as the host->client RPC timeout.
|
|
744
|
+
|
|
745
|
+
const getBrowserLaunchOptions = (
|
|
746
|
+
project: ProjectContext,
|
|
747
|
+
): BrowserLaunchOptions => ({
|
|
748
|
+
provider: project.normalizedConfig.browser.provider,
|
|
749
|
+
browser: project.normalizedConfig.browser.browser,
|
|
750
|
+
headless: project.normalizedConfig.browser.headless,
|
|
751
|
+
port: project.normalizedConfig.browser.port,
|
|
752
|
+
strictPort: project.normalizedConfig.browser.strictPort,
|
|
753
|
+
providerOptions: getBrowserProviderOptions(project),
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const ensureConsistentBrowserLaunchOptions = (
|
|
757
|
+
projects: ProjectContext[],
|
|
758
|
+
): BrowserLaunchOptions => {
|
|
759
|
+
if (projects.length === 0) {
|
|
760
|
+
throw new Error('No browser-enabled projects found.');
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const firstProject = projects[0]!;
|
|
764
|
+
const firstOptions = getBrowserLaunchOptions(firstProject);
|
|
765
|
+
|
|
766
|
+
for (const project of projects.slice(1)) {
|
|
767
|
+
const options = getBrowserLaunchOptions(project);
|
|
768
|
+
// Each browser project now runs on its own rsbuild dev server, so ports may
|
|
769
|
+
// differ per project. Only the shared single Playwright browser forces
|
|
770
|
+
// provider/browser/headless/providerOptions to match across projects.
|
|
771
|
+
if (
|
|
772
|
+
options.provider !== firstOptions.provider ||
|
|
773
|
+
options.browser !== firstOptions.browser ||
|
|
774
|
+
options.headless !== firstOptions.headless ||
|
|
775
|
+
!isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
|
|
776
|
+
) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
779
|
+
'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return firstOptions;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
export const collectProjectEntries = async (
|
|
788
|
+
context: RstestContext,
|
|
789
|
+
// The explicit browser-project subset the executor was constructed with. Falls
|
|
790
|
+
// back to re-deriving from `context` for internal callers (e.g. the watch
|
|
791
|
+
// plugin) that do not carry the plan's project list.
|
|
792
|
+
browserProjects: ProjectContext[] = getBrowserProjects(context),
|
|
793
|
+
): Promise<BrowserProjectEntries[]> => {
|
|
794
|
+
return Promise.all(
|
|
795
|
+
browserProjects.map(async (project) => {
|
|
796
|
+
const {
|
|
797
|
+
normalizedConfig: { include, exclude, includeSource, setupFiles },
|
|
798
|
+
} = project;
|
|
799
|
+
|
|
800
|
+
const tests = await getTestEntries({
|
|
801
|
+
include,
|
|
802
|
+
exclude: exclude.patterns,
|
|
803
|
+
includeSource,
|
|
804
|
+
rootPath: context.rootPath,
|
|
805
|
+
projectRoot: project.rootPath,
|
|
806
|
+
fileFilters: context.fileFilters || [],
|
|
807
|
+
fileFilterMode: context.fileFilterMode,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const setup = getSetupFiles(setupFiles, project.rootPath);
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
project,
|
|
814
|
+
setupFiles: Object.values(setup),
|
|
815
|
+
testFiles: Object.values(tests),
|
|
816
|
+
};
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const resolveBrowserFile = (relativePath: string): string => {
|
|
822
|
+
// __dirname points to packages/browser/dist when running from built code
|
|
823
|
+
// or packages/browser/src when running from source
|
|
824
|
+
const candidates = [
|
|
825
|
+
// When running from built dist: look in ../src for source files
|
|
826
|
+
resolve(__dirname, '../src', relativePath),
|
|
827
|
+
// When running from source (dev mode)
|
|
828
|
+
resolve(__dirname, relativePath),
|
|
829
|
+
];
|
|
830
|
+
|
|
831
|
+
for (const candidate of candidates) {
|
|
832
|
+
if (existsSync(candidate)) {
|
|
833
|
+
return candidate;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
throw new Error(`Unable to resolve browser client file: ${relativePath}`);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
export const resolveContainerDist = (): string => {
|
|
841
|
+
// When running from built dist: browser-container is in the same dist folder
|
|
842
|
+
const distPath = resolve(__dirname, 'browser-container');
|
|
843
|
+
if (existsSync(distPath)) {
|
|
844
|
+
return distPath;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
throw new Error(
|
|
848
|
+
`Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`,
|
|
849
|
+
);
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
// ============================================================================
|
|
853
|
+
// Manifest Generation
|
|
854
|
+
// ============================================================================
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Format environment name to a valid JavaScript identifier.
|
|
858
|
+
* Replaces non-alphanumeric characters with underscores.
|
|
859
|
+
*/
|
|
860
|
+
const toSafeVarName = (name: string): string => {
|
|
861
|
+
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
// Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
|
|
865
|
+
// `./<path-relative-to-project-root>` with forward slashes. The runtime derives
|
|
866
|
+
// the same key from the target test file, so the non-watch import map below must
|
|
867
|
+
// key by the identical form for `loadTest(key)` to resolve.
|
|
868
|
+
export const toContextKey = (
|
|
869
|
+
filePath: string,
|
|
870
|
+
projectRootPosix: string,
|
|
871
|
+
): string => {
|
|
872
|
+
const posixPath = normalize(filePath);
|
|
873
|
+
// Only strip the root at a path boundary: a bare `startsWith` would mangle a
|
|
874
|
+
// sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
|
|
875
|
+
const withinRoot =
|
|
876
|
+
posixPath === projectRootPosix ||
|
|
877
|
+
posixPath.startsWith(`${projectRootPosix}/`);
|
|
878
|
+
if (!withinRoot) {
|
|
879
|
+
// Test file outside the project root: use the absolute path as the key so
|
|
880
|
+
// the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
|
|
881
|
+
// key would be re-rooted under projectRoot and point at a nonexistent file.
|
|
882
|
+
return posixPath;
|
|
883
|
+
}
|
|
884
|
+
const rel = posixPath.slice(projectRootPosix.length);
|
|
885
|
+
return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
const generateManifestModule = ({
|
|
889
|
+
manifestPath,
|
|
890
|
+
entries,
|
|
891
|
+
isWatchMode,
|
|
892
|
+
}: {
|
|
893
|
+
manifestPath: string;
|
|
894
|
+
entries: BrowserProjectEntries[];
|
|
895
|
+
isWatchMode: boolean;
|
|
896
|
+
}): string => {
|
|
897
|
+
const manifestDirPosix = normalize(dirname(manifestPath));
|
|
898
|
+
|
|
899
|
+
const toRelativeImport = (filePath: string): string => {
|
|
900
|
+
const posixPath = normalize(filePath);
|
|
901
|
+
let relativePath = relative(manifestDirPosix, posixPath);
|
|
902
|
+
if (!relativePath.startsWith('.')) {
|
|
903
|
+
relativePath = `./${relativePath}`;
|
|
904
|
+
}
|
|
905
|
+
return relativePath;
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const lines: string[] = [];
|
|
909
|
+
|
|
910
|
+
// 1. Export all projects configuration
|
|
911
|
+
lines.push('// All projects configuration');
|
|
912
|
+
lines.push('export const projects = [');
|
|
913
|
+
for (const { project } of entries) {
|
|
914
|
+
lines.push(' {');
|
|
915
|
+
lines.push(` name: ${JSON.stringify(project.name)},`);
|
|
916
|
+
lines.push(
|
|
917
|
+
` environmentName: ${JSON.stringify(project.environmentName)},`,
|
|
918
|
+
);
|
|
919
|
+
lines.push(
|
|
920
|
+
` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
|
|
921
|
+
);
|
|
922
|
+
lines.push(' },');
|
|
923
|
+
}
|
|
924
|
+
lines.push('];');
|
|
925
|
+
lines.push('');
|
|
926
|
+
|
|
927
|
+
// 2. Setup loaders for each project
|
|
928
|
+
lines.push('// Setup loaders for each project');
|
|
929
|
+
lines.push('export const projectSetupLoaders = {');
|
|
930
|
+
for (const { project, setupFiles } of entries) {
|
|
931
|
+
lines.push(` ${JSON.stringify(project.name)}: [`);
|
|
932
|
+
for (const filePath of setupFiles) {
|
|
933
|
+
const relativePath = toRelativeImport(filePath);
|
|
934
|
+
lines.push(` () => import(${JSON.stringify(relativePath)}),`);
|
|
935
|
+
}
|
|
936
|
+
lines.push(' ],');
|
|
937
|
+
}
|
|
938
|
+
lines.push('};');
|
|
939
|
+
lines.push('');
|
|
940
|
+
|
|
941
|
+
// 3. Test context for each project. Both branches expose the same shape as a
|
|
942
|
+
// webpackContext (callable by key, plus `keys()`), consumed in section 4.
|
|
943
|
+
lines.push('// Test context for each project');
|
|
944
|
+
for (const { project, testFiles } of entries) {
|
|
945
|
+
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
946
|
+
const projectRootPosix = normalize(project.rootPath);
|
|
947
|
+
|
|
948
|
+
if (isWatchMode) {
|
|
949
|
+
// Watch mode keeps the include-glob context so newly added files are
|
|
950
|
+
// picked up on rebuild via `keys()` without regenerating the manifest.
|
|
951
|
+
// `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
|
|
952
|
+
// works with or without lazyCompilation; headed watch additionally layers
|
|
953
|
+
// lazyCompilation on top to keep the initial build cheap.
|
|
954
|
+
const includeRegExp = globPatternsToRegExp(
|
|
955
|
+
project.normalizedConfig.include,
|
|
956
|
+
);
|
|
957
|
+
const excludeRegExp = createBrowserContextExcludeRegExp(
|
|
958
|
+
project.normalizedConfig.exclude.patterns,
|
|
959
|
+
projectRootPosix,
|
|
960
|
+
);
|
|
961
|
+
const { includeSource } = project.normalizedConfig;
|
|
962
|
+
const emitContext = (contextVarName: string, regExp: RegExp): void => {
|
|
963
|
+
lines.push(
|
|
964
|
+
`const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
|
|
965
|
+
);
|
|
966
|
+
lines.push(' recursive: true,');
|
|
967
|
+
lines.push(` regExp: ${regExp.toString()},`);
|
|
968
|
+
if (excludeRegExp) {
|
|
969
|
+
lines.push(` exclude: ${excludeRegExp.toString()},`);
|
|
970
|
+
}
|
|
971
|
+
lines.push(" mode: 'lazy',");
|
|
972
|
+
lines.push('});');
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
if (includeSource.length === 0) {
|
|
976
|
+
emitContext(varName, includeRegExp);
|
|
977
|
+
} else {
|
|
978
|
+
// In-source test files (`includeSource`) carry an
|
|
979
|
+
// `if (import.meta.rstest)` block. The include context can't see them,
|
|
980
|
+
// so a second context over the `includeSource` globs backs
|
|
981
|
+
// host-scheduled loads, while `keys()` only unions the entry-probed
|
|
982
|
+
// in-source files (the probe does not apply inside the bundle, so raw
|
|
983
|
+
// source-context keys would execute never-probed files and fail with
|
|
984
|
+
// "No test suites found"). The probed list can go stale until a
|
|
985
|
+
// manifest refresh; scheduled-by-path loading never does.
|
|
986
|
+
emitContext(`${varName}_include`, includeRegExp);
|
|
987
|
+
emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
|
|
988
|
+
const probedKeys = testFiles.map((filePath) =>
|
|
989
|
+
toContextKey(filePath, projectRootPosix),
|
|
990
|
+
);
|
|
991
|
+
lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
|
|
992
|
+
lines.push(
|
|
993
|
+
`const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
|
|
994
|
+
);
|
|
995
|
+
lines.push(`const ${varName} = Object.assign(`);
|
|
996
|
+
lines.push(
|
|
997
|
+
` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
|
|
998
|
+
);
|
|
999
|
+
lines.push(' {');
|
|
1000
|
+
lines.push(
|
|
1001
|
+
` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
|
|
1002
|
+
);
|
|
1003
|
+
lines.push(' },');
|
|
1004
|
+
lines.push(');');
|
|
1005
|
+
}
|
|
1006
|
+
} else {
|
|
1007
|
+
// One-shot runs: the file set is fixed and already filtered, so emit an
|
|
1008
|
+
// explicit lazy-import map (one chunk per literal `import()`, like the
|
|
1009
|
+
// setup loaders above). The eager, non-lazyCompilation build then compiles
|
|
1010
|
+
// only the run set instead of every included test file.
|
|
1011
|
+
lines.push(`const ${varName}_modules = {`);
|
|
1012
|
+
for (const filePath of testFiles) {
|
|
1013
|
+
const key = toContextKey(filePath, projectRootPosix);
|
|
1014
|
+
const importPath = toRelativeImport(filePath);
|
|
1015
|
+
lines.push(
|
|
1016
|
+
` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
lines.push('};');
|
|
1020
|
+
lines.push(
|
|
1021
|
+
`const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
|
|
1022
|
+
);
|
|
1023
|
+
lines.push(` keys: () => Object.keys(${varName}_modules),`);
|
|
1024
|
+
lines.push('});');
|
|
1025
|
+
}
|
|
1026
|
+
lines.push('');
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// 4. Export test contexts object
|
|
1030
|
+
lines.push('export const projectTestContexts = {');
|
|
1031
|
+
for (const { project } of entries) {
|
|
1032
|
+
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
1033
|
+
lines.push(` ${JSON.stringify(project.name)}: {`);
|
|
1034
|
+
lines.push(` getTestKeys: () => ${varName}.keys(),`);
|
|
1035
|
+
lines.push(` loadTest: (key) => ${varName}(key),`);
|
|
1036
|
+
lines.push(
|
|
1037
|
+
` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
|
|
1038
|
+
);
|
|
1039
|
+
lines.push(' },');
|
|
1040
|
+
}
|
|
1041
|
+
lines.push('};');
|
|
1042
|
+
lines.push('');
|
|
1043
|
+
|
|
1044
|
+
// 5. Backward compatibility exports (use first project as default)
|
|
1045
|
+
lines.push('// Backward compatibility: export first project as default');
|
|
1046
|
+
lines.push('export const projectConfig = projects[0];');
|
|
1047
|
+
lines.push(
|
|
1048
|
+
'export const setupLoaders = projectSetupLoaders[projects[0].name] || [];',
|
|
1049
|
+
);
|
|
1050
|
+
lines.push('const _defaultCtx = projectTestContexts[projects[0].name];');
|
|
1051
|
+
lines.push(
|
|
1052
|
+
'export const getTestKeys = () => _defaultCtx ? _defaultCtx.getTestKeys() : [];',
|
|
1053
|
+
);
|
|
1054
|
+
lines.push(
|
|
1055
|
+
'export const loadTest = (key) => _defaultCtx ? _defaultCtx.loadTest(key) : Promise.reject(new Error("No project found"));',
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
return `${lines.join('\n')}\n`;
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
const htmlTemplate = `<!DOCTYPE html>
|
|
1062
|
+
<html lang="en">
|
|
1063
|
+
<head>
|
|
1064
|
+
<meta charset="UTF-8" />
|
|
1065
|
+
<title>Rstest Browser Runner</title>
|
|
1066
|
+
</head>
|
|
1067
|
+
<body>
|
|
1068
|
+
<script type="module" src="/static/js/runner.js"></script>
|
|
1069
|
+
</body>
|
|
1070
|
+
</html>
|
|
1071
|
+
`;
|
|
1072
|
+
|
|
1073
|
+
// Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
|
|
1074
|
+
// Rsbuild suppresses the removed-file log if all removed paths include "virtual":
|
|
1075
|
+
// https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
|
|
1076
|
+
const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
|
|
1077
|
+
|
|
1078
|
+
// ============================================================================
|
|
1079
|
+
// Browser Runtime Lifecycle
|
|
1080
|
+
// ============================================================================
|
|
1081
|
+
|
|
1082
|
+
const closeAllProjectServers = (
|
|
1083
|
+
servers: Iterable<BrowserProjectServer>,
|
|
1084
|
+
): Promise<unknown> =>
|
|
1085
|
+
Promise.allSettled([...servers].map((server) => server.devServer.close()));
|
|
1086
|
+
|
|
1087
|
+
// Copy a proxied fetch Response's status + headers onto the Node response,
|
|
1088
|
+
// dropping content-length (the body is re-sent, so the original length may not
|
|
1089
|
+
// match).
|
|
1090
|
+
const copyProxyResponseHeaders = (
|
|
1091
|
+
response: Response,
|
|
1092
|
+
res: ServerResponse,
|
|
1093
|
+
): void => {
|
|
1094
|
+
res.statusCode = response.status;
|
|
1095
|
+
response.headers.forEach((value, key) => {
|
|
1096
|
+
if (key.toLowerCase() === 'content-length') {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
res.setHeader(key, value);
|
|
1100
|
+
});
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
export const destroyBrowserRuntime = async (
|
|
1104
|
+
runtime: BrowserRuntime,
|
|
1105
|
+
): Promise<void> => {
|
|
1106
|
+
try {
|
|
1107
|
+
await runtime.browser?.close?.();
|
|
1108
|
+
} catch {
|
|
1109
|
+
// ignore
|
|
1110
|
+
}
|
|
1111
|
+
await closeAllProjectServers(runtime.projectServers.values());
|
|
1112
|
+
try {
|
|
1113
|
+
runtime.wss?.close();
|
|
1114
|
+
} catch {
|
|
1115
|
+
// ignore
|
|
1116
|
+
}
|
|
1117
|
+
await fs
|
|
1118
|
+
.rm(runtime.tempDir, { recursive: true, force: true })
|
|
1119
|
+
.catch(() => {});
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
export const createBrowserRuntime = async ({
|
|
1123
|
+
context,
|
|
1124
|
+
projectEntries: initialProjectEntries,
|
|
1125
|
+
browserProjects,
|
|
1126
|
+
shardedEntries,
|
|
1127
|
+
freezeShardedEntries,
|
|
1128
|
+
tempDir,
|
|
1129
|
+
isWatchMode,
|
|
1130
|
+
onTriggerRerun,
|
|
1131
|
+
containerDistPath,
|
|
1132
|
+
containerDevServer,
|
|
1133
|
+
forceHeadless,
|
|
1134
|
+
skipProviderLaunch,
|
|
1135
|
+
appliedModifyRstestConfigEnvironments,
|
|
1136
|
+
}: {
|
|
1137
|
+
context: RstestContext;
|
|
1138
|
+
projectEntries: BrowserProjectEntries[];
|
|
1139
|
+
/**
|
|
1140
|
+
* The explicit browser-project subset (plan output). Drives launch-option
|
|
1141
|
+
* consistency and the container origin (`browserProjects[0]`).
|
|
1142
|
+
*/
|
|
1143
|
+
browserProjects: ProjectContext[];
|
|
1144
|
+
shardedEntries?: Map<string, { entries: Record<string, string> }>;
|
|
1145
|
+
freezeShardedEntries?: boolean;
|
|
1146
|
+
tempDir: string;
|
|
1147
|
+
isWatchMode: boolean;
|
|
1148
|
+
onTriggerRerun?: () => Promise<void>;
|
|
1149
|
+
containerDistPath?: string;
|
|
1150
|
+
containerDevServer?: string;
|
|
1151
|
+
/** Force headless mode regardless of user config (used for list command) */
|
|
1152
|
+
forceHeadless?: boolean;
|
|
1153
|
+
skipProviderLaunch?: boolean;
|
|
1154
|
+
appliedModifyRstestConfigEnvironments?: Set<string>;
|
|
1155
|
+
}): Promise<BrowserRuntime> => {
|
|
1156
|
+
// ---- Shared singletons (created once, wired into every project server) ----
|
|
1157
|
+
const containerHtmlTemplate = containerDistPath
|
|
1158
|
+
? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
|
|
1159
|
+
: null;
|
|
1160
|
+
|
|
1161
|
+
let injectedContainerHtml: string | null = null;
|
|
1162
|
+
let serializedOptions = 'null';
|
|
1163
|
+
// Reserved extension seam for future browser-side capabilities.
|
|
1164
|
+
const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
|
|
1165
|
+
|
|
1166
|
+
const setContainerOptions = (options: BrowserHostConfig): void => {
|
|
1167
|
+
serializedOptions = serializeForInlineScript(options);
|
|
1168
|
+
if (containerHtmlTemplate) {
|
|
1169
|
+
injectedContainerHtml = containerHtmlTemplate.replace(
|
|
1170
|
+
OPTIONS_PLACEHOLDER,
|
|
1171
|
+
serializedOptions,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
let browserLaunchOptions =
|
|
1177
|
+
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1178
|
+
let projectEntries = initialProjectEntries;
|
|
1179
|
+
// Created with the runtime so the per-project watch plugins and the
|
|
1180
|
+
// controller's rerun closures share one state whose lifetime matches the
|
|
1181
|
+
// compilers holding the diffed chunks.
|
|
1182
|
+
const watchState = createBrowserWatchState();
|
|
1183
|
+
const manifestModules: Array<{
|
|
1184
|
+
manifestPath: string;
|
|
1185
|
+
project: ProjectContext;
|
|
1186
|
+
modules: Record<string, string>;
|
|
1187
|
+
}> = [];
|
|
1188
|
+
|
|
1189
|
+
const createRuntimeWithoutProvider = (): BrowserRuntime => {
|
|
1190
|
+
const firstProject = browserProjects[0]!;
|
|
1191
|
+
return {
|
|
1192
|
+
projectServers: new Map(),
|
|
1193
|
+
containerServer: {
|
|
1194
|
+
projectName: firstProject.name,
|
|
1195
|
+
environmentName: firstProject.environmentName,
|
|
1196
|
+
rsbuildInstance: undefined as unknown as RsbuildInstance,
|
|
1197
|
+
devServer: {
|
|
1198
|
+
close: async () => undefined,
|
|
1199
|
+
} as RsbuildDevServer,
|
|
1200
|
+
port: 0,
|
|
1201
|
+
manifestPath: '',
|
|
1202
|
+
},
|
|
1203
|
+
browser: undefined as unknown as BrowserProviderBrowser,
|
|
1204
|
+
browserLaunchOptions,
|
|
1205
|
+
wsPort: 0,
|
|
1206
|
+
tempDir,
|
|
1207
|
+
setContainerOptions,
|
|
1208
|
+
dispatchHandlers,
|
|
1209
|
+
wss: undefined as unknown as WebSocketServer,
|
|
1210
|
+
projectEntries,
|
|
1211
|
+
watchState,
|
|
1212
|
+
};
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const getProjectEntry = (project: ProjectContext) =>
|
|
1216
|
+
projectEntries.find(
|
|
1217
|
+
(item) => item.project.environmentName === project.environmentName,
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
const refreshManifestModule = (manifestModule: {
|
|
1221
|
+
manifestPath: string;
|
|
1222
|
+
project: ProjectContext;
|
|
1223
|
+
modules: Record<string, string>;
|
|
1224
|
+
}): void => {
|
|
1225
|
+
const entry = getProjectEntry(manifestModule.project);
|
|
1226
|
+
manifestModule.modules[manifestModule.manifestPath] =
|
|
1227
|
+
generateManifestModule({
|
|
1228
|
+
manifestPath: manifestModule.manifestPath,
|
|
1229
|
+
entries: [
|
|
1230
|
+
{
|
|
1231
|
+
project: manifestModule.project,
|
|
1232
|
+
testFiles: entry?.testFiles ?? [],
|
|
1233
|
+
setupFiles: entry?.setupFiles ?? [],
|
|
1234
|
+
},
|
|
1235
|
+
],
|
|
1236
|
+
isWatchMode,
|
|
1237
|
+
});
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
const refreshProjectEntries = async (): Promise<void> => {
|
|
1241
|
+
validateBrowserConfig(context);
|
|
1242
|
+
browserLaunchOptions =
|
|
1243
|
+
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1244
|
+
const updatedShardedEntries = freezeShardedEntries
|
|
1245
|
+
? shardedEntries
|
|
1246
|
+
: context.normalizedConfig.shard
|
|
1247
|
+
? await resolveShardedEntries(context, { silent: true })
|
|
1248
|
+
: shardedEntries;
|
|
1249
|
+
projectEntries = await resolveProjectEntries(
|
|
1250
|
+
context,
|
|
1251
|
+
updatedShardedEntries,
|
|
1252
|
+
browserProjects,
|
|
1253
|
+
);
|
|
1254
|
+
for (const manifestModule of manifestModules) {
|
|
1255
|
+
refreshManifestModule(manifestModule);
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
// Rstest internal aliases that must not be overridden by user config
|
|
1260
|
+
const browserRuntimePath = fileURLToPath(
|
|
1261
|
+
import.meta.resolve('@rstest/core/internal/browser-runtime'),
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
// Shared by every project — only the per-project `@rstest/browser-manifest`
|
|
1265
|
+
// alias varies (one virtual manifest per server).
|
|
1266
|
+
const staticRstestAliases = {
|
|
1267
|
+
// User test code `import { describe, it } from '@rstest/core'` is NOT
|
|
1268
|
+
// aliased: `applyWebMockRspackConfig` keeps the request external against
|
|
1269
|
+
// `globalThis['@rstest/core']` (node parity), which also keeps the mock
|
|
1270
|
+
// hoister's provider-import ordering correct for `rs.hoisted` callbacks.
|
|
1271
|
+
// User test code: import { page } from '@rstest/browser'
|
|
1272
|
+
'@rstest/browser': resolveBrowserFile('browser.ts'),
|
|
1273
|
+
// Browser runtime APIs for entry.ts
|
|
1274
|
+
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
1275
|
+
'@rstest/core/internal/browser-runtime': browserRuntimePath,
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// rspack `define` replaces `process.env` / `import.meta.env` with this literal
|
|
1279
|
+
// expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
|
|
1280
|
+
// text, so the owned key can never drift from the runtime
|
|
1281
|
+
// `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
|
|
1282
|
+
const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
|
|
1283
|
+
RSTEST_ENV_SYMBOL_KEY,
|
|
1284
|
+
)})]`;
|
|
1285
|
+
|
|
1286
|
+
// Serve prebuilt container assets (SPA) via sirv (container origin only)
|
|
1287
|
+
const serveContainer = containerDistPath
|
|
1288
|
+
? sirv(containerDistPath, {
|
|
1289
|
+
dev: false,
|
|
1290
|
+
single: 'index.html',
|
|
1291
|
+
})
|
|
1292
|
+
: null;
|
|
1293
|
+
|
|
1294
|
+
const containerDevBase = containerDevServer
|
|
1295
|
+
? new URL(containerDevServer)
|
|
1296
|
+
: null;
|
|
1297
|
+
|
|
1298
|
+
const respondWithDevServerHtml = async (
|
|
1299
|
+
url: URL,
|
|
1300
|
+
res: ServerResponse,
|
|
1301
|
+
): Promise<boolean> => {
|
|
1302
|
+
if (!containerDevBase) {
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
try {
|
|
1307
|
+
const target = new URL(url.pathname + url.search, containerDevBase);
|
|
1308
|
+
const response = await fetch(target);
|
|
1309
|
+
if (!response.ok) {
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
let html = await response.text();
|
|
1314
|
+
html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
|
|
1315
|
+
|
|
1316
|
+
copyProxyResponseHeaders(response, res);
|
|
1317
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1318
|
+
res.end(html);
|
|
1319
|
+
return true;
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
logger.debug(
|
|
1322
|
+
`[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
|
|
1323
|
+
);
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const proxyDevServerAsset = async (
|
|
1329
|
+
req: IncomingMessage,
|
|
1330
|
+
res: ServerResponse,
|
|
1331
|
+
): Promise<boolean> => {
|
|
1332
|
+
if (!containerDevBase || !req.url) {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
try {
|
|
1337
|
+
const target = new URL(req.url, containerDevBase);
|
|
1338
|
+
const response = await fetch(target);
|
|
1339
|
+
if (!response.ok) {
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1344
|
+
copyProxyResponseHeaders(response, res);
|
|
1345
|
+
res.end(buffer);
|
|
1346
|
+
return true;
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
logger.debug(
|
|
1349
|
+
`[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
|
|
1350
|
+
);
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
const serveContainerRoute = async (
|
|
1356
|
+
req: IncomingMessage,
|
|
1357
|
+
res: ServerResponse,
|
|
1358
|
+
next: () => void,
|
|
1359
|
+
): Promise<void> => {
|
|
1360
|
+
if (!req.url) {
|
|
1361
|
+
next();
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1366
|
+
if (url.pathname === '/') {
|
|
1367
|
+
if (await respondWithDevServerHtml(url, res)) {
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const html =
|
|
1372
|
+
injectedContainerHtml ||
|
|
1373
|
+
containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
|
|
1374
|
+
|
|
1375
|
+
if (html) {
|
|
1376
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1377
|
+
res.end(html);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
res.statusCode = 502;
|
|
1382
|
+
res.end('Container UI is not available.');
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (url.pathname.startsWith('/container-static/')) {
|
|
1387
|
+
if (await proxyDevServerAsset(req, res)) {
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (serveContainer) {
|
|
1392
|
+
serveContainer(req, res, next);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
res.statusCode = 502;
|
|
1397
|
+
res.end('Container assets are not available.');
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
next();
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
// ---- Build one isolated rsbuild instance + dev server per project ----
|
|
1405
|
+
const buildProjectServer = async (
|
|
1406
|
+
project: ProjectContext,
|
|
1407
|
+
isContainerServer: boolean,
|
|
1408
|
+
): Promise<BrowserProjectServer> => {
|
|
1409
|
+
const manifestPath = join(
|
|
1410
|
+
tempDir,
|
|
1411
|
+
toSafeVarName(project.environmentName),
|
|
1412
|
+
VIRTUAL_MANIFEST_FILENAME,
|
|
1413
|
+
);
|
|
1414
|
+
const entry = getProjectEntry(project);
|
|
1415
|
+
const virtualManifestModules = {
|
|
1416
|
+
[manifestPath]: generateManifestModule({
|
|
1417
|
+
manifestPath,
|
|
1418
|
+
entries: [
|
|
1419
|
+
{
|
|
1420
|
+
project,
|
|
1421
|
+
testFiles: entry?.testFiles ?? [],
|
|
1422
|
+
setupFiles: entry?.setupFiles ?? [],
|
|
1423
|
+
},
|
|
1424
|
+
],
|
|
1425
|
+
isWatchMode,
|
|
1426
|
+
}),
|
|
1427
|
+
};
|
|
1428
|
+
const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
|
|
1429
|
+
virtualManifestModules,
|
|
1430
|
+
);
|
|
1431
|
+
manifestModules.push({
|
|
1432
|
+
manifestPath,
|
|
1433
|
+
project,
|
|
1434
|
+
modules: virtualManifestModules,
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
const rstestInternalAliases = {
|
|
1438
|
+
'@rstest/browser-manifest': manifestPath,
|
|
1439
|
+
...staticRstestAliases,
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const isHeadless =
|
|
1443
|
+
forceHeadless || project.normalizedConfig.browser.headless;
|
|
1444
|
+
const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
|
|
1445
|
+
|
|
1446
|
+
const rsbuildInstance = await createRsbuild({
|
|
1447
|
+
callerName: 'rstest',
|
|
1448
|
+
rsbuildConfig: {
|
|
1449
|
+
root: context.rootPath,
|
|
1450
|
+
mode: 'development',
|
|
1451
|
+
server: {
|
|
1452
|
+
printUrls: false,
|
|
1453
|
+
// Each project gets its own dev server. Honor an explicitly
|
|
1454
|
+
// configured port; otherwise keep the historical 4000 default for the
|
|
1455
|
+
// container server and let the OS assign free ports for the rest, so
|
|
1456
|
+
// multiple projects never collide on one port.
|
|
1457
|
+
port:
|
|
1458
|
+
project.normalizedConfig.browser.port ??
|
|
1459
|
+
(isContainerServer ? 4000 : 0),
|
|
1460
|
+
strictPort: project.normalizedConfig.browser.strictPort,
|
|
1461
|
+
// User plugins may emit index.html; register before Rsbuild's HTML
|
|
1462
|
+
// completion middleware so `/` remains owned by the Browser UI.
|
|
1463
|
+
setup: isContainerServer
|
|
1464
|
+
? ({ server }) => {
|
|
1465
|
+
server.middlewares.use(serveContainerRoute);
|
|
1466
|
+
}
|
|
1467
|
+
: undefined,
|
|
1468
|
+
},
|
|
1469
|
+
dev: createBrowserRsbuildDevConfig(enableHmr),
|
|
1470
|
+
environments: {
|
|
1471
|
+
[project.environmentName]:
|
|
1472
|
+
getBrowserRsbuildEnvironmentConfig(project),
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
initModifyRstestConfigHooks(
|
|
1478
|
+
context,
|
|
1479
|
+
rsbuildInstance,
|
|
1480
|
+
[project],
|
|
1481
|
+
[project],
|
|
1482
|
+
{
|
|
1483
|
+
getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
|
|
1484
|
+
onModifyRstestConfigApplied: refreshProjectEntries,
|
|
1485
|
+
appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
|
|
1486
|
+
},
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1490
|
+
rsbuildInstance.addPlugins([
|
|
1491
|
+
// Same mock runtime as the node build (importActual doppelganger rule +
|
|
1492
|
+
// mock webpack runtime module); order-insensitive and self-contained.
|
|
1493
|
+
pluginMockRuntime,
|
|
1494
|
+
{
|
|
1495
|
+
name: 'rstest:browser-user-config',
|
|
1496
|
+
setup(api) {
|
|
1497
|
+
// Internal extension entry: register host dispatch handlers without
|
|
1498
|
+
// coupling scheduling to individual capability implementations.
|
|
1499
|
+
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1500
|
+
'rstest:browser',
|
|
1501
|
+
{
|
|
1502
|
+
registerDispatchHandler: (
|
|
1503
|
+
namespace: string,
|
|
1504
|
+
handler: BrowserDispatchHandler,
|
|
1505
|
+
) => {
|
|
1506
|
+
dispatchHandlers.set(namespace, handler);
|
|
1507
|
+
},
|
|
1508
|
+
},
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
api.modifyEnvironmentConfig({
|
|
1512
|
+
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1513
|
+
if (name !== project.environmentName) {
|
|
1514
|
+
return config;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const userRsbuildConfig = project.normalizedConfig;
|
|
1518
|
+
const buildCache = resolveProjectBuildCache({
|
|
1519
|
+
context,
|
|
1520
|
+
project,
|
|
1521
|
+
});
|
|
1522
|
+
const setupFiles = Object.values(
|
|
1523
|
+
getSetupFiles(
|
|
1524
|
+
project.normalizedConfig.setupFiles,
|
|
1525
|
+
project.rootPath,
|
|
1526
|
+
),
|
|
1527
|
+
);
|
|
1528
|
+
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
1529
|
+
const merged = mergeEnvironmentConfig(
|
|
1530
|
+
config,
|
|
1531
|
+
{
|
|
1532
|
+
...userRsbuildConfig,
|
|
1533
|
+
performance: buildCache
|
|
1534
|
+
? {
|
|
1535
|
+
...userRsbuildConfig.performance,
|
|
1536
|
+
buildCache,
|
|
1537
|
+
}
|
|
1538
|
+
: userRsbuildConfig.performance,
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
resolve: {
|
|
1542
|
+
alias: rstestInternalAliases,
|
|
1543
|
+
},
|
|
1544
|
+
source: {
|
|
1545
|
+
define: {
|
|
1546
|
+
'process.env': rstestEnvDefine,
|
|
1547
|
+
'import.meta.env': rstestEnvDefine,
|
|
1548
|
+
// In-source `if (import.meta.rstest)` blocks read the
|
|
1549
|
+
// per-file runtime API the client entry publishes on
|
|
1550
|
+
// `globalThis` (node parity: `global['@rstest/core']`).
|
|
1551
|
+
'import.meta.rstest': importMetaRstestDefine('web'),
|
|
1552
|
+
},
|
|
1553
|
+
},
|
|
1554
|
+
output: {
|
|
1555
|
+
target: 'web',
|
|
1556
|
+
// Enable source map for inline snapshot support
|
|
1557
|
+
sourceMap: {
|
|
1558
|
+
js: 'source-map',
|
|
1559
|
+
},
|
|
1560
|
+
// Every project server compiles the same asset names
|
|
1561
|
+
// (`static/js/runner.js`, ...). With `dev.writeToDisk`
|
|
1562
|
+
// (debug mode) the middleware serves from disk, so a
|
|
1563
|
+
// shared dist dir would be last-writer-wins and one
|
|
1564
|
+
// project's server would deliver another project's
|
|
1565
|
+
// bundle — keep each project's output isolated, inside
|
|
1566
|
+
// the run's temp dir so teardown removes it.
|
|
1567
|
+
distPath: {
|
|
1568
|
+
root: join(
|
|
1569
|
+
tempDir,
|
|
1570
|
+
'server',
|
|
1571
|
+
toSafeVarName(project.environmentName),
|
|
1572
|
+
),
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
tools: {
|
|
1576
|
+
swc: (swcConfig) => {
|
|
1577
|
+
// Fixture dependency discovery reads callback parameters
|
|
1578
|
+
// through Function#toString(). Playwright's supported
|
|
1579
|
+
// browsers all support parameter destructuring, so keep
|
|
1580
|
+
// that syntax intact in the browser test bundle.
|
|
1581
|
+
swcConfig.env ??= {};
|
|
1582
|
+
swcConfig.env.exclude = Array.from(
|
|
1583
|
+
new Set([
|
|
1584
|
+
...(swcConfig.env.exclude ?? []),
|
|
1585
|
+
'transform-parameters',
|
|
1586
|
+
]),
|
|
1587
|
+
);
|
|
1588
|
+
},
|
|
1589
|
+
rspack: (rspackConfig) => {
|
|
1590
|
+
rspackConfig.mode = 'development';
|
|
1591
|
+
// Web parameterization of the node mock transform:
|
|
1592
|
+
// RstestPlugin (hoist + path injection), the
|
|
1593
|
+
// `@rstest/core` global external, and
|
|
1594
|
+
// `exportsPresence: 'warn'`.
|
|
1595
|
+
applyWebMockRspackConfig(rspackConfig, {
|
|
1596
|
+
rspack,
|
|
1597
|
+
rootPath: project.rootPath,
|
|
1598
|
+
});
|
|
1599
|
+
// lazyCompilation's only delivery transport is the HMR
|
|
1600
|
+
// runtime, so it follows the same gate as HMR (see
|
|
1601
|
+
// `shouldEnableBrowserHmr`): headed watch only, everything
|
|
1602
|
+
// else compiles eagerly.
|
|
1603
|
+
rspackConfig.lazyCompilation = enableHmr
|
|
1604
|
+
? createBrowserLazyCompilationConfig(setupFiles)
|
|
1605
|
+
: false;
|
|
1606
|
+
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
1607
|
+
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
1608
|
+
|
|
1609
|
+
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
1610
|
+
|
|
1611
|
+
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
1612
|
+
// This preserves the sourcemap chain for inline snapshot support
|
|
1613
|
+
// See: https://rspack.rs/config/module-rules#rulesextractsourcemap
|
|
1614
|
+
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
1615
|
+
rspackConfig.module = rspackConfig.module || {};
|
|
1616
|
+
rspackConfig.module.rules =
|
|
1617
|
+
rspackConfig.module.rules || [];
|
|
1618
|
+
rspackConfig.module.rules.unshift({
|
|
1619
|
+
test: /\.js$/,
|
|
1620
|
+
include: browserRuntimeDir,
|
|
1621
|
+
extractSourceMap: true,
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
if (isDebug()) {
|
|
1625
|
+
logger.log(
|
|
1626
|
+
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
},
|
|
1631
|
+
},
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
// Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
|
|
1635
|
+
// In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
|
|
1636
|
+
// This must be done after mergeEnvironmentConfig to ensure highest priority.
|
|
1637
|
+
merged.source = merged.source || {};
|
|
1638
|
+
merged.source.entry = {
|
|
1639
|
+
runner: resolveBrowserFile('client/entry.ts'),
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
return merged;
|
|
1643
|
+
},
|
|
1644
|
+
// Execute after all other plugins to ensure rstest's entry config has the highest priority
|
|
1645
|
+
order: 'post',
|
|
1646
|
+
});
|
|
1647
|
+
},
|
|
1648
|
+
},
|
|
1649
|
+
]);
|
|
1650
|
+
|
|
1651
|
+
// Register watch plugin if in watch mode
|
|
1652
|
+
if (isWatchMode && onTriggerRerun) {
|
|
1653
|
+
rsbuildInstance.addPlugins([
|
|
1654
|
+
{
|
|
1655
|
+
name: 'rstest:browser-watch',
|
|
1656
|
+
setup(api) {
|
|
1657
|
+
api.onBeforeDevCompile(() => {
|
|
1658
|
+
watchState.compileStartTimes.set(project.name, Date.now());
|
|
1659
|
+
if (!watchState.hooksEnabled) {
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
api.onAfterDevCompile(async ({ stats }) => {
|
|
1666
|
+
const compileStart = watchState.compileStartTimes.get(
|
|
1667
|
+
project.name,
|
|
1668
|
+
);
|
|
1669
|
+
if (compileStart !== undefined) {
|
|
1670
|
+
watchState.compileStartTimes.delete(project.name);
|
|
1671
|
+
// Only change-triggered compiles feed the pending rerun's
|
|
1672
|
+
// build phase (the initial build is the initial run's
|
|
1673
|
+
// buildTime). Parallel project compiles overlap; the longest
|
|
1674
|
+
// one bounds the rerun's build phase.
|
|
1675
|
+
if (watchState.hooksEnabled) {
|
|
1676
|
+
watchState.pendingBuildTimeMs = Math.max(
|
|
1677
|
+
watchState.pendingBuildTimeMs,
|
|
1678
|
+
Date.now() - compileStart,
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
// Collect hashes even during initial build to establish baseline
|
|
1683
|
+
if (stats) {
|
|
1684
|
+
// This compiler only ever holds this project's entries; the
|
|
1685
|
+
// diff baseline is keyed per project accordingly.
|
|
1686
|
+
const [projectEntry] = await collectProjectEntries(context, [
|
|
1687
|
+
project,
|
|
1688
|
+
]);
|
|
1689
|
+
const entryTestFiles = new Set<string>(
|
|
1690
|
+
collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
|
|
1691
|
+
(file) => file.testPath,
|
|
1692
|
+
),
|
|
1693
|
+
);
|
|
1694
|
+
const setupFiles = new Set<string>(
|
|
1695
|
+
(projectEntry?.setupFiles ?? []).map((file) =>
|
|
1696
|
+
normalize(file),
|
|
1697
|
+
),
|
|
1698
|
+
);
|
|
1699
|
+
|
|
1700
|
+
let state = watchState.invalidation.get(project.name);
|
|
1701
|
+
if (!state) {
|
|
1702
|
+
state = {};
|
|
1703
|
+
watchState.invalidation.set(project.name, state);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const statsJson = stats.toJson({ all: true });
|
|
1707
|
+
const affected = getAffectedTestFiles({
|
|
1708
|
+
chunks: statsJson.chunks,
|
|
1709
|
+
entryTestFiles,
|
|
1710
|
+
setupFiles,
|
|
1711
|
+
state,
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
if (affected.length > 0) {
|
|
1715
|
+
const pending =
|
|
1716
|
+
watchState.pendingAffectedTestFiles.get(project.name) ??
|
|
1717
|
+
new Set<string>();
|
|
1718
|
+
for (const file of affected) {
|
|
1719
|
+
pending.add(file);
|
|
1720
|
+
}
|
|
1721
|
+
watchState.pendingAffectedTestFiles.set(
|
|
1722
|
+
project.name,
|
|
1723
|
+
pending,
|
|
1724
|
+
);
|
|
1725
|
+
logger.debug(
|
|
1726
|
+
`[Watch] Affected test files: ${affected.join(', ')}`,
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (!watchState.hooksEnabled) {
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
await onTriggerRerun();
|
|
1736
|
+
});
|
|
1737
|
+
},
|
|
1738
|
+
},
|
|
1739
|
+
]);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (skipProviderLaunch) {
|
|
1743
|
+
await rsbuildInstance.initConfigs({ action: 'dev' });
|
|
1744
|
+
return {
|
|
1745
|
+
projectName: project.name,
|
|
1746
|
+
environmentName: project.environmentName,
|
|
1747
|
+
rsbuildInstance,
|
|
1748
|
+
devServer: {
|
|
1749
|
+
close: async () => undefined,
|
|
1750
|
+
} as RsbuildDevServer,
|
|
1751
|
+
port: 0,
|
|
1752
|
+
manifestPath,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Register coverage plugin if this project enables coverage
|
|
1757
|
+
const coverage = project.normalizedConfig.coverage;
|
|
1758
|
+
if (coverage?.enabled && context.command !== 'list') {
|
|
1759
|
+
const { pluginCoverage } = await loadCoverageProvider(
|
|
1760
|
+
coverage,
|
|
1761
|
+
context.rootPath,
|
|
1762
|
+
);
|
|
1763
|
+
rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const devServer = await rsbuildInstance.createDevServer({
|
|
1767
|
+
getPortSilently: true,
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
if (isDebug()) {
|
|
1771
|
+
await rsbuildInstance.inspectConfig({
|
|
1772
|
+
writeToDisk: true,
|
|
1773
|
+
// The server's own distPath is isolated per project inside the run's
|
|
1774
|
+
// temp dir (removed at teardown); keep the debug artifacts at the
|
|
1775
|
+
// project's stable dist root so they survive the run and stay where
|
|
1776
|
+
// the docs point users to.
|
|
1777
|
+
outputPath: resolve(
|
|
1778
|
+
context.rootPath,
|
|
1779
|
+
context.normalizedConfig.output.distPath.root,
|
|
1780
|
+
'.rsbuild',
|
|
1781
|
+
),
|
|
1782
|
+
extraConfigs: {
|
|
1783
|
+
rstest: {
|
|
1784
|
+
...context.normalizedConfig,
|
|
1785
|
+
projects: [project.normalizedConfig],
|
|
1786
|
+
},
|
|
1787
|
+
},
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
devServer.middlewares.use(
|
|
1792
|
+
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
1793
|
+
if (!req.url) {
|
|
1794
|
+
next();
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1798
|
+
if (url.pathname === '/__open-in-editor') {
|
|
1799
|
+
const file = url.searchParams.get('file');
|
|
1800
|
+
if (!file) {
|
|
1801
|
+
res.statusCode = 400;
|
|
1802
|
+
res.end('Missing file');
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
try {
|
|
1806
|
+
await openEditor([{ file }]);
|
|
1807
|
+
res.statusCode = 204;
|
|
1808
|
+
res.end();
|
|
1809
|
+
} catch (error) {
|
|
1810
|
+
logger.debug(
|
|
1811
|
+
`[Browser UI] Failed to open editor: ${String(error)}`,
|
|
1812
|
+
);
|
|
1813
|
+
res.statusCode = 500;
|
|
1814
|
+
res.end('Failed to open editor');
|
|
1815
|
+
}
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
if (url.pathname === '/runner.html') {
|
|
1819
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1820
|
+
res.end(htmlTemplate);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
next();
|
|
1824
|
+
},
|
|
1825
|
+
);
|
|
1826
|
+
|
|
1827
|
+
const { port: listenPort } = await devServer.listen();
|
|
1828
|
+
const port = resolveListenPort(listenPort, devServer.httpServer);
|
|
1829
|
+
|
|
1830
|
+
return {
|
|
1831
|
+
projectName: project.name,
|
|
1832
|
+
environmentName: project.environmentName,
|
|
1833
|
+
rsbuildInstance,
|
|
1834
|
+
devServer,
|
|
1835
|
+
port,
|
|
1836
|
+
manifestPath,
|
|
1837
|
+
};
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
// Build each project's server sequentially. Servers must bind ports one at a
|
|
1841
|
+
// time: projects may share a configured port and rely on strictPort:false
|
|
1842
|
+
// bumping to the next free one, which races under concurrent listen().
|
|
1843
|
+
const projectServers = new Map<string, BrowserProjectServer>();
|
|
1844
|
+
try {
|
|
1845
|
+
for (const [index, project] of browserProjects.entries()) {
|
|
1846
|
+
const server = await buildProjectServer(project, index === 0);
|
|
1847
|
+
projectServers.set(server.projectName, server);
|
|
1848
|
+
}
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
await closeAllProjectServers(projectServers.values());
|
|
1851
|
+
throw error;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (skipProviderLaunch) {
|
|
1855
|
+
return createRuntimeWithoutProvider();
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
|
|
1859
|
+
// otherwise) and index 0 is the designated container origin.
|
|
1860
|
+
const containerServer = projectServers.get(browserProjects[0]!.name)!;
|
|
1861
|
+
|
|
1862
|
+
// Create WebSocket server on an available port
|
|
1863
|
+
// Using port: 0 lets the OS assign an available port, avoiding conflicts
|
|
1864
|
+
// when the fixed port (e.g., container port + 1) is already in use
|
|
1865
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
1866
|
+
await new Promise<void>((resolve, reject) => {
|
|
1867
|
+
wss.once('listening', resolve);
|
|
1868
|
+
wss.once('error', reject);
|
|
1869
|
+
});
|
|
1870
|
+
const wsPort = (wss.address() as AddressInfo).port;
|
|
1871
|
+
logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
|
|
1872
|
+
|
|
1873
|
+
const browserName = browserLaunchOptions.browser ?? 'chromium';
|
|
1874
|
+
try {
|
|
1875
|
+
const providerImplementation = getBrowserProviderImplementation(
|
|
1876
|
+
browserLaunchOptions.provider,
|
|
1877
|
+
);
|
|
1878
|
+
const runtime = await providerImplementation.launchRuntime({
|
|
1879
|
+
browserName,
|
|
1880
|
+
headless: forceHeadless ?? browserLaunchOptions.headless,
|
|
1881
|
+
providerOptions: browserLaunchOptions.providerOptions,
|
|
1882
|
+
});
|
|
1883
|
+
return {
|
|
1884
|
+
projectServers,
|
|
1885
|
+
containerServer,
|
|
1886
|
+
browser: runtime.browser,
|
|
1887
|
+
browserLaunchOptions,
|
|
1888
|
+
wsPort,
|
|
1889
|
+
tempDir,
|
|
1890
|
+
setContainerOptions,
|
|
1891
|
+
dispatchHandlers,
|
|
1892
|
+
wss,
|
|
1893
|
+
projectEntries,
|
|
1894
|
+
watchState,
|
|
1895
|
+
};
|
|
1896
|
+
} catch (error) {
|
|
1897
|
+
wss.close();
|
|
1898
|
+
await closeAllProjectServers(projectServers.values());
|
|
1899
|
+
throw error;
|
|
1900
|
+
}
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
export async function resolveProjectEntries(
|
|
1904
|
+
context: RstestContext,
|
|
1905
|
+
shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
|
|
1906
|
+
browserProjects: ProjectContext[],
|
|
1907
|
+
): Promise<BrowserProjectEntries[]> {
|
|
1908
|
+
if (shardedEntries) {
|
|
1909
|
+
const projectEntries: BrowserProjectEntries[] = [];
|
|
1910
|
+
for (const project of browserProjects) {
|
|
1911
|
+
const entryInfo = shardedEntries.get(project.environmentName);
|
|
1912
|
+
if (entryInfo && Object.keys(entryInfo.entries).length > 0) {
|
|
1913
|
+
const setup = getSetupFiles(
|
|
1914
|
+
project.normalizedConfig.setupFiles,
|
|
1915
|
+
project.rootPath,
|
|
1916
|
+
);
|
|
1917
|
+
projectEntries.push({
|
|
1918
|
+
project,
|
|
1919
|
+
setupFiles: Object.values(setup),
|
|
1920
|
+
testFiles: Object.values(entryInfo.entries),
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return projectEntries;
|
|
1925
|
+
}
|
|
1926
|
+
return collectProjectEntries(context, browserProjects);
|
|
1927
|
+
}
|