@rstest/browser 0.10.6 → 0.11.1
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/{854.c59f71483d.js → 328.37af068e21.js} +2712 -2786
- package/dist/browser-container/container-static/js/328.37af068e21.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.fedc31dff3.js → index.c8fe4ff310.js} +58 -56
- package/dist/browser-container/index.html +1 -1
- package/dist/browser.js +1 -1
- package/dist/hostController.d.ts +17 -1
- package/dist/index.js +443 -365
- package/dist/protocol.d.ts +7 -1
- package/dist/providers/index.d.ts +3 -0
- package/package.json +7 -7
- package/src/client/entry.ts +25 -23
- package/src/client/formatConsole.ts +77 -0
- package/src/client/locator.ts +1 -5
- package/src/hostController.ts +671 -526
- package/src/protocol.ts +9 -5
- package/src/providers/index.ts +3 -0
- package/src/providers/playwright/runtime.ts +11 -6
- package/dist/browser-container/container-static/js/854.c59f71483d.js.LICENSE.txt +0 -1
- package/src/client/fakeTimersStub.ts +0 -91
- /package/dist/{626.js → 33.js} +0 -0
package/src/hostController.ts
CHANGED
|
@@ -101,15 +101,6 @@ type RsbuildInstance = rsbuild.RsbuildInstance;
|
|
|
101
101
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
102
102
|
const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
* Extra time added on top of a file's `testTimeout` before the host gives up
|
|
106
|
-
* waiting for that file's RPC to settle. Covers fixed per-file overhead (page
|
|
107
|
-
* navigation, runner boot) that is not part of the user's test budget. The
|
|
108
|
-
* headless and headed scheduling paths both apply this same buffer, so it lives
|
|
109
|
-
* here as one constant to keep their per-file timeout semantics identical.
|
|
110
|
-
*/
|
|
111
|
-
const PER_FILE_TIMEOUT_BUFFER_MS = 30_000;
|
|
112
|
-
|
|
113
104
|
/**
|
|
114
105
|
* Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
|
|
115
106
|
* mode. Browser host runs every test file inside the same Node process, so
|
|
@@ -138,10 +129,6 @@ const serializeForInlineScript = (value: unknown): string => {
|
|
|
138
129
|
// Type Definitions
|
|
139
130
|
// ============================================================================
|
|
140
131
|
|
|
141
|
-
type VirtualModulesPluginInstance = InstanceType<
|
|
142
|
-
(typeof rspack.experiments)['VirtualModulesPlugin']
|
|
143
|
-
>;
|
|
144
|
-
|
|
145
132
|
type BrowserProjectEntries = {
|
|
146
133
|
project: ProjectContext;
|
|
147
134
|
setupFiles: string[];
|
|
@@ -415,16 +402,30 @@ class ContainerRpcManager {
|
|
|
415
402
|
// Browser Runtime - Core runtime state
|
|
416
403
|
// ============================================================================
|
|
417
404
|
|
|
418
|
-
|
|
405
|
+
// One isolated rsbuild instance + dev server per browser project. Physical
|
|
406
|
+
// isolation (separate compiler, dev server, port, virtual manifest, runner)
|
|
407
|
+
// prevents one project's build/resolve config from leaking into another's
|
|
408
|
+
// compilation and avoids shared-dev-server races (chunk filenames,
|
|
409
|
+
// lazyCompilation backend) between projects.
|
|
410
|
+
type BrowserProjectServer = {
|
|
411
|
+
projectName: string;
|
|
412
|
+
environmentName: string;
|
|
419
413
|
rsbuildInstance: RsbuildInstance;
|
|
420
414
|
devServer: RsbuildDevServer;
|
|
415
|
+
port: number;
|
|
416
|
+
manifestPath: string;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
type BrowserRuntime = {
|
|
420
|
+
// Per-project servers, keyed by project name.
|
|
421
|
+
projectServers: Map<string, BrowserProjectServer>;
|
|
422
|
+
// The server that hosts the container UI HTML (headed mode). The WebSocket
|
|
423
|
+
// server below is shared and reachable from any origin.
|
|
424
|
+
containerServer: BrowserProjectServer;
|
|
421
425
|
browser: BrowserProviderBrowser;
|
|
422
426
|
browserLaunchOptions: BrowserLaunchOptions;
|
|
423
|
-
port: number;
|
|
424
427
|
wsPort: number;
|
|
425
|
-
manifestPath: string;
|
|
426
428
|
tempDir: string;
|
|
427
|
-
manifestPlugin: VirtualModulesPluginInstance;
|
|
428
429
|
containerPage?: BrowserProviderPage;
|
|
429
430
|
containerContext?: BrowserProviderContext;
|
|
430
431
|
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
@@ -600,8 +601,24 @@ export const createBrowserLazyCompilationConfig = (
|
|
|
600
601
|
};
|
|
601
602
|
};
|
|
602
603
|
|
|
604
|
+
/**
|
|
605
|
+
* HMR — and the lazyCompilation transport it carries — is wired only for headed
|
|
606
|
+
* watch, the sole path that reuses a persistent page and applies module updates
|
|
607
|
+
* in place. Headless always loads each test file in a fresh page (pulling the
|
|
608
|
+
* latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
|
|
609
|
+
* so pushing HMR updates there is dead weight that only races factory
|
|
610
|
+
* registration for chunk-split node_modules (rspack#11922) and lets
|
|
611
|
+
* lazyCompilation's accept-chain walk abort the next spec when no boundary
|
|
612
|
+
* exists (#1472). Disabling HMR does not make watch rebuilds any less
|
|
613
|
+
* incremental — HMR is only the client push transport.
|
|
614
|
+
*/
|
|
615
|
+
export const shouldEnableBrowserHmr = (
|
|
616
|
+
isWatchMode: boolean,
|
|
617
|
+
isHeadless: boolean,
|
|
618
|
+
): boolean => isWatchMode && !isHeadless;
|
|
619
|
+
|
|
603
620
|
export const createBrowserRsbuildDevConfig = (
|
|
604
|
-
|
|
621
|
+
enableHmr: boolean,
|
|
605
622
|
): {
|
|
606
623
|
writeToDisk: boolean;
|
|
607
624
|
hmr: boolean;
|
|
@@ -611,9 +628,10 @@ export const createBrowserRsbuildDevConfig = (
|
|
|
611
628
|
} => {
|
|
612
629
|
return {
|
|
613
630
|
writeToDisk: isDebug(),
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
631
|
+
// `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
|
|
632
|
+
// path that reuses a page. See that helper for why fresh-page runs (headless,
|
|
633
|
+
// or any one-shot) must not receive HMR pushes.
|
|
634
|
+
hmr: enableHmr,
|
|
617
635
|
client: {
|
|
618
636
|
logLevel: 'error' as const,
|
|
619
637
|
},
|
|
@@ -1055,17 +1073,18 @@ const ensureConsistentBrowserLaunchOptions = (
|
|
|
1055
1073
|
|
|
1056
1074
|
for (const project of projects.slice(1)) {
|
|
1057
1075
|
const options = getBrowserLaunchOptions(project);
|
|
1076
|
+
// Each browser project now runs on its own rsbuild dev server, so ports may
|
|
1077
|
+
// differ per project. Only the shared single Playwright browser forces
|
|
1078
|
+
// provider/browser/headless/providerOptions to match across projects.
|
|
1058
1079
|
if (
|
|
1059
1080
|
options.provider !== firstOptions.provider ||
|
|
1060
1081
|
options.browser !== firstOptions.browser ||
|
|
1061
1082
|
options.headless !== firstOptions.headless ||
|
|
1062
|
-
options.port !== firstOptions.port ||
|
|
1063
|
-
options.strictPort !== firstOptions.strictPort ||
|
|
1064
1083
|
!isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
|
|
1065
1084
|
) {
|
|
1066
1085
|
throw new Error(
|
|
1067
1086
|
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
1068
|
-
'All browser-enabled projects in one run must share provider/browser/headless/
|
|
1087
|
+
'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
|
|
1069
1088
|
);
|
|
1070
1089
|
}
|
|
1071
1090
|
}
|
|
@@ -1173,12 +1192,38 @@ const toSafeVarName = (name: string): string => {
|
|
|
1173
1192
|
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
1174
1193
|
};
|
|
1175
1194
|
|
|
1195
|
+
// Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
|
|
1196
|
+
// `./<path-relative-to-project-root>` with forward slashes. The runtime derives
|
|
1197
|
+
// the same key from the target test file, so the non-watch import map below must
|
|
1198
|
+
// key by the identical form for `loadTest(key)` to resolve.
|
|
1199
|
+
export const toContextKey = (
|
|
1200
|
+
filePath: string,
|
|
1201
|
+
projectRootPosix: string,
|
|
1202
|
+
): string => {
|
|
1203
|
+
const posixPath = normalize(filePath);
|
|
1204
|
+
// Only strip the root at a path boundary: a bare `startsWith` would mangle a
|
|
1205
|
+
// sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
|
|
1206
|
+
const withinRoot =
|
|
1207
|
+
posixPath === projectRootPosix ||
|
|
1208
|
+
posixPath.startsWith(`${projectRootPosix}/`);
|
|
1209
|
+
if (!withinRoot) {
|
|
1210
|
+
// Test file outside the project root: use the absolute path as the key so
|
|
1211
|
+
// the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
|
|
1212
|
+
// key would be re-rooted under projectRoot and point at a nonexistent file.
|
|
1213
|
+
return posixPath;
|
|
1214
|
+
}
|
|
1215
|
+
const rel = posixPath.slice(projectRootPosix.length);
|
|
1216
|
+
return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1176
1219
|
const generateManifestModule = ({
|
|
1177
1220
|
manifestPath,
|
|
1178
1221
|
entries,
|
|
1222
|
+
isWatchMode,
|
|
1179
1223
|
}: {
|
|
1180
1224
|
manifestPath: string;
|
|
1181
1225
|
entries: BrowserProjectEntries[];
|
|
1226
|
+
isWatchMode: boolean;
|
|
1182
1227
|
}): string => {
|
|
1183
1228
|
const manifestDirPosix = normalize(dirname(manifestPath));
|
|
1184
1229
|
|
|
@@ -1224,30 +1269,56 @@ const generateManifestModule = ({
|
|
|
1224
1269
|
lines.push('};');
|
|
1225
1270
|
lines.push('');
|
|
1226
1271
|
|
|
1227
|
-
// 3. Test context for each project
|
|
1272
|
+
// 3. Test context for each project. Both branches expose the same shape as a
|
|
1273
|
+
// webpackContext (callable by key, plus `keys()`), consumed in section 4.
|
|
1228
1274
|
lines.push('// Test context for each project');
|
|
1229
|
-
for (const { project } of entries) {
|
|
1275
|
+
for (const { project, testFiles } of entries) {
|
|
1230
1276
|
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
1231
1277
|
const projectRootPosix = normalize(project.rootPath);
|
|
1232
|
-
const includeRegExp = globPatternsToRegExp(
|
|
1233
|
-
project.normalizedConfig.include,
|
|
1234
|
-
);
|
|
1235
|
-
const excludePatterns = project.normalizedConfig.exclude.patterns;
|
|
1236
|
-
const excludeRegExp = createBrowserContextExcludeRegExp(
|
|
1237
|
-
excludePatterns,
|
|
1238
|
-
projectRootPosix,
|
|
1239
|
-
);
|
|
1240
1278
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1279
|
+
if (isWatchMode) {
|
|
1280
|
+
// Watch mode keeps the include-glob context so newly added files are
|
|
1281
|
+
// picked up on rebuild via `keys()` without regenerating the manifest.
|
|
1282
|
+
// `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
|
|
1283
|
+
// works with or without lazyCompilation; headed watch additionally layers
|
|
1284
|
+
// lazyCompilation on top to keep the initial build cheap.
|
|
1285
|
+
const includeRegExp = globPatternsToRegExp(
|
|
1286
|
+
project.normalizedConfig.include,
|
|
1287
|
+
);
|
|
1288
|
+
const excludeRegExp = createBrowserContextExcludeRegExp(
|
|
1289
|
+
project.normalizedConfig.exclude.patterns,
|
|
1290
|
+
projectRootPosix,
|
|
1291
|
+
);
|
|
1292
|
+
lines.push(
|
|
1293
|
+
`const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
|
|
1294
|
+
);
|
|
1295
|
+
lines.push(' recursive: true,');
|
|
1296
|
+
lines.push(` regExp: ${includeRegExp.toString()},`);
|
|
1297
|
+
if (excludeRegExp) {
|
|
1298
|
+
lines.push(` exclude: ${excludeRegExp.toString()},`);
|
|
1299
|
+
}
|
|
1300
|
+
lines.push(" mode: 'lazy',");
|
|
1301
|
+
lines.push('});');
|
|
1302
|
+
} else {
|
|
1303
|
+
// One-shot runs: the file set is fixed and already filtered, so emit an
|
|
1304
|
+
// explicit lazy-import map (one chunk per literal `import()`, like the
|
|
1305
|
+
// setup loaders above). The eager, non-lazyCompilation build then compiles
|
|
1306
|
+
// only the run set instead of every included test file.
|
|
1307
|
+
lines.push(`const ${varName}_modules = {`);
|
|
1308
|
+
for (const filePath of testFiles) {
|
|
1309
|
+
const key = toContextKey(filePath, projectRootPosix);
|
|
1310
|
+
const importPath = toRelativeImport(filePath);
|
|
1311
|
+
lines.push(
|
|
1312
|
+
` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
lines.push('};');
|
|
1316
|
+
lines.push(
|
|
1317
|
+
`const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
|
|
1318
|
+
);
|
|
1319
|
+
lines.push(` keys: () => Object.keys(${varName}_modules),`);
|
|
1320
|
+
lines.push('});');
|
|
1248
1321
|
}
|
|
1249
|
-
lines.push(" mode: 'lazy',");
|
|
1250
|
-
lines.push('});');
|
|
1251
1322
|
lines.push('');
|
|
1252
1323
|
}
|
|
1253
1324
|
|
|
@@ -1304,6 +1375,27 @@ const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
|
|
|
1304
1375
|
// Browser Runtime Lifecycle
|
|
1305
1376
|
// ============================================================================
|
|
1306
1377
|
|
|
1378
|
+
const closeAllProjectServers = (
|
|
1379
|
+
servers: Iterable<BrowserProjectServer>,
|
|
1380
|
+
): Promise<unknown> =>
|
|
1381
|
+
Promise.allSettled([...servers].map((server) => server.devServer.close()));
|
|
1382
|
+
|
|
1383
|
+
// Copy a proxied fetch Response's status + headers onto the Node response,
|
|
1384
|
+
// dropping content-length (the body is re-sent, so the original length may not
|
|
1385
|
+
// match).
|
|
1386
|
+
const copyProxyResponseHeaders = (
|
|
1387
|
+
response: Response,
|
|
1388
|
+
res: ServerResponse,
|
|
1389
|
+
): void => {
|
|
1390
|
+
res.statusCode = response.status;
|
|
1391
|
+
response.headers.forEach((value, key) => {
|
|
1392
|
+
if (key.toLowerCase() === 'content-length') {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
res.setHeader(key, value);
|
|
1396
|
+
});
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1307
1399
|
const destroyBrowserRuntime = async (
|
|
1308
1400
|
runtime: BrowserRuntime,
|
|
1309
1401
|
): Promise<void> => {
|
|
@@ -1312,11 +1404,7 @@ const destroyBrowserRuntime = async (
|
|
|
1312
1404
|
} catch {
|
|
1313
1405
|
// ignore
|
|
1314
1406
|
}
|
|
1315
|
-
|
|
1316
|
-
await runtime.devServer?.close?.();
|
|
1317
|
-
} catch {
|
|
1318
|
-
// ignore
|
|
1319
|
-
}
|
|
1407
|
+
await closeAllProjectServers(runtime.projectServers.values());
|
|
1320
1408
|
try {
|
|
1321
1409
|
runtime.wss?.close();
|
|
1322
1410
|
} catch {
|
|
@@ -1367,8 +1455,7 @@ const registerWatchCleanup = (): void => {
|
|
|
1367
1455
|
|
|
1368
1456
|
const createBrowserRuntime = async ({
|
|
1369
1457
|
context,
|
|
1370
|
-
|
|
1371
|
-
manifestSource,
|
|
1458
|
+
projectEntries,
|
|
1372
1459
|
tempDir,
|
|
1373
1460
|
isWatchMode,
|
|
1374
1461
|
onTriggerRerun,
|
|
@@ -1377,8 +1464,7 @@ const createBrowserRuntime = async ({
|
|
|
1377
1464
|
forceHeadless,
|
|
1378
1465
|
}: {
|
|
1379
1466
|
context: RstestContext;
|
|
1380
|
-
|
|
1381
|
-
manifestSource: string;
|
|
1467
|
+
projectEntries: BrowserProjectEntries[];
|
|
1382
1468
|
tempDir: string;
|
|
1383
1469
|
isWatchMode: boolean;
|
|
1384
1470
|
onTriggerRerun?: () => Promise<void>;
|
|
@@ -1387,10 +1473,7 @@ const createBrowserRuntime = async ({
|
|
|
1387
1473
|
/** Force headless mode regardless of user config (used for list command) */
|
|
1388
1474
|
forceHeadless?: boolean;
|
|
1389
1475
|
}): Promise<BrowserRuntime> => {
|
|
1390
|
-
|
|
1391
|
-
[manifestPath]: manifestSource,
|
|
1392
|
-
});
|
|
1393
|
-
|
|
1476
|
+
// ---- Shared singletons (created once, wired into every project server) ----
|
|
1394
1477
|
const containerHtmlTemplate = containerDistPath
|
|
1395
1478
|
? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
|
|
1396
1479
|
: null;
|
|
@@ -1411,12 +1494,6 @@ const createBrowserRuntime = async ({
|
|
|
1411
1494
|
};
|
|
1412
1495
|
|
|
1413
1496
|
const browserProjects = getBrowserProjects(context);
|
|
1414
|
-
const projectByEnvironmentName = new Map(
|
|
1415
|
-
browserProjects.map((project) => [project.environmentName, project]),
|
|
1416
|
-
);
|
|
1417
|
-
const userPlugins = browserProjects.flatMap(
|
|
1418
|
-
(project) => project.normalizedConfig.plugins || [],
|
|
1419
|
-
);
|
|
1420
1497
|
const browserLaunchOptions =
|
|
1421
1498
|
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1422
1499
|
|
|
@@ -1425,8 +1502,9 @@ const createBrowserRuntime = async ({
|
|
|
1425
1502
|
import.meta.resolve('@rstest/core/internal/browser-runtime'),
|
|
1426
1503
|
);
|
|
1427
1504
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1505
|
+
// Shared by every project — only the per-project `@rstest/browser-manifest`
|
|
1506
|
+
// alias varies (one virtual manifest per server).
|
|
1507
|
+
const staticRstestAliases = {
|
|
1430
1508
|
// User test code: import { describe, it } from '@rstest/core'
|
|
1431
1509
|
'@rstest/core': resolveBrowserFile('client/public.ts'),
|
|
1432
1510
|
// User test code: import { page } from '@rstest/browser'
|
|
@@ -1434,228 +1512,17 @@ const createBrowserRuntime = async ({
|
|
|
1434
1512
|
// Browser runtime APIs for entry.ts and public.ts
|
|
1435
1513
|
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
1436
1514
|
'@rstest/core/internal/browser-runtime': browserRuntimePath,
|
|
1437
|
-
'@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'),
|
|
1438
1515
|
};
|
|
1439
1516
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
printUrls: false,
|
|
1448
|
-
port: browserLaunchOptions.port ?? 4000,
|
|
1449
|
-
strictPort: browserLaunchOptions.strictPort,
|
|
1450
|
-
},
|
|
1451
|
-
dev: createBrowserRsbuildDevConfig(isWatchMode),
|
|
1452
|
-
environments: {
|
|
1453
|
-
...Object.fromEntries(
|
|
1454
|
-
browserProjects.map((project) => [project.environmentName, {}]),
|
|
1455
|
-
),
|
|
1456
|
-
},
|
|
1457
|
-
},
|
|
1458
|
-
});
|
|
1459
|
-
|
|
1460
|
-
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1461
|
-
rsbuildInstance.addPlugins([
|
|
1462
|
-
{
|
|
1463
|
-
name: 'rstest:browser-user-config',
|
|
1464
|
-
setup(api) {
|
|
1465
|
-
// Internal extension entry: register host dispatch handlers without
|
|
1466
|
-
// coupling scheduling to individual capability implementations.
|
|
1467
|
-
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1468
|
-
'rstest:browser',
|
|
1469
|
-
{
|
|
1470
|
-
registerDispatchHandler: (
|
|
1471
|
-
namespace: string,
|
|
1472
|
-
handler: BrowserDispatchHandler,
|
|
1473
|
-
) => {
|
|
1474
|
-
dispatchHandlers.set(namespace, handler);
|
|
1475
|
-
},
|
|
1476
|
-
},
|
|
1477
|
-
);
|
|
1478
|
-
|
|
1479
|
-
api.modifyEnvironmentConfig({
|
|
1480
|
-
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1481
|
-
const project = projectByEnvironmentName.get(name);
|
|
1482
|
-
if (!project) {
|
|
1483
|
-
return config;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
const userRsbuildConfig = project.normalizedConfig;
|
|
1487
|
-
const buildCache = resolveProjectBuildCache({
|
|
1488
|
-
context,
|
|
1489
|
-
project,
|
|
1490
|
-
});
|
|
1491
|
-
const setupFiles = Object.values(
|
|
1492
|
-
getSetupFiles(
|
|
1493
|
-
project.normalizedConfig.setupFiles,
|
|
1494
|
-
project.rootPath,
|
|
1495
|
-
),
|
|
1496
|
-
);
|
|
1497
|
-
// rspack `define` replaces `process.env` / `import.meta.env` with
|
|
1498
|
-
// this literal expression. JSON.stringify reproduces the exact
|
|
1499
|
-
// double-quoted `"rstest.env"` text, so the owned key can never
|
|
1500
|
-
// drift from the runtime `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
|
|
1501
|
-
const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
|
|
1502
|
-
RSTEST_ENV_SYMBOL_KEY,
|
|
1503
|
-
)})]`;
|
|
1504
|
-
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
1505
|
-
const merged = mergeEnvironmentConfig(
|
|
1506
|
-
config,
|
|
1507
|
-
{
|
|
1508
|
-
...userRsbuildConfig,
|
|
1509
|
-
performance: buildCache
|
|
1510
|
-
? {
|
|
1511
|
-
...userRsbuildConfig.performance,
|
|
1512
|
-
buildCache,
|
|
1513
|
-
}
|
|
1514
|
-
: userRsbuildConfig.performance,
|
|
1515
|
-
},
|
|
1516
|
-
{
|
|
1517
|
-
resolve: {
|
|
1518
|
-
alias: rstestInternalAliases,
|
|
1519
|
-
},
|
|
1520
|
-
source: {
|
|
1521
|
-
define: {
|
|
1522
|
-
'process.env': rstestEnvDefine,
|
|
1523
|
-
'import.meta.env': rstestEnvDefine,
|
|
1524
|
-
},
|
|
1525
|
-
},
|
|
1526
|
-
output: {
|
|
1527
|
-
target: 'web',
|
|
1528
|
-
// Enable source map for inline snapshot support
|
|
1529
|
-
sourceMap: {
|
|
1530
|
-
js: 'source-map',
|
|
1531
|
-
},
|
|
1532
|
-
},
|
|
1533
|
-
tools: {
|
|
1534
|
-
rspack: (rspackConfig) => {
|
|
1535
|
-
rspackConfig.mode = 'development';
|
|
1536
|
-
rspackConfig.lazyCompilation =
|
|
1537
|
-
createBrowserLazyCompilationConfig(setupFiles);
|
|
1538
|
-
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
1539
|
-
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
1540
|
-
|
|
1541
|
-
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
1542
|
-
|
|
1543
|
-
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
1544
|
-
// This preserves the sourcemap chain for inline snapshot support
|
|
1545
|
-
// See: https://rspack.dev/config/module-rules#rulesextractsourcemap
|
|
1546
|
-
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
1547
|
-
rspackConfig.module = rspackConfig.module || {};
|
|
1548
|
-
rspackConfig.module.rules = rspackConfig.module.rules || [];
|
|
1549
|
-
rspackConfig.module.rules.unshift({
|
|
1550
|
-
test: /\.js$/,
|
|
1551
|
-
include: browserRuntimeDir,
|
|
1552
|
-
extractSourceMap: true,
|
|
1553
|
-
});
|
|
1554
|
-
|
|
1555
|
-
if (isDebug()) {
|
|
1556
|
-
logger.log(
|
|
1557
|
-
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
1558
|
-
);
|
|
1559
|
-
}
|
|
1560
|
-
},
|
|
1561
|
-
},
|
|
1562
|
-
},
|
|
1563
|
-
);
|
|
1564
|
-
|
|
1565
|
-
// Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
|
|
1566
|
-
// In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
|
|
1567
|
-
// This must be done after mergeEnvironmentConfig to ensure highest priority.
|
|
1568
|
-
merged.source = merged.source || {};
|
|
1569
|
-
merged.source.entry = {
|
|
1570
|
-
runner: resolveBrowserFile('client/entry.ts'),
|
|
1571
|
-
};
|
|
1517
|
+
// rspack `define` replaces `process.env` / `import.meta.env` with this literal
|
|
1518
|
+
// expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
|
|
1519
|
+
// text, so the owned key can never drift from the runtime
|
|
1520
|
+
// `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
|
|
1521
|
+
const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
|
|
1522
|
+
RSTEST_ENV_SYMBOL_KEY,
|
|
1523
|
+
)})]`;
|
|
1572
1524
|
|
|
1573
|
-
|
|
1574
|
-
},
|
|
1575
|
-
// Execute after all other plugins to ensure rstest's entry config has the highest priority
|
|
1576
|
-
order: 'post',
|
|
1577
|
-
});
|
|
1578
|
-
},
|
|
1579
|
-
},
|
|
1580
|
-
]);
|
|
1581
|
-
|
|
1582
|
-
// Register watch plugin if in watch mode
|
|
1583
|
-
if (isWatchMode && onTriggerRerun) {
|
|
1584
|
-
rsbuildInstance.addPlugins([
|
|
1585
|
-
{
|
|
1586
|
-
name: 'rstest:browser-watch',
|
|
1587
|
-
setup(api) {
|
|
1588
|
-
api.onBeforeDevCompile(() => {
|
|
1589
|
-
if (!watchContext.hooksEnabled) {
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
1593
|
-
});
|
|
1594
|
-
|
|
1595
|
-
api.onAfterDevCompile(async ({ stats }) => {
|
|
1596
|
-
// Collect hashes even during initial build to establish baseline
|
|
1597
|
-
if (stats) {
|
|
1598
|
-
const projectEntries = await collectProjectEntries(context);
|
|
1599
|
-
const entryTestFiles = new Set<string>(
|
|
1600
|
-
collectWatchTestFiles(projectEntries).map(
|
|
1601
|
-
(file) => file.testPath,
|
|
1602
|
-
),
|
|
1603
|
-
);
|
|
1604
|
-
|
|
1605
|
-
const statsJson = stats.toJson({ all: true });
|
|
1606
|
-
const affected = getAffectedTestFiles(
|
|
1607
|
-
statsJson.chunks,
|
|
1608
|
-
entryTestFiles,
|
|
1609
|
-
);
|
|
1610
|
-
watchContext.affectedTestFiles = affected;
|
|
1611
|
-
|
|
1612
|
-
if (affected.length > 0) {
|
|
1613
|
-
logger.debug(
|
|
1614
|
-
`[Watch] Affected test files: ${affected.join(', ')}`,
|
|
1615
|
-
);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
if (!watchContext.hooksEnabled) {
|
|
1620
|
-
return;
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
await onTriggerRerun();
|
|
1624
|
-
});
|
|
1625
|
-
},
|
|
1626
|
-
},
|
|
1627
|
-
]);
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
// Register coverage plugin for browser mode
|
|
1631
|
-
const coverage = browserProjects.find(
|
|
1632
|
-
(project) => project.normalizedConfig.coverage?.enabled,
|
|
1633
|
-
)?.normalizedConfig.coverage;
|
|
1634
|
-
if (coverage?.enabled && context.command !== 'list') {
|
|
1635
|
-
const { pluginCoverage } = await loadCoverageProvider(
|
|
1636
|
-
coverage,
|
|
1637
|
-
context.rootPath,
|
|
1638
|
-
);
|
|
1639
|
-
rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
const devServer = await rsbuildInstance.createDevServer({
|
|
1643
|
-
getPortSilently: true,
|
|
1644
|
-
});
|
|
1645
|
-
|
|
1646
|
-
if (isDebug()) {
|
|
1647
|
-
await rsbuildInstance.inspectConfig({
|
|
1648
|
-
writeToDisk: true,
|
|
1649
|
-
extraConfigs: {
|
|
1650
|
-
rstest: {
|
|
1651
|
-
...context.normalizedConfig,
|
|
1652
|
-
projects: browserProjects.map((p) => p.normalizedConfig),
|
|
1653
|
-
},
|
|
1654
|
-
},
|
|
1655
|
-
});
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
// Serve prebuilt container assets (SPA) via sirv
|
|
1525
|
+
// Serve prebuilt container assets (SPA) via sirv (container origin only)
|
|
1659
1526
|
const serveContainer = containerDistPath
|
|
1660
1527
|
? sirv(containerDistPath, {
|
|
1661
1528
|
dev: false,
|
|
@@ -1685,13 +1552,7 @@ const createBrowserRuntime = async ({
|
|
|
1685
1552
|
let html = await response.text();
|
|
1686
1553
|
html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
|
|
1687
1554
|
|
|
1688
|
-
res
|
|
1689
|
-
response.headers.forEach((value, key) => {
|
|
1690
|
-
if (key.toLowerCase() === 'content-length') {
|
|
1691
|
-
return;
|
|
1692
|
-
}
|
|
1693
|
-
res.setHeader(key, value);
|
|
1694
|
-
});
|
|
1555
|
+
copyProxyResponseHeaders(response, res);
|
|
1695
1556
|
res.setHeader('Content-Type', 'text/html');
|
|
1696
1557
|
res.end(html);
|
|
1697
1558
|
return true;
|
|
@@ -1719,13 +1580,7 @@ const createBrowserRuntime = async ({
|
|
|
1719
1580
|
}
|
|
1720
1581
|
|
|
1721
1582
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1722
|
-
res
|
|
1723
|
-
response.headers.forEach((value, key) => {
|
|
1724
|
-
if (key.toLowerCase() === 'content-length') {
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
res.setHeader(key, value);
|
|
1728
|
-
});
|
|
1583
|
+
copyProxyResponseHeaders(response, res);
|
|
1729
1584
|
res.end(buffer);
|
|
1730
1585
|
return true;
|
|
1731
1586
|
} catch (error) {
|
|
@@ -1736,75 +1591,366 @@ const createBrowserRuntime = async ({
|
|
|
1736
1591
|
}
|
|
1737
1592
|
};
|
|
1738
1593
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1594
|
+
const entryByEnvironmentName = new Map(
|
|
1595
|
+
projectEntries.map((entry) => [entry.project.environmentName, entry]),
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
// ---- Build one isolated rsbuild instance + dev server per project ----
|
|
1599
|
+
const buildProjectServer = async (
|
|
1600
|
+
project: ProjectContext,
|
|
1601
|
+
isContainerServer: boolean,
|
|
1602
|
+
): Promise<BrowserProjectServer> => {
|
|
1603
|
+
const manifestPath = join(
|
|
1604
|
+
tempDir,
|
|
1605
|
+
toSafeVarName(project.environmentName),
|
|
1606
|
+
VIRTUAL_MANIFEST_FILENAME,
|
|
1607
|
+
);
|
|
1608
|
+
const entry = entryByEnvironmentName.get(project.environmentName);
|
|
1609
|
+
const manifestSource = generateManifestModule({
|
|
1610
|
+
manifestPath,
|
|
1611
|
+
entries: [
|
|
1612
|
+
{
|
|
1613
|
+
project,
|
|
1614
|
+
testFiles: entry?.testFiles ?? [],
|
|
1615
|
+
setupFiles: entry?.setupFiles ?? [],
|
|
1616
|
+
},
|
|
1617
|
+
],
|
|
1618
|
+
isWatchMode,
|
|
1619
|
+
});
|
|
1620
|
+
const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
|
|
1621
|
+
[manifestPath]: manifestSource,
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
const rstestInternalAliases = {
|
|
1625
|
+
'@rstest/browser-manifest': manifestPath,
|
|
1626
|
+
...staticRstestAliases,
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
const isHeadless =
|
|
1630
|
+
forceHeadless || project.normalizedConfig.browser.headless;
|
|
1631
|
+
const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
|
|
1632
|
+
|
|
1633
|
+
const rsbuildInstance = await createRsbuild({
|
|
1634
|
+
callerName: 'rstest-browser',
|
|
1635
|
+
rsbuildConfig: {
|
|
1636
|
+
root: context.rootPath,
|
|
1637
|
+
mode: 'development',
|
|
1638
|
+
plugins: project.normalizedConfig.plugins || [],
|
|
1639
|
+
server: {
|
|
1640
|
+
printUrls: false,
|
|
1641
|
+
// Each project gets its own dev server. Honor an explicitly
|
|
1642
|
+
// configured port; otherwise keep the historical 4000 default for the
|
|
1643
|
+
// container server and let the OS assign free ports for the rest, so
|
|
1644
|
+
// multiple projects never collide on one port.
|
|
1645
|
+
port:
|
|
1646
|
+
project.normalizedConfig.browser.port ??
|
|
1647
|
+
(isContainerServer ? 4000 : 0),
|
|
1648
|
+
strictPort: project.normalizedConfig.browser.strictPort,
|
|
1649
|
+
},
|
|
1650
|
+
dev: createBrowserRsbuildDevConfig(enableHmr),
|
|
1651
|
+
environments: {
|
|
1652
|
+
[project.environmentName]: {},
|
|
1653
|
+
},
|
|
1654
|
+
},
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1658
|
+
rsbuildInstance.addPlugins([
|
|
1659
|
+
{
|
|
1660
|
+
name: 'rstest:browser-user-config',
|
|
1661
|
+
setup(api) {
|
|
1662
|
+
// Internal extension entry: register host dispatch handlers without
|
|
1663
|
+
// coupling scheduling to individual capability implementations.
|
|
1664
|
+
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1665
|
+
'rstest:browser',
|
|
1666
|
+
{
|
|
1667
|
+
registerDispatchHandler: (
|
|
1668
|
+
namespace: string,
|
|
1669
|
+
handler: BrowserDispatchHandler,
|
|
1670
|
+
) => {
|
|
1671
|
+
dispatchHandlers.set(namespace, handler);
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1674
|
+
);
|
|
1675
|
+
|
|
1676
|
+
api.modifyEnvironmentConfig({
|
|
1677
|
+
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1678
|
+
if (name !== project.environmentName) {
|
|
1679
|
+
return config;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const userRsbuildConfig = project.normalizedConfig;
|
|
1683
|
+
const buildCache = resolveProjectBuildCache({
|
|
1684
|
+
context,
|
|
1685
|
+
project,
|
|
1686
|
+
});
|
|
1687
|
+
const setupFiles = Object.values(
|
|
1688
|
+
getSetupFiles(
|
|
1689
|
+
project.normalizedConfig.setupFiles,
|
|
1690
|
+
project.rootPath,
|
|
1691
|
+
),
|
|
1692
|
+
);
|
|
1693
|
+
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
1694
|
+
const merged = mergeEnvironmentConfig(
|
|
1695
|
+
config,
|
|
1696
|
+
{
|
|
1697
|
+
...userRsbuildConfig,
|
|
1698
|
+
performance: buildCache
|
|
1699
|
+
? {
|
|
1700
|
+
...userRsbuildConfig.performance,
|
|
1701
|
+
buildCache,
|
|
1702
|
+
}
|
|
1703
|
+
: userRsbuildConfig.performance,
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
resolve: {
|
|
1707
|
+
alias: rstestInternalAliases,
|
|
1708
|
+
},
|
|
1709
|
+
source: {
|
|
1710
|
+
define: {
|
|
1711
|
+
'process.env': rstestEnvDefine,
|
|
1712
|
+
'import.meta.env': rstestEnvDefine,
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
output: {
|
|
1716
|
+
target: 'web',
|
|
1717
|
+
// Enable source map for inline snapshot support
|
|
1718
|
+
sourceMap: {
|
|
1719
|
+
js: 'source-map',
|
|
1720
|
+
},
|
|
1721
|
+
},
|
|
1722
|
+
tools: {
|
|
1723
|
+
rspack: (rspackConfig) => {
|
|
1724
|
+
rspackConfig.mode = 'development';
|
|
1725
|
+
// lazyCompilation's only delivery transport is the HMR
|
|
1726
|
+
// runtime, so it follows the same gate as HMR (see
|
|
1727
|
+
// `shouldEnableBrowserHmr`): headed watch only, everything
|
|
1728
|
+
// else compiles eagerly.
|
|
1729
|
+
rspackConfig.lazyCompilation = enableHmr
|
|
1730
|
+
? createBrowserLazyCompilationConfig(setupFiles)
|
|
1731
|
+
: false;
|
|
1732
|
+
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
1733
|
+
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
1734
|
+
|
|
1735
|
+
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
1736
|
+
|
|
1737
|
+
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
1738
|
+
// This preserves the sourcemap chain for inline snapshot support
|
|
1739
|
+
// See: https://rspack.dev/config/module-rules#rulesextractsourcemap
|
|
1740
|
+
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
1741
|
+
rspackConfig.module = rspackConfig.module || {};
|
|
1742
|
+
rspackConfig.module.rules =
|
|
1743
|
+
rspackConfig.module.rules || [];
|
|
1744
|
+
rspackConfig.module.rules.unshift({
|
|
1745
|
+
test: /\.js$/,
|
|
1746
|
+
include: browserRuntimeDir,
|
|
1747
|
+
extractSourceMap: true,
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
if (isDebug()) {
|
|
1751
|
+
logger.log(
|
|
1752
|
+
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
// Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
|
|
1761
|
+
// In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
|
|
1762
|
+
// This must be done after mergeEnvironmentConfig to ensure highest priority.
|
|
1763
|
+
merged.source = merged.source || {};
|
|
1764
|
+
merged.source.entry = {
|
|
1765
|
+
runner: resolveBrowserFile('client/entry.ts'),
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
return merged;
|
|
1769
|
+
},
|
|
1770
|
+
// Execute after all other plugins to ensure rstest's entry config has the highest priority
|
|
1771
|
+
order: 'post',
|
|
1772
|
+
});
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
]);
|
|
1776
|
+
|
|
1777
|
+
// Register watch plugin if in watch mode
|
|
1778
|
+
if (isWatchMode && onTriggerRerun) {
|
|
1779
|
+
rsbuildInstance.addPlugins([
|
|
1780
|
+
{
|
|
1781
|
+
name: 'rstest:browser-watch',
|
|
1782
|
+
setup(api) {
|
|
1783
|
+
api.onBeforeDevCompile(() => {
|
|
1784
|
+
if (!watchContext.hooksEnabled) {
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
api.onAfterDevCompile(async ({ stats }) => {
|
|
1791
|
+
// Collect hashes even during initial build to establish baseline
|
|
1792
|
+
if (stats) {
|
|
1793
|
+
const allProjectEntries = await collectProjectEntries(context);
|
|
1794
|
+
const entryTestFiles = new Set<string>(
|
|
1795
|
+
collectWatchTestFiles(allProjectEntries).map(
|
|
1796
|
+
(file) => file.testPath,
|
|
1797
|
+
),
|
|
1798
|
+
);
|
|
1799
|
+
|
|
1800
|
+
const statsJson = stats.toJson({ all: true });
|
|
1801
|
+
const affected = getAffectedTestFiles(
|
|
1802
|
+
statsJson.chunks,
|
|
1803
|
+
entryTestFiles,
|
|
1804
|
+
);
|
|
1805
|
+
watchContext.affectedTestFiles = affected;
|
|
1806
|
+
|
|
1807
|
+
if (affected.length > 0) {
|
|
1808
|
+
logger.debug(
|
|
1809
|
+
`[Watch] Affected test files: ${affected.join(', ')}`,
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (!watchContext.hooksEnabled) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
await onTriggerRerun();
|
|
1819
|
+
});
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
]);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Register coverage plugin if this project enables coverage
|
|
1826
|
+
const coverage = project.normalizedConfig.coverage;
|
|
1827
|
+
if (coverage?.enabled && context.command !== 'list') {
|
|
1828
|
+
const { pluginCoverage } = await loadCoverageProvider(
|
|
1829
|
+
coverage,
|
|
1830
|
+
context.rootPath,
|
|
1831
|
+
);
|
|
1832
|
+
rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const devServer = await rsbuildInstance.createDevServer({
|
|
1836
|
+
getPortSilently: true,
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
if (isDebug()) {
|
|
1840
|
+
await rsbuildInstance.inspectConfig({
|
|
1841
|
+
writeToDisk: true,
|
|
1842
|
+
extraConfigs: {
|
|
1843
|
+
rstest: {
|
|
1844
|
+
...context.normalizedConfig,
|
|
1845
|
+
projects: [project.normalizedConfig],
|
|
1846
|
+
},
|
|
1847
|
+
},
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
devServer.middlewares.use(
|
|
1852
|
+
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
1853
|
+
if (!req.url) {
|
|
1854
|
+
next();
|
|
1751
1855
|
return;
|
|
1752
1856
|
}
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1857
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1858
|
+
if (url.pathname === '/__open-in-editor') {
|
|
1859
|
+
const file = url.searchParams.get('file');
|
|
1860
|
+
if (!file) {
|
|
1861
|
+
res.statusCode = 400;
|
|
1862
|
+
res.end('Missing file');
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
try {
|
|
1866
|
+
await openEditor([{ file }]);
|
|
1867
|
+
res.statusCode = 204;
|
|
1868
|
+
res.end();
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
logger.debug(
|
|
1871
|
+
`[Browser UI] Failed to open editor: ${String(error)}`,
|
|
1872
|
+
);
|
|
1873
|
+
res.statusCode = 500;
|
|
1874
|
+
res.end('Failed to open editor');
|
|
1875
|
+
}
|
|
1766
1876
|
return;
|
|
1767
1877
|
}
|
|
1878
|
+
// Container UI HTML + static assets are served by the container origin
|
|
1879
|
+
// only. Per-project runner servers expose just /runner.html + assets.
|
|
1880
|
+
if (isContainerServer) {
|
|
1881
|
+
if (url.pathname === '/') {
|
|
1882
|
+
if (await respondWithDevServerHtml(url, res)) {
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const html =
|
|
1887
|
+
injectedContainerHtml ||
|
|
1888
|
+
containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
|
|
1768
1889
|
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1890
|
+
if (html) {
|
|
1891
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1892
|
+
res.end(html);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1772
1895
|
|
|
1773
|
-
|
|
1896
|
+
res.statusCode = 502;
|
|
1897
|
+
res.end('Container UI is not available.');
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (url.pathname.startsWith('/container-static/')) {
|
|
1901
|
+
if (await proxyDevServerAsset(req, res)) {
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (serveContainer) {
|
|
1906
|
+
serveContainer(req, res, next);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
res.statusCode = 502;
|
|
1911
|
+
res.end('Container assets are not available.');
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (url.pathname === '/runner.html') {
|
|
1774
1916
|
res.setHeader('Content-Type', 'text/html');
|
|
1775
|
-
res.end(
|
|
1917
|
+
res.end(htmlTemplate);
|
|
1776
1918
|
return;
|
|
1777
1919
|
}
|
|
1920
|
+
next();
|
|
1921
|
+
},
|
|
1922
|
+
);
|
|
1778
1923
|
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
return;
|
|
1782
|
-
}
|
|
1783
|
-
if (url.pathname.startsWith('/container-static/')) {
|
|
1784
|
-
if (await proxyDevServerAsset(req, res)) {
|
|
1785
|
-
return;
|
|
1786
|
-
}
|
|
1924
|
+
const { port: listenPort } = await devServer.listen();
|
|
1925
|
+
const port = resolveListenPort(listenPort, devServer.httpServer);
|
|
1787
1926
|
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1927
|
+
return {
|
|
1928
|
+
projectName: project.name,
|
|
1929
|
+
environmentName: project.environmentName,
|
|
1930
|
+
rsbuildInstance,
|
|
1931
|
+
devServer,
|
|
1932
|
+
port,
|
|
1933
|
+
manifestPath,
|
|
1934
|
+
};
|
|
1935
|
+
};
|
|
1792
1936
|
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1937
|
+
// Build each project's server sequentially. Servers must bind ports one at a
|
|
1938
|
+
// time: projects may share a configured port and rely on strictPort:false
|
|
1939
|
+
// bumping to the next free one, which races under concurrent listen().
|
|
1940
|
+
const projectServers = new Map<string, BrowserProjectServer>();
|
|
1941
|
+
try {
|
|
1942
|
+
for (const [index, project] of browserProjects.entries()) {
|
|
1943
|
+
const server = await buildProjectServer(project, index === 0);
|
|
1944
|
+
projectServers.set(server.projectName, server);
|
|
1945
|
+
}
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
await closeAllProjectServers(projectServers.values());
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1805
1950
|
|
|
1806
|
-
|
|
1807
|
-
|
|
1951
|
+
// browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
|
|
1952
|
+
// otherwise) and index 0 is the designated container origin.
|
|
1953
|
+
const containerServer = projectServers.get(browserProjects[0]!.name)!;
|
|
1808
1954
|
|
|
1809
1955
|
// Create WebSocket server on an available port
|
|
1810
1956
|
// Using port: 0 lets the OS assign an available port, avoiding conflicts
|
|
@@ -1828,22 +1974,19 @@ const createBrowserRuntime = async ({
|
|
|
1828
1974
|
providerOptions: browserLaunchOptions.providerOptions,
|
|
1829
1975
|
});
|
|
1830
1976
|
return {
|
|
1831
|
-
|
|
1832
|
-
|
|
1977
|
+
projectServers,
|
|
1978
|
+
containerServer,
|
|
1833
1979
|
browser: runtime.browser,
|
|
1834
1980
|
browserLaunchOptions,
|
|
1835
|
-
port,
|
|
1836
1981
|
wsPort,
|
|
1837
|
-
manifestPath,
|
|
1838
1982
|
tempDir,
|
|
1839
|
-
manifestPlugin: virtualManifestPlugin,
|
|
1840
1983
|
setContainerOptions,
|
|
1841
1984
|
dispatchHandlers,
|
|
1842
1985
|
wss,
|
|
1843
1986
|
};
|
|
1844
1987
|
} catch (error) {
|
|
1845
1988
|
wss.close();
|
|
1846
|
-
await
|
|
1989
|
+
await closeAllProjectServers(projectServers.values());
|
|
1847
1990
|
throw error;
|
|
1848
1991
|
}
|
|
1849
1992
|
};
|
|
@@ -2169,12 +2312,6 @@ export const runBrowserController = async (
|
|
|
2169
2312
|
Date.now().toString(),
|
|
2170
2313
|
);
|
|
2171
2314
|
|
|
2172
|
-
const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
|
|
2173
|
-
const manifestSource = generateManifestModule({
|
|
2174
|
-
manifestPath,
|
|
2175
|
-
entries: projectEntries,
|
|
2176
|
-
});
|
|
2177
|
-
|
|
2178
2315
|
// Track initial test files for watch mode
|
|
2179
2316
|
if (isWatchMode) {
|
|
2180
2317
|
watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
|
|
@@ -2189,8 +2326,7 @@ export const runBrowserController = async (
|
|
|
2189
2326
|
try {
|
|
2190
2327
|
runtime = await createBrowserRuntime({
|
|
2191
2328
|
context,
|
|
2192
|
-
|
|
2193
|
-
manifestSource,
|
|
2329
|
+
projectEntries,
|
|
2194
2330
|
tempDir,
|
|
2195
2331
|
isWatchMode,
|
|
2196
2332
|
onTriggerRerun: isWatchMode
|
|
@@ -2219,7 +2355,7 @@ export const runBrowserController = async (
|
|
|
2219
2355
|
}
|
|
2220
2356
|
}
|
|
2221
2357
|
|
|
2222
|
-
const { browser, browserLaunchOptions,
|
|
2358
|
+
const { browser, browserLaunchOptions, wsPort, wss } = runtime;
|
|
2223
2359
|
const buildTime = Date.now() - buildStart;
|
|
2224
2360
|
|
|
2225
2361
|
// Collect all test files from project entries with project info
|
|
@@ -2250,13 +2386,22 @@ export const runBrowserController = async (
|
|
|
2250
2386
|
),
|
|
2251
2387
|
);
|
|
2252
2388
|
|
|
2389
|
+
const projectRunnerUrls = Object.fromEntries(
|
|
2390
|
+
[...runtime.projectServers].map(([name, server]) => [
|
|
2391
|
+
name,
|
|
2392
|
+
`http://localhost:${server.port}`,
|
|
2393
|
+
]),
|
|
2394
|
+
);
|
|
2395
|
+
|
|
2253
2396
|
const hostOptions: BrowserHostConfig = {
|
|
2254
2397
|
rootPath: normalize(context.rootPath),
|
|
2255
2398
|
projects: projectRuntimeConfigs,
|
|
2256
2399
|
snapshot: {
|
|
2257
2400
|
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
2258
2401
|
},
|
|
2259
|
-
|
|
2402
|
+
// Container origin (fallback). Per-project runner origins below.
|
|
2403
|
+
runnerUrl: `http://localhost:${runtime.containerServer.port}`,
|
|
2404
|
+
projectRunnerUrls,
|
|
2260
2405
|
wsPort,
|
|
2261
2406
|
debug: isDebug(),
|
|
2262
2407
|
rpcTimeout: maxTestTimeoutForRpc,
|
|
@@ -2283,8 +2428,7 @@ export const runBrowserController = async (
|
|
|
2283
2428
|
|
|
2284
2429
|
let activeContainerPage: BrowserProviderPage | null = null;
|
|
2285
2430
|
let getHeadlessRunnerPageBySessionId:
|
|
2286
|
-
|
|
2287
|
-
| undefined;
|
|
2431
|
+
((sessionId: string) => BrowserProviderPage | undefined) | undefined;
|
|
2288
2432
|
|
|
2289
2433
|
const dispatchBrowserRpcRequest = async ({
|
|
2290
2434
|
request,
|
|
@@ -2831,17 +2975,34 @@ export const runBrowserController = async (
|
|
|
2831
2975
|
resolveDone = resolve;
|
|
2832
2976
|
});
|
|
2833
2977
|
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2978
|
+
// Event-driven death detection (vitest-style): a renderer crash or an
|
|
2979
|
+
// unexpected page close produces no further messages, so fail the file at
|
|
2980
|
+
// once. Per-test/hook timeouts are enforced inside the runner, so the host
|
|
2981
|
+
// deliberately keeps no execution-duration watchdog. Our own teardown
|
|
2982
|
+
// close is ignored because `settled`/`run.cancelled` are set by then.
|
|
2983
|
+
const crashDeferred = createDeferredPromise<string>();
|
|
2984
|
+
const onPageDead = (reason: string): void => {
|
|
2985
|
+
if (
|
|
2986
|
+
settled ||
|
|
2987
|
+
run.cancelled ||
|
|
2988
|
+
!runLifecycle.isTokenActive(run.token)
|
|
2989
|
+
) {
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
settled = true;
|
|
2993
|
+
crashDeferred.resolve(reason);
|
|
2994
|
+
};
|
|
2842
2995
|
|
|
2843
2996
|
try {
|
|
2844
2997
|
page = await browserContext.newPage();
|
|
2998
|
+
page.on('crash', () =>
|
|
2999
|
+
onPageDead(`Browser page crashed while running ${file.testPath}.`),
|
|
3000
|
+
);
|
|
3001
|
+
page.on('close', () =>
|
|
3002
|
+
onPageDead(
|
|
3003
|
+
`Browser page closed unexpectedly while running ${file.testPath}.`,
|
|
3004
|
+
),
|
|
3005
|
+
);
|
|
2845
3006
|
|
|
2846
3007
|
const session = sessionRegistry.register({
|
|
2847
3008
|
testFile: file.testPath,
|
|
@@ -2900,32 +3061,35 @@ export const runBrowserController = async (
|
|
|
2900
3061
|
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
2901
3062
|
);
|
|
2902
3063
|
|
|
2903
|
-
|
|
3064
|
+
const projectServer = runtime.projectServers.get(file.projectName);
|
|
3065
|
+
if (!projectServer) {
|
|
3066
|
+
throw new Error(
|
|
3067
|
+
`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`,
|
|
3068
|
+
);
|
|
3069
|
+
}
|
|
3070
|
+
await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
|
|
2904
3071
|
waitUntil: 'load',
|
|
2905
3072
|
});
|
|
2906
3073
|
|
|
2907
|
-
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
|
2908
|
-
timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
|
|
2909
|
-
});
|
|
2910
|
-
|
|
2911
3074
|
const state = await Promise.race([
|
|
2912
|
-
donePromise.then(() => 'done' as const),
|
|
2913
|
-
|
|
2914
|
-
|
|
3075
|
+
donePromise.then(() => ({ type: 'done' as const })),
|
|
3076
|
+
crashDeferred.promise.then((reason) => ({
|
|
3077
|
+
type: 'crash' as const,
|
|
3078
|
+
reason,
|
|
3079
|
+
})),
|
|
3080
|
+
run.cancelSignal.then(() => ({ type: 'cancelled' as const })),
|
|
2915
3081
|
]);
|
|
2916
3082
|
|
|
2917
|
-
if (state === 'cancelled') {
|
|
3083
|
+
if (state.type === 'cancelled') {
|
|
2918
3084
|
return;
|
|
2919
3085
|
}
|
|
2920
3086
|
|
|
2921
3087
|
if (
|
|
2922
|
-
state === '
|
|
3088
|
+
state.type === 'crash' &&
|
|
2923
3089
|
runLifecycle.isTokenActive(run.token) &&
|
|
2924
3090
|
!run.cancelled
|
|
2925
3091
|
) {
|
|
2926
|
-
await handleFatal({
|
|
2927
|
-
message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
|
|
2928
|
-
});
|
|
3092
|
+
await handleFatal({ message: state.reason });
|
|
2929
3093
|
await cancelRun(run, false);
|
|
2930
3094
|
}
|
|
2931
3095
|
} catch (error) {
|
|
@@ -2938,9 +3102,6 @@ export const runBrowserController = async (
|
|
|
2938
3102
|
await cancelRun(run, false);
|
|
2939
3103
|
}
|
|
2940
3104
|
} finally {
|
|
2941
|
-
if (timeoutId) {
|
|
2942
|
-
clearTimeout(timeoutId);
|
|
2943
|
-
}
|
|
2944
3105
|
if (page) {
|
|
2945
3106
|
try {
|
|
2946
3107
|
await page.close();
|
|
@@ -3322,16 +3483,6 @@ export const runBrowserController = async (
|
|
|
3322
3483
|
return fileInfo;
|
|
3323
3484
|
};
|
|
3324
3485
|
|
|
3325
|
-
const getHeadedPerFileTimeoutMs = (file: TestFileInfo): number => {
|
|
3326
|
-
const projectRuntime = projectRuntimeConfigs.find(
|
|
3327
|
-
(project) => project.name === file.projectName,
|
|
3328
|
-
);
|
|
3329
|
-
return (
|
|
3330
|
-
(projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
|
|
3331
|
-
PER_FILE_TIMEOUT_BUFFER_MS
|
|
3332
|
-
);
|
|
3333
|
-
};
|
|
3334
|
-
|
|
3335
3486
|
// Open a container page for user to view (reuse in watch mode)
|
|
3336
3487
|
let containerContext: BrowserProviderContext;
|
|
3337
3488
|
let containerPage: BrowserProviderPage;
|
|
@@ -3456,12 +3607,13 @@ export const runBrowserController = async (
|
|
|
3456
3607
|
pending.deferred.resolve();
|
|
3457
3608
|
};
|
|
3458
3609
|
|
|
3459
|
-
|
|
3610
|
+
// No execution-duration watchdog: per-test/hook timeouts are enforced inside
|
|
3611
|
+
// the runner, and a dead container is caught event-driven by the WebSocket
|
|
3612
|
+
// `close` handler, which rejects every pending reload via `onDisconnect`.
|
|
3613
|
+
const reloadTestFileAndWait = async (
|
|
3460
3614
|
file: TestFileInfo,
|
|
3461
3615
|
testNamePattern?: string,
|
|
3462
3616
|
): Promise<void> => {
|
|
3463
|
-
const timeoutMs = getHeadedPerFileTimeoutMs(file);
|
|
3464
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
3465
3617
|
let reloadAck: ReloadTestFileAck | undefined;
|
|
3466
3618
|
|
|
3467
3619
|
try {
|
|
@@ -3469,22 +3621,7 @@ export const runBrowserController = async (
|
|
|
3469
3621
|
file.testPath,
|
|
3470
3622
|
testNamePattern,
|
|
3471
3623
|
);
|
|
3472
|
-
|
|
3473
|
-
file.testPath,
|
|
3474
|
-
reloadAck.runId,
|
|
3475
|
-
);
|
|
3476
|
-
await Promise.race([
|
|
3477
|
-
completionPromise,
|
|
3478
|
-
new Promise<never>((_, reject) => {
|
|
3479
|
-
timeoutId = setTimeout(() => {
|
|
3480
|
-
reject(
|
|
3481
|
-
new Error(
|
|
3482
|
-
`Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`,
|
|
3483
|
-
),
|
|
3484
|
-
);
|
|
3485
|
-
}, timeoutMs);
|
|
3486
|
-
}),
|
|
3487
|
-
]);
|
|
3624
|
+
await registerPendingHeadedReload(file.testPath, reloadAck.runId);
|
|
3488
3625
|
} catch (error) {
|
|
3489
3626
|
if (reloadAck?.runId) {
|
|
3490
3627
|
rejectPendingHeadedReload(
|
|
@@ -3494,10 +3631,6 @@ export const runBrowserController = async (
|
|
|
3494
3631
|
);
|
|
3495
3632
|
}
|
|
3496
3633
|
throw error;
|
|
3497
|
-
} finally {
|
|
3498
|
-
if (timeoutId) {
|
|
3499
|
-
clearTimeout(timeoutId);
|
|
3500
|
-
}
|
|
3501
3634
|
}
|
|
3502
3635
|
};
|
|
3503
3636
|
|
|
@@ -3581,13 +3714,14 @@ export const runBrowserController = async (
|
|
|
3581
3714
|
// Only navigate on first creation
|
|
3582
3715
|
if (isNewPage) {
|
|
3583
3716
|
const pagePath = '/';
|
|
3584
|
-
|
|
3717
|
+
const containerPort = runtime.containerServer.port;
|
|
3718
|
+
await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
|
|
3585
3719
|
waitUntil: 'load',
|
|
3586
3720
|
});
|
|
3587
3721
|
|
|
3588
3722
|
logger.log(
|
|
3589
3723
|
color.cyan(
|
|
3590
|
-
`\nBrowser mode opened at http://localhost:${
|
|
3724
|
+
`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
|
|
3591
3725
|
),
|
|
3592
3726
|
);
|
|
3593
3727
|
}
|
|
@@ -3600,7 +3734,7 @@ export const runBrowserController = async (
|
|
|
3600
3734
|
if (fatalError) {
|
|
3601
3735
|
return;
|
|
3602
3736
|
}
|
|
3603
|
-
await
|
|
3737
|
+
await reloadTestFileAndWait(file, testNamePattern);
|
|
3604
3738
|
});
|
|
3605
3739
|
};
|
|
3606
3740
|
|
|
@@ -3819,11 +3953,6 @@ export const listBrowserTests = async (
|
|
|
3819
3953
|
`list-${Date.now()}`,
|
|
3820
3954
|
);
|
|
3821
3955
|
|
|
3822
|
-
const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
|
|
3823
|
-
const manifestSource = generateManifestModule({
|
|
3824
|
-
manifestPath,
|
|
3825
|
-
entries: projectEntries,
|
|
3826
|
-
});
|
|
3827
3956
|
const browserProjects = getBrowserProjects(context);
|
|
3828
3957
|
|
|
3829
3958
|
// Create a simplified browser runtime for collect mode
|
|
@@ -3831,8 +3960,7 @@ export const listBrowserTests = async (
|
|
|
3831
3960
|
try {
|
|
3832
3961
|
runtime = await createBrowserRuntime({
|
|
3833
3962
|
context,
|
|
3834
|
-
|
|
3835
|
-
manifestSource,
|
|
3963
|
+
projectEntries,
|
|
3836
3964
|
tempDir,
|
|
3837
3965
|
isWatchMode: false,
|
|
3838
3966
|
containerDistPath: undefined,
|
|
@@ -3854,7 +3982,7 @@ export const listBrowserTests = async (
|
|
|
3854
3982
|
throw error;
|
|
3855
3983
|
}
|
|
3856
3984
|
|
|
3857
|
-
const { browser, browserLaunchOptions
|
|
3985
|
+
const { browser, browserLaunchOptions } = runtime;
|
|
3858
3986
|
|
|
3859
3987
|
// Get browser projects for runtime config
|
|
3860
3988
|
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
@@ -3888,105 +4016,122 @@ export const listBrowserTests = async (
|
|
|
3888
4016
|
|
|
3889
4017
|
runtime.setContainerOptions(hostOptions);
|
|
3890
4018
|
|
|
3891
|
-
// Collect results
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
let collectCompleted = false;
|
|
3895
|
-
|
|
3896
|
-
// Promise that resolves when collection is complete
|
|
3897
|
-
let resolveCollect: (() => void) | undefined;
|
|
3898
|
-
const collectPromise = new Promise<void>((resolve) => {
|
|
3899
|
-
resolveCollect = resolve;
|
|
3900
|
-
});
|
|
3901
|
-
|
|
3902
|
-
// Create a headless page to run collection
|
|
4019
|
+
// Collect results across every project's isolated dev server. Each server
|
|
4020
|
+
// serves only its own project's manifest, so collection navigates one page
|
|
4021
|
+
// per project; each page returns its own results, aggregated afterwards.
|
|
3903
4022
|
const browserContext = await browser.newContext({
|
|
3904
4023
|
providerOptions: browserLaunchOptions.providerOptions,
|
|
3905
4024
|
viewport: null,
|
|
3906
4025
|
});
|
|
3907
|
-
const page = await browserContext.newPage();
|
|
3908
|
-
|
|
3909
|
-
// Expose dispatch function for browser client to send messages
|
|
3910
|
-
await page.exposeFunction(
|
|
3911
|
-
DISPATCH_MESSAGE_TYPE,
|
|
3912
|
-
(message: { type: string; payload?: unknown }) => {
|
|
3913
|
-
switch (message.type) {
|
|
3914
|
-
case 'collect-result': {
|
|
3915
|
-
const payload = message.payload as {
|
|
3916
|
-
testPath: string;
|
|
3917
|
-
project: string;
|
|
3918
|
-
tests: Test[];
|
|
3919
|
-
};
|
|
3920
|
-
collectResults.push({
|
|
3921
|
-
testPath: payload.testPath,
|
|
3922
|
-
project: payload.project,
|
|
3923
|
-
tests: payload.tests,
|
|
3924
|
-
});
|
|
3925
|
-
break;
|
|
3926
|
-
}
|
|
3927
|
-
case 'collect-complete':
|
|
3928
|
-
collectCompleted = true;
|
|
3929
|
-
resolveCollect?.();
|
|
3930
|
-
break;
|
|
3931
|
-
case 'fatal': {
|
|
3932
|
-
const payload = message.payload as {
|
|
3933
|
-
message: string;
|
|
3934
|
-
stack?: string;
|
|
3935
|
-
};
|
|
3936
|
-
fatalError = new Error(payload.message);
|
|
3937
|
-
fatalError.stack = payload.stack;
|
|
3938
|
-
resolveCollect?.();
|
|
3939
|
-
break;
|
|
3940
|
-
}
|
|
3941
|
-
case 'ready':
|
|
3942
|
-
case 'log':
|
|
3943
|
-
// Ignore these messages during collection
|
|
3944
|
-
break;
|
|
3945
|
-
default:
|
|
3946
|
-
// Log unexpected messages for debugging
|
|
3947
|
-
logger.debug(`[List] Unexpected message: ${message.type}`);
|
|
3948
|
-
}
|
|
3949
|
-
},
|
|
3950
|
-
);
|
|
3951
4026
|
|
|
3952
|
-
// Inject host options before navigation so the runner can access them
|
|
3953
4027
|
const serializedOptions = serializeForInlineScript(hostOptions);
|
|
3954
|
-
await page.addInitScript(
|
|
3955
|
-
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
3956
|
-
);
|
|
3957
4028
|
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
4029
|
+
const collectFromServer = async (
|
|
4030
|
+
server: BrowserProjectServer,
|
|
4031
|
+
): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
|
|
4032
|
+
const results: ListCommandResult[] = [];
|
|
4033
|
+
let error: Error | null = null;
|
|
4034
|
+
let collectCompleted = false;
|
|
4035
|
+
let resolveCollect: (() => void) | undefined;
|
|
4036
|
+
const collectPromise = new Promise<void>((resolve) => {
|
|
4037
|
+
resolveCollect = resolve;
|
|
4038
|
+
});
|
|
3962
4039
|
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
4040
|
+
const page = await browserContext.newPage();
|
|
4041
|
+
|
|
4042
|
+
// Expose dispatch function for browser client to send messages
|
|
4043
|
+
await page.exposeFunction(
|
|
4044
|
+
DISPATCH_MESSAGE_TYPE,
|
|
4045
|
+
(message: { type: string; payload?: unknown }) => {
|
|
4046
|
+
switch (message.type) {
|
|
4047
|
+
case 'collect-result': {
|
|
4048
|
+
const payload = message.payload as {
|
|
4049
|
+
testPath: string;
|
|
4050
|
+
project: string;
|
|
4051
|
+
tests: Test[];
|
|
4052
|
+
};
|
|
4053
|
+
results.push({
|
|
4054
|
+
testPath: payload.testPath,
|
|
4055
|
+
project: payload.project,
|
|
4056
|
+
tests: payload.tests,
|
|
4057
|
+
});
|
|
4058
|
+
break;
|
|
4059
|
+
}
|
|
4060
|
+
case 'collect-complete':
|
|
4061
|
+
collectCompleted = true;
|
|
4062
|
+
resolveCollect?.();
|
|
4063
|
+
break;
|
|
4064
|
+
case 'fatal': {
|
|
4065
|
+
const payload = message.payload as {
|
|
4066
|
+
message: string;
|
|
4067
|
+
stack?: string;
|
|
4068
|
+
};
|
|
4069
|
+
error = new Error(payload.message);
|
|
4070
|
+
error.stack = payload.stack;
|
|
4071
|
+
resolveCollect?.();
|
|
4072
|
+
break;
|
|
4073
|
+
}
|
|
4074
|
+
case 'ready':
|
|
4075
|
+
case 'log':
|
|
4076
|
+
// Ignore these messages during collection
|
|
4077
|
+
break;
|
|
4078
|
+
default:
|
|
4079
|
+
// Log unexpected messages for debugging
|
|
4080
|
+
logger.debug(`[List] Unexpected message: ${message.type}`);
|
|
4081
|
+
}
|
|
4082
|
+
},
|
|
4083
|
+
);
|
|
3978
4084
|
|
|
3979
|
-
|
|
4085
|
+
// Inject host options before navigation so the runner can access them
|
|
4086
|
+
await page.addInitScript(
|
|
4087
|
+
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
4088
|
+
);
|
|
3980
4089
|
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
4090
|
+
// Navigate to this project's runner page
|
|
4091
|
+
await page.goto(`http://localhost:${server.port}/runner.html`, {
|
|
4092
|
+
waitUntil: 'load',
|
|
4093
|
+
});
|
|
4094
|
+
|
|
4095
|
+
// Wait for collection to complete with timeout
|
|
4096
|
+
const timeoutMs = 30000;
|
|
4097
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
4098
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
4099
|
+
timeoutId = setTimeout(() => {
|
|
4100
|
+
if (!collectCompleted) {
|
|
4101
|
+
logger.warn(
|
|
4102
|
+
color.yellow(
|
|
4103
|
+
`[List] Browser test collection timed out after ${timeoutMs}ms`,
|
|
4104
|
+
),
|
|
4105
|
+
);
|
|
4106
|
+
}
|
|
4107
|
+
resolve();
|
|
4108
|
+
}, timeoutMs);
|
|
4109
|
+
});
|
|
4110
|
+
|
|
4111
|
+
await Promise.race([collectPromise, timeoutPromise]);
|
|
4112
|
+
|
|
4113
|
+
// Clear timeout to prevent Node.js from waiting for it
|
|
4114
|
+
if (timeoutId) {
|
|
4115
|
+
clearTimeout(timeoutId);
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
await page.close().catch(() => {});
|
|
4119
|
+
return { results, error };
|
|
4120
|
+
};
|
|
4121
|
+
|
|
4122
|
+
// Collect every project concurrently — each navigates its own page against
|
|
4123
|
+
// its own dev server and returns its own results.
|
|
4124
|
+
const collected = await Promise.all(
|
|
4125
|
+
[...runtime.projectServers.values()].map((server) =>
|
|
4126
|
+
collectFromServer(server),
|
|
4127
|
+
),
|
|
4128
|
+
);
|
|
4129
|
+
const collectResults = collected.flatMap((entry) => entry.results);
|
|
4130
|
+
const fatalError = collected.find((entry) => entry.error)?.error ?? null;
|
|
3985
4131
|
|
|
3986
4132
|
// Cleanup
|
|
3987
4133
|
const cleanup = async () => {
|
|
3988
4134
|
try {
|
|
3989
|
-
await page.close();
|
|
3990
4135
|
await browserContext.close();
|
|
3991
4136
|
} catch {
|
|
3992
4137
|
// ignore
|
|
@@ -4004,8 +4149,8 @@ export const listBrowserTests = async (
|
|
|
4004
4149
|
errors: [
|
|
4005
4150
|
{
|
|
4006
4151
|
name: 'BrowserCollectError',
|
|
4007
|
-
message:
|
|
4008
|
-
stack:
|
|
4152
|
+
message: fatalError.message,
|
|
4153
|
+
stack: fatalError.stack,
|
|
4009
4154
|
} as FormattedError,
|
|
4010
4155
|
],
|
|
4011
4156
|
};
|