@rstest/browser 0.11.1 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-container/container-static/js/{328.37af068e21.js → 949.7d935e2ac9.js} +1638 -1612
- package/dist/browser-container/container-static/js/949.7d935e2ac9.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.c8fe4ff310.js → index.492a6c2f8b.js} +11 -11
- package/dist/browser-container/index.html +1 -1
- package/dist/browserExecutor.d.ts +11 -0
- package/dist/configValidation.d.ts +6 -1
- package/dist/hostController.d.ts +2 -6
- package/dist/index.d.ts +4 -3
- package/dist/index.js +570 -301
- package/dist/protocol.d.ts +2 -2
- package/package.json +7 -7
- package/src/AGENTS.md +17 -1
- package/src/browserExecutor.ts +138 -0
- package/src/client/AGENTS.md +20 -13
- package/src/client/entry.ts +83 -15
- package/src/client/snapshot.ts +1 -2
- package/src/concurrency.ts +42 -25
- package/src/configValidation.ts +182 -1
- package/src/hostController.ts +546 -350
- package/src/index.ts +9 -5
- package/src/protocol.ts +2 -2
- package/dist/browser-container/container-static/js/328.37af068e21.js.LICENSE.txt +0 -1
- package/src/client/formatConsole.ts +0 -77
- package/src/client/public.ts +0 -12
package/src/hostController.ts
CHANGED
|
@@ -6,26 +6,39 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
7
|
import type { Rspack } from '@rstest/core';
|
|
8
8
|
import {
|
|
9
|
+
applyWebMockRspackConfig,
|
|
9
10
|
type BrowserTestRunOptions,
|
|
10
11
|
type BrowserTestRunResult,
|
|
11
12
|
type CoverageMapData,
|
|
13
|
+
type ListBrowserTestsOptions,
|
|
12
14
|
color,
|
|
13
15
|
createCoverageProvider,
|
|
16
|
+
createRunnerEventSink,
|
|
17
|
+
createSilentConsoleController,
|
|
14
18
|
DEFAULT_TEST_TIMEOUT,
|
|
15
19
|
type FormattedError,
|
|
16
20
|
getNoTestFilesMessage,
|
|
21
|
+
getPrettyConsoleName,
|
|
17
22
|
getSetupFiles,
|
|
18
23
|
getTestEntries,
|
|
24
|
+
hasUserRstestConfigPlugins,
|
|
25
|
+
importMetaRstestDefine,
|
|
26
|
+
initModifyRstestConfigHooks,
|
|
19
27
|
isDebug,
|
|
20
28
|
type ListCommandResult,
|
|
21
29
|
loadCoverageProvider,
|
|
22
30
|
logger,
|
|
31
|
+
pluginMockRuntime,
|
|
32
|
+
prepareWatchRerunState,
|
|
33
|
+
projectRuntimeConfig,
|
|
23
34
|
PhaseTracker,
|
|
24
35
|
type ProjectContext,
|
|
25
36
|
type Reporter,
|
|
37
|
+
type RunnerEventSink,
|
|
26
38
|
type RstestContext,
|
|
27
|
-
type RuntimeConfig,
|
|
28
39
|
resolveProjectBuildCache,
|
|
40
|
+
resolveSnapshotPathDefault,
|
|
41
|
+
resolveShardedEntries,
|
|
29
42
|
RSTEST_ENV_SYMBOL_KEY,
|
|
30
43
|
rsbuild,
|
|
31
44
|
serializableConfig,
|
|
@@ -36,7 +49,7 @@ import {
|
|
|
36
49
|
} from '@rstest/core/internal/browser';
|
|
37
50
|
import { type BirpcReturn, createBirpc } from 'birpc';
|
|
38
51
|
import openEditor from 'open-editor';
|
|
39
|
-
import {
|
|
52
|
+
import { dirname, join, normalize, relative, resolve } from 'pathe';
|
|
40
53
|
import picomatch from 'picomatch';
|
|
41
54
|
import sirv from 'sirv';
|
|
42
55
|
import { type WebSocket, WebSocketServer } from 'ws';
|
|
@@ -45,6 +58,7 @@ import {
|
|
|
45
58
|
createHostDispatchRouter,
|
|
46
59
|
type HostDispatchRouterOptions,
|
|
47
60
|
} from './dispatchCapabilities';
|
|
61
|
+
import { validateBrowserConfig } from './configValidation';
|
|
48
62
|
import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
|
|
49
63
|
import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
|
|
50
64
|
import { attachHeadlessRunnerTransport } from './headlessTransport';
|
|
@@ -97,6 +111,8 @@ import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
|
|
|
97
111
|
const { createRsbuild, rspack } = rsbuild;
|
|
98
112
|
type RsbuildDevServer = rsbuild.RsbuildDevServer;
|
|
99
113
|
type RsbuildInstance = rsbuild.RsbuildInstance;
|
|
114
|
+
type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
|
|
115
|
+
Pick<rsbuild.RsbuildConfig, 'root'>;
|
|
100
116
|
|
|
101
117
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
102
118
|
const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
|
|
@@ -200,10 +216,6 @@ const getFileTaskId = (testPath: string): string => {
|
|
|
200
216
|
return `file:${testPath}`;
|
|
201
217
|
};
|
|
202
218
|
|
|
203
|
-
const getBufferedLogTaskId = (log: UserConsoleLog): string => {
|
|
204
|
-
return log.taskId ?? getFileTaskId(log.testPath);
|
|
205
|
-
};
|
|
206
|
-
|
|
207
219
|
const createDeferredPromise = <T>(): DeferredPromise<T> => {
|
|
208
220
|
let resolve!: DeferredPromise<T>['resolve'];
|
|
209
221
|
let reject!: DeferredPromise<T>['reject'];
|
|
@@ -433,6 +445,7 @@ type BrowserRuntime = {
|
|
|
433
445
|
dispatchHandlers: Map<string, BrowserDispatchHandler>;
|
|
434
446
|
wss: WebSocketServer;
|
|
435
447
|
rpcManager?: ContainerRpcManager;
|
|
448
|
+
projectEntries: BrowserProjectEntries[];
|
|
436
449
|
};
|
|
437
450
|
|
|
438
451
|
// ============================================================================
|
|
@@ -973,82 +986,25 @@ const getAffectedTestFiles = (
|
|
|
973
986
|
return Array.from(affectedFiles);
|
|
974
987
|
};
|
|
975
988
|
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
)
|
|
979
|
-
|
|
980
|
-
testNamePattern,
|
|
981
|
-
testTimeout,
|
|
982
|
-
passWithNoTests,
|
|
983
|
-
retry,
|
|
984
|
-
globals,
|
|
985
|
-
clearMocks,
|
|
986
|
-
resetMocks,
|
|
987
|
-
restoreMocks,
|
|
988
|
-
unstubEnvs,
|
|
989
|
-
unstubGlobals,
|
|
990
|
-
maxConcurrency,
|
|
991
|
-
printConsoleTrace,
|
|
992
|
-
disableConsoleIntercept,
|
|
993
|
-
testEnvironment,
|
|
994
|
-
hookTimeout,
|
|
995
|
-
isolate,
|
|
996
|
-
coverage,
|
|
997
|
-
snapshotFormat,
|
|
998
|
-
env,
|
|
999
|
-
bail,
|
|
1000
|
-
logHeapUsage,
|
|
1001
|
-
detectAsyncLeaks,
|
|
1002
|
-
chaiConfig,
|
|
1003
|
-
includeTaskLocation,
|
|
1004
|
-
silent,
|
|
1005
|
-
} = project.normalizedConfig;
|
|
989
|
+
const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
|
|
990
|
+
context.projects.filter(
|
|
991
|
+
(project) => project.normalizedConfig.browser.enabled,
|
|
992
|
+
);
|
|
1006
993
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
// User-supplied `env` wins so explicit overrides still take effect.
|
|
1014
|
-
// See https://github.com/web-infra-dev/rstest/issues/1351
|
|
1015
|
-
env: {
|
|
1016
|
-
NODE_ENV: process.env.NODE_ENV,
|
|
1017
|
-
RSTEST: 'true',
|
|
1018
|
-
...env,
|
|
1019
|
-
},
|
|
1020
|
-
testNamePattern,
|
|
1021
|
-
testTimeout,
|
|
1022
|
-
hookTimeout,
|
|
1023
|
-
passWithNoTests,
|
|
1024
|
-
retry,
|
|
1025
|
-
globals,
|
|
1026
|
-
clearMocks,
|
|
1027
|
-
resetMocks,
|
|
1028
|
-
restoreMocks,
|
|
1029
|
-
unstubEnvs,
|
|
1030
|
-
unstubGlobals,
|
|
1031
|
-
maxConcurrency,
|
|
1032
|
-
printConsoleTrace,
|
|
1033
|
-
disableConsoleIntercept,
|
|
1034
|
-
testEnvironment,
|
|
1035
|
-
isolate,
|
|
1036
|
-
coverage,
|
|
1037
|
-
snapshotFormat,
|
|
1038
|
-
bail,
|
|
1039
|
-
logHeapUsage,
|
|
1040
|
-
detectAsyncLeaks,
|
|
1041
|
-
chaiConfig,
|
|
1042
|
-
includeTaskLocation,
|
|
1043
|
-
silent,
|
|
1044
|
-
};
|
|
1045
|
-
};
|
|
994
|
+
const getBrowserRsbuildEnvironmentConfig = (
|
|
995
|
+
project: ProjectContext,
|
|
996
|
+
): RsbuildEnvironmentConfig => ({
|
|
997
|
+
plugins: project.normalizedConfig.plugins,
|
|
998
|
+
root: project.rootPath,
|
|
999
|
+
});
|
|
1046
1000
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1001
|
+
// Max testTimeout across browser projects, used as the host->client RPC timeout.
|
|
1002
|
+
const getMaxTestTimeoutForRpc = (projects: ProjectContext[]): number =>
|
|
1003
|
+
Math.max(
|
|
1004
|
+
...projects.map(
|
|
1005
|
+
(p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
|
|
1006
|
+
),
|
|
1050
1007
|
);
|
|
1051
|
-
};
|
|
1052
1008
|
|
|
1053
1009
|
const getBrowserLaunchOptions = (
|
|
1054
1010
|
project: ProjectContext,
|
|
@@ -1118,10 +1074,11 @@ const resolveProviderForTestPath = ({
|
|
|
1118
1074
|
|
|
1119
1075
|
const collectProjectEntries = async (
|
|
1120
1076
|
context: RstestContext,
|
|
1077
|
+
// The explicit browser-project subset the executor was constructed with. Falls
|
|
1078
|
+
// back to re-deriving from `context` for internal callers (e.g. the watch
|
|
1079
|
+
// plugin) that do not carry the plan's project list.
|
|
1080
|
+
browserProjects: ProjectContext[] = getBrowserProjects(context),
|
|
1121
1081
|
): Promise<BrowserProjectEntries[]> => {
|
|
1122
|
-
// Only collect entries for browser mode projects
|
|
1123
|
-
const browserProjects = getBrowserProjects(context);
|
|
1124
|
-
|
|
1125
1082
|
return Promise.all(
|
|
1126
1083
|
browserProjects.map(async (project) => {
|
|
1127
1084
|
const {
|
|
@@ -1289,16 +1246,51 @@ const generateManifestModule = ({
|
|
|
1289
1246
|
project.normalizedConfig.exclude.patterns,
|
|
1290
1247
|
projectRootPosix,
|
|
1291
1248
|
);
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
lines.push(`
|
|
1249
|
+
const { includeSource } = project.normalizedConfig;
|
|
1250
|
+
const emitContext = (contextVarName: string, regExp: RegExp): void => {
|
|
1251
|
+
lines.push(
|
|
1252
|
+
`const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
|
|
1253
|
+
);
|
|
1254
|
+
lines.push(' recursive: true,');
|
|
1255
|
+
lines.push(` regExp: ${regExp.toString()},`);
|
|
1256
|
+
if (excludeRegExp) {
|
|
1257
|
+
lines.push(` exclude: ${excludeRegExp.toString()},`);
|
|
1258
|
+
}
|
|
1259
|
+
lines.push(" mode: 'lazy',");
|
|
1260
|
+
lines.push('});');
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
if (includeSource.length === 0) {
|
|
1264
|
+
emitContext(varName, includeRegExp);
|
|
1265
|
+
} else {
|
|
1266
|
+
// In-source test files (`includeSource`) carry an
|
|
1267
|
+
// `if (import.meta.rstest)` block. The include context can't see them,
|
|
1268
|
+
// so a second context over the `includeSource` globs backs
|
|
1269
|
+
// host-scheduled loads, while `keys()` only unions the entry-probed
|
|
1270
|
+
// in-source files (the probe does not apply inside the bundle, so raw
|
|
1271
|
+
// source-context keys would execute never-probed files and fail with
|
|
1272
|
+
// "No test suites found"). The probed list can go stale until a
|
|
1273
|
+
// manifest refresh; scheduled-by-path loading never does.
|
|
1274
|
+
emitContext(`${varName}_include`, includeRegExp);
|
|
1275
|
+
emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
|
|
1276
|
+
const probedKeys = testFiles.map((filePath) =>
|
|
1277
|
+
toContextKey(filePath, projectRootPosix),
|
|
1278
|
+
);
|
|
1279
|
+
lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
|
|
1280
|
+
lines.push(
|
|
1281
|
+
`const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
|
|
1282
|
+
);
|
|
1283
|
+
lines.push(`const ${varName} = Object.assign(`);
|
|
1284
|
+
lines.push(
|
|
1285
|
+
` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
|
|
1286
|
+
);
|
|
1287
|
+
lines.push(' {');
|
|
1288
|
+
lines.push(
|
|
1289
|
+
` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
|
|
1290
|
+
);
|
|
1291
|
+
lines.push(' },');
|
|
1292
|
+
lines.push(');');
|
|
1299
1293
|
}
|
|
1300
|
-
lines.push(" mode: 'lazy',");
|
|
1301
|
-
lines.push('});');
|
|
1302
1294
|
} else {
|
|
1303
1295
|
// One-shot runs: the file set is fixed and already filtered, so emit an
|
|
1304
1296
|
// explicit lazy-import map (one chunk per literal `import()`, like the
|
|
@@ -1455,16 +1447,28 @@ const registerWatchCleanup = (): void => {
|
|
|
1455
1447
|
|
|
1456
1448
|
const createBrowserRuntime = async ({
|
|
1457
1449
|
context,
|
|
1458
|
-
projectEntries,
|
|
1450
|
+
projectEntries: initialProjectEntries,
|
|
1451
|
+
browserProjects,
|
|
1452
|
+
shardedEntries,
|
|
1453
|
+
freezeShardedEntries,
|
|
1459
1454
|
tempDir,
|
|
1460
1455
|
isWatchMode,
|
|
1461
1456
|
onTriggerRerun,
|
|
1462
1457
|
containerDistPath,
|
|
1463
1458
|
containerDevServer,
|
|
1464
1459
|
forceHeadless,
|
|
1460
|
+
skipProviderLaunch,
|
|
1461
|
+
appliedModifyRstestConfigEnvironments,
|
|
1465
1462
|
}: {
|
|
1466
1463
|
context: RstestContext;
|
|
1467
1464
|
projectEntries: BrowserProjectEntries[];
|
|
1465
|
+
/**
|
|
1466
|
+
* The explicit browser-project subset (plan output). Drives launch-option
|
|
1467
|
+
* consistency and the container origin (`browserProjects[0]`).
|
|
1468
|
+
*/
|
|
1469
|
+
browserProjects: ProjectContext[];
|
|
1470
|
+
shardedEntries?: Map<string, { entries: Record<string, string> }>;
|
|
1471
|
+
freezeShardedEntries?: boolean;
|
|
1468
1472
|
tempDir: string;
|
|
1469
1473
|
isWatchMode: boolean;
|
|
1470
1474
|
onTriggerRerun?: () => Promise<void>;
|
|
@@ -1472,6 +1476,8 @@ const createBrowserRuntime = async ({
|
|
|
1472
1476
|
containerDevServer?: string;
|
|
1473
1477
|
/** Force headless mode regardless of user config (used for list command) */
|
|
1474
1478
|
forceHeadless?: boolean;
|
|
1479
|
+
skipProviderLaunch?: boolean;
|
|
1480
|
+
appliedModifyRstestConfigEnvironments?: Set<string>;
|
|
1475
1481
|
}): Promise<BrowserRuntime> => {
|
|
1476
1482
|
// ---- Shared singletons (created once, wired into every project server) ----
|
|
1477
1483
|
const containerHtmlTemplate = containerDistPath
|
|
@@ -1493,9 +1499,83 @@ const createBrowserRuntime = async ({
|
|
|
1493
1499
|
}
|
|
1494
1500
|
};
|
|
1495
1501
|
|
|
1496
|
-
|
|
1497
|
-
const browserLaunchOptions =
|
|
1502
|
+
let browserLaunchOptions =
|
|
1498
1503
|
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1504
|
+
let projectEntries = initialProjectEntries;
|
|
1505
|
+
const manifestModules: Array<{
|
|
1506
|
+
manifestPath: string;
|
|
1507
|
+
project: ProjectContext;
|
|
1508
|
+
modules: Record<string, string>;
|
|
1509
|
+
}> = [];
|
|
1510
|
+
|
|
1511
|
+
const createRuntimeWithoutProvider = (): BrowserRuntime => {
|
|
1512
|
+
const firstProject = browserProjects[0]!;
|
|
1513
|
+
return {
|
|
1514
|
+
projectServers: new Map(),
|
|
1515
|
+
containerServer: {
|
|
1516
|
+
projectName: firstProject.name,
|
|
1517
|
+
environmentName: firstProject.environmentName,
|
|
1518
|
+
rsbuildInstance: undefined as unknown as RsbuildInstance,
|
|
1519
|
+
devServer: {
|
|
1520
|
+
close: async () => undefined,
|
|
1521
|
+
} as RsbuildDevServer,
|
|
1522
|
+
port: 0,
|
|
1523
|
+
manifestPath: '',
|
|
1524
|
+
},
|
|
1525
|
+
browser: undefined as unknown as BrowserProviderBrowser,
|
|
1526
|
+
browserLaunchOptions,
|
|
1527
|
+
wsPort: 0,
|
|
1528
|
+
tempDir,
|
|
1529
|
+
setContainerOptions,
|
|
1530
|
+
dispatchHandlers,
|
|
1531
|
+
wss: undefined as unknown as WebSocketServer,
|
|
1532
|
+
projectEntries,
|
|
1533
|
+
};
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
const getProjectEntry = (project: ProjectContext) =>
|
|
1537
|
+
projectEntries.find(
|
|
1538
|
+
(item) => item.project.environmentName === project.environmentName,
|
|
1539
|
+
);
|
|
1540
|
+
|
|
1541
|
+
const refreshManifestModule = (manifestModule: {
|
|
1542
|
+
manifestPath: string;
|
|
1543
|
+
project: ProjectContext;
|
|
1544
|
+
modules: Record<string, string>;
|
|
1545
|
+
}): void => {
|
|
1546
|
+
const entry = getProjectEntry(manifestModule.project);
|
|
1547
|
+
manifestModule.modules[manifestModule.manifestPath] =
|
|
1548
|
+
generateManifestModule({
|
|
1549
|
+
manifestPath: manifestModule.manifestPath,
|
|
1550
|
+
entries: [
|
|
1551
|
+
{
|
|
1552
|
+
project: manifestModule.project,
|
|
1553
|
+
testFiles: entry?.testFiles ?? [],
|
|
1554
|
+
setupFiles: entry?.setupFiles ?? [],
|
|
1555
|
+
},
|
|
1556
|
+
],
|
|
1557
|
+
isWatchMode,
|
|
1558
|
+
});
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
const refreshProjectEntries = async (): Promise<void> => {
|
|
1562
|
+
validateBrowserConfig(context);
|
|
1563
|
+
browserLaunchOptions =
|
|
1564
|
+
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1565
|
+
const updatedShardedEntries = freezeShardedEntries
|
|
1566
|
+
? shardedEntries
|
|
1567
|
+
: context.normalizedConfig.shard
|
|
1568
|
+
? await resolveShardedEntries(context, { silent: true })
|
|
1569
|
+
: shardedEntries;
|
|
1570
|
+
projectEntries = await resolveProjectEntries(
|
|
1571
|
+
context,
|
|
1572
|
+
updatedShardedEntries,
|
|
1573
|
+
browserProjects,
|
|
1574
|
+
);
|
|
1575
|
+
for (const manifestModule of manifestModules) {
|
|
1576
|
+
refreshManifestModule(manifestModule);
|
|
1577
|
+
}
|
|
1578
|
+
};
|
|
1499
1579
|
|
|
1500
1580
|
// Rstest internal aliases that must not be overridden by user config
|
|
1501
1581
|
const browserRuntimePath = fileURLToPath(
|
|
@@ -1505,11 +1585,13 @@ const createBrowserRuntime = async ({
|
|
|
1505
1585
|
// Shared by every project — only the per-project `@rstest/browser-manifest`
|
|
1506
1586
|
// alias varies (one virtual manifest per server).
|
|
1507
1587
|
const staticRstestAliases = {
|
|
1508
|
-
// User test code
|
|
1509
|
-
|
|
1588
|
+
// User test code `import { describe, it } from '@rstest/core'` is NOT
|
|
1589
|
+
// aliased: `applyWebMockRspackConfig` keeps the request external against
|
|
1590
|
+
// `globalThis['@rstest/core']` (node parity), which also keeps the mock
|
|
1591
|
+
// hoister's provider-import ordering correct for `rs.hoisted` callbacks.
|
|
1510
1592
|
// User test code: import { page } from '@rstest/browser'
|
|
1511
1593
|
'@rstest/browser': resolveBrowserFile('browser.ts'),
|
|
1512
|
-
// Browser runtime APIs for entry.ts
|
|
1594
|
+
// Browser runtime APIs for entry.ts
|
|
1513
1595
|
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
1514
1596
|
'@rstest/core/internal/browser-runtime': browserRuntimePath,
|
|
1515
1597
|
};
|
|
@@ -1591,10 +1673,6 @@ const createBrowserRuntime = async ({
|
|
|
1591
1673
|
}
|
|
1592
1674
|
};
|
|
1593
1675
|
|
|
1594
|
-
const entryByEnvironmentName = new Map(
|
|
1595
|
-
projectEntries.map((entry) => [entry.project.environmentName, entry]),
|
|
1596
|
-
);
|
|
1597
|
-
|
|
1598
1676
|
// ---- Build one isolated rsbuild instance + dev server per project ----
|
|
1599
1677
|
const buildProjectServer = async (
|
|
1600
1678
|
project: ProjectContext,
|
|
@@ -1605,20 +1683,27 @@ const createBrowserRuntime = async ({
|
|
|
1605
1683
|
toSafeVarName(project.environmentName),
|
|
1606
1684
|
VIRTUAL_MANIFEST_FILENAME,
|
|
1607
1685
|
);
|
|
1608
|
-
const entry =
|
|
1609
|
-
const
|
|
1686
|
+
const entry = getProjectEntry(project);
|
|
1687
|
+
const virtualManifestModules = {
|
|
1688
|
+
[manifestPath]: generateManifestModule({
|
|
1689
|
+
manifestPath,
|
|
1690
|
+
entries: [
|
|
1691
|
+
{
|
|
1692
|
+
project,
|
|
1693
|
+
testFiles: entry?.testFiles ?? [],
|
|
1694
|
+
setupFiles: entry?.setupFiles ?? [],
|
|
1695
|
+
},
|
|
1696
|
+
],
|
|
1697
|
+
isWatchMode,
|
|
1698
|
+
}),
|
|
1699
|
+
};
|
|
1700
|
+
const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
|
|
1701
|
+
virtualManifestModules,
|
|
1702
|
+
);
|
|
1703
|
+
manifestModules.push({
|
|
1610
1704
|
manifestPath,
|
|
1611
|
-
|
|
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,
|
|
1705
|
+
project,
|
|
1706
|
+
modules: virtualManifestModules,
|
|
1622
1707
|
});
|
|
1623
1708
|
|
|
1624
1709
|
const rstestInternalAliases = {
|
|
@@ -1631,11 +1716,10 @@ const createBrowserRuntime = async ({
|
|
|
1631
1716
|
const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
|
|
1632
1717
|
|
|
1633
1718
|
const rsbuildInstance = await createRsbuild({
|
|
1634
|
-
callerName: 'rstest
|
|
1719
|
+
callerName: 'rstest',
|
|
1635
1720
|
rsbuildConfig: {
|
|
1636
1721
|
root: context.rootPath,
|
|
1637
1722
|
mode: 'development',
|
|
1638
|
-
plugins: project.normalizedConfig.plugins || [],
|
|
1639
1723
|
server: {
|
|
1640
1724
|
printUrls: false,
|
|
1641
1725
|
// Each project gets its own dev server. Honor an explicitly
|
|
@@ -1649,13 +1733,29 @@ const createBrowserRuntime = async ({
|
|
|
1649
1733
|
},
|
|
1650
1734
|
dev: createBrowserRsbuildDevConfig(enableHmr),
|
|
1651
1735
|
environments: {
|
|
1652
|
-
[project.environmentName]:
|
|
1736
|
+
[project.environmentName]:
|
|
1737
|
+
getBrowserRsbuildEnvironmentConfig(project),
|
|
1653
1738
|
},
|
|
1654
1739
|
},
|
|
1655
1740
|
});
|
|
1656
1741
|
|
|
1742
|
+
initModifyRstestConfigHooks(
|
|
1743
|
+
context,
|
|
1744
|
+
rsbuildInstance,
|
|
1745
|
+
[project],
|
|
1746
|
+
[project],
|
|
1747
|
+
{
|
|
1748
|
+
getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
|
|
1749
|
+
onModifyRstestConfigApplied: refreshProjectEntries,
|
|
1750
|
+
appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
|
|
1751
|
+
},
|
|
1752
|
+
);
|
|
1753
|
+
|
|
1657
1754
|
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1658
1755
|
rsbuildInstance.addPlugins([
|
|
1756
|
+
// Same mock runtime as the node build (importActual doppelganger rule +
|
|
1757
|
+
// mock webpack runtime module); order-insensitive and self-contained.
|
|
1758
|
+
pluginMockRuntime,
|
|
1659
1759
|
{
|
|
1660
1760
|
name: 'rstest:browser-user-config',
|
|
1661
1761
|
setup(api) {
|
|
@@ -1710,6 +1810,10 @@ const createBrowserRuntime = async ({
|
|
|
1710
1810
|
define: {
|
|
1711
1811
|
'process.env': rstestEnvDefine,
|
|
1712
1812
|
'import.meta.env': rstestEnvDefine,
|
|
1813
|
+
// In-source `if (import.meta.rstest)` blocks read the
|
|
1814
|
+
// per-file runtime API the client entry publishes on
|
|
1815
|
+
// `globalThis` (node parity: `global['@rstest/core']`).
|
|
1816
|
+
'import.meta.rstest': importMetaRstestDefine('web'),
|
|
1713
1817
|
},
|
|
1714
1818
|
},
|
|
1715
1819
|
output: {
|
|
@@ -1722,6 +1826,14 @@ const createBrowserRuntime = async ({
|
|
|
1722
1826
|
tools: {
|
|
1723
1827
|
rspack: (rspackConfig) => {
|
|
1724
1828
|
rspackConfig.mode = 'development';
|
|
1829
|
+
// Web parameterization of the node mock transform:
|
|
1830
|
+
// RstestPlugin (hoist + path injection), the
|
|
1831
|
+
// `@rstest/core` global external, and
|
|
1832
|
+
// `exportsPresence: 'warn'`.
|
|
1833
|
+
applyWebMockRspackConfig(rspackConfig, {
|
|
1834
|
+
rspack,
|
|
1835
|
+
rootPath: project.rootPath,
|
|
1836
|
+
});
|
|
1725
1837
|
// lazyCompilation's only delivery transport is the HMR
|
|
1726
1838
|
// runtime, so it follows the same gate as HMR (see
|
|
1727
1839
|
// `shouldEnableBrowserHmr`): headed watch only, everything
|
|
@@ -1736,7 +1848,7 @@ const createBrowserRuntime = async ({
|
|
|
1736
1848
|
|
|
1737
1849
|
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
1738
1850
|
// This preserves the sourcemap chain for inline snapshot support
|
|
1739
|
-
// See: https://rspack.
|
|
1851
|
+
// See: https://rspack.rs/config/module-rules#rulesextractsourcemap
|
|
1740
1852
|
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
1741
1853
|
rspackConfig.module = rspackConfig.module || {};
|
|
1742
1854
|
rspackConfig.module.rules =
|
|
@@ -1822,6 +1934,20 @@ const createBrowserRuntime = async ({
|
|
|
1822
1934
|
]);
|
|
1823
1935
|
}
|
|
1824
1936
|
|
|
1937
|
+
if (skipProviderLaunch) {
|
|
1938
|
+
await rsbuildInstance.initConfigs({ action: 'dev' });
|
|
1939
|
+
return {
|
|
1940
|
+
projectName: project.name,
|
|
1941
|
+
environmentName: project.environmentName,
|
|
1942
|
+
rsbuildInstance,
|
|
1943
|
+
devServer: {
|
|
1944
|
+
close: async () => undefined,
|
|
1945
|
+
} as RsbuildDevServer,
|
|
1946
|
+
port: 0,
|
|
1947
|
+
manifestPath,
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1825
1951
|
// Register coverage plugin if this project enables coverage
|
|
1826
1952
|
const coverage = project.normalizedConfig.coverage;
|
|
1827
1953
|
if (coverage?.enabled && context.command !== 'list') {
|
|
@@ -1948,6 +2074,10 @@ const createBrowserRuntime = async ({
|
|
|
1948
2074
|
throw error;
|
|
1949
2075
|
}
|
|
1950
2076
|
|
|
2077
|
+
if (skipProviderLaunch) {
|
|
2078
|
+
return createRuntimeWithoutProvider();
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1951
2081
|
// browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
|
|
1952
2082
|
// otherwise) and index 0 is the designated container origin.
|
|
1953
2083
|
const containerServer = projectServers.get(browserProjects[0]!.name)!;
|
|
@@ -1983,6 +2113,7 @@ const createBrowserRuntime = async ({
|
|
|
1983
2113
|
setContainerOptions,
|
|
1984
2114
|
dispatchHandlers,
|
|
1985
2115
|
wss,
|
|
2116
|
+
projectEntries,
|
|
1986
2117
|
};
|
|
1987
2118
|
} catch (error) {
|
|
1988
2119
|
wss.close();
|
|
@@ -1993,10 +2124,10 @@ const createBrowserRuntime = async ({
|
|
|
1993
2124
|
|
|
1994
2125
|
async function resolveProjectEntries(
|
|
1995
2126
|
context: RstestContext,
|
|
1996
|
-
shardedEntries
|
|
2127
|
+
shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
|
|
2128
|
+
browserProjects: ProjectContext[],
|
|
1997
2129
|
): Promise<BrowserProjectEntries[]> {
|
|
1998
2130
|
if (shardedEntries) {
|
|
1999
|
-
const browserProjects = getBrowserProjects(context);
|
|
2000
2131
|
const projectEntries: BrowserProjectEntries[] = [];
|
|
2001
2132
|
for (const project of browserProjects) {
|
|
2002
2133
|
const entryInfo = shardedEntries.get(project.environmentName);
|
|
@@ -2014,7 +2145,7 @@ async function resolveProjectEntries(
|
|
|
2014
2145
|
}
|
|
2015
2146
|
return projectEntries;
|
|
2016
2147
|
}
|
|
2017
|
-
return collectProjectEntries(context);
|
|
2148
|
+
return collectProjectEntries(context, browserProjects);
|
|
2018
2149
|
}
|
|
2019
2150
|
|
|
2020
2151
|
// ============================================================================
|
|
@@ -2026,11 +2157,18 @@ export const runBrowserController = async (
|
|
|
2026
2157
|
options?: BrowserTestRunOptions,
|
|
2027
2158
|
): Promise<BrowserTestRunResult | void> => {
|
|
2028
2159
|
const {
|
|
2029
|
-
skipOnTestRunEnd = false,
|
|
2030
2160
|
allowEmptyWatchRun = false,
|
|
2161
|
+
allowEmptyRun = false,
|
|
2162
|
+
filesOnly = false,
|
|
2031
2163
|
onTraceEvents,
|
|
2164
|
+
env,
|
|
2032
2165
|
} = options ?? {};
|
|
2033
2166
|
const buildStart = Date.now();
|
|
2167
|
+
// Non-watch vs watch is the live switch for self-finalize: in non-watch runs
|
|
2168
|
+
// core owns the unified finalize (reporters, exit code, coverage) through
|
|
2169
|
+
// `finalizeRunCycle`, so the host never self-finalizes and always returns a
|
|
2170
|
+
// fully-populated result with `close`. Watch reruns keep their host-driven
|
|
2171
|
+
// per-rerun finalize.
|
|
2034
2172
|
const isWatchMode = context.command === 'watch';
|
|
2035
2173
|
|
|
2036
2174
|
// Per-file PhaseTrackers, populated only when `--trace` is on (caller
|
|
@@ -2041,7 +2179,11 @@ export const runBrowserController = async (
|
|
|
2041
2179
|
const phaseTrackers = onTraceEvents
|
|
2042
2180
|
? new Map<string, PhaseTracker>()
|
|
2043
2181
|
: undefined;
|
|
2044
|
-
|
|
2182
|
+
// Explicit projects input (plan output) replaces re-deriving `browser.enabled`
|
|
2183
|
+
// projects from `context`, whose `projects` array is mutated during planning.
|
|
2184
|
+
// Falls back to re-derivation only when the caller passes no list at all —
|
|
2185
|
+
// an explicit empty subset must stay empty, not widen to every project.
|
|
2186
|
+
const browserProjects = options?.projects ?? getBrowserProjects(context);
|
|
2045
2187
|
const useHeadlessDirect = browserProjects.every(
|
|
2046
2188
|
(project) => project.normalizedConfig.browser.headless,
|
|
2047
2189
|
);
|
|
@@ -2109,7 +2251,7 @@ export const runBrowserController = async (
|
|
|
2109
2251
|
close,
|
|
2110
2252
|
};
|
|
2111
2253
|
|
|
2112
|
-
if (
|
|
2254
|
+
if (isWatchMode) {
|
|
2113
2255
|
for (const reporter of context.reporters) {
|
|
2114
2256
|
await (reporter as Reporter).onTestRunEnd?.({
|
|
2115
2257
|
results: [],
|
|
@@ -2133,11 +2275,16 @@ export const runBrowserController = async (
|
|
|
2133
2275
|
error: unknown,
|
|
2134
2276
|
cleanup?: () => Promise<void>,
|
|
2135
2277
|
): Promise<BrowserTestRunResult> => {
|
|
2136
|
-
|
|
2278
|
+
// Non-watch runs defer the exit code to core's `finalizeRunCycle`, which
|
|
2279
|
+
// raises it from the returned outcome's `errors`. Watch reruns keep owning
|
|
2280
|
+
// their own exit code.
|
|
2281
|
+
if (isWatchMode) {
|
|
2282
|
+
ensureProcessExitCode(1);
|
|
2283
|
+
}
|
|
2137
2284
|
|
|
2138
2285
|
const normalizedError = toError(error);
|
|
2139
2286
|
|
|
2140
|
-
if (cleanup &&
|
|
2287
|
+
if (cleanup && !isWatchMode) {
|
|
2141
2288
|
return buildErrorResult(normalizedError, cleanup);
|
|
2142
2289
|
}
|
|
2143
2290
|
|
|
@@ -2159,7 +2306,7 @@ export const runBrowserController = async (
|
|
|
2159
2306
|
};
|
|
2160
2307
|
|
|
2161
2308
|
const notifyTestRunStart = async (): Promise<void> => {
|
|
2162
|
-
if (
|
|
2309
|
+
if (!isWatchMode) {
|
|
2163
2310
|
return;
|
|
2164
2311
|
}
|
|
2165
2312
|
|
|
@@ -2188,7 +2335,7 @@ export const runBrowserController = async (
|
|
|
2188
2335
|
unhandledErrors?: Error[];
|
|
2189
2336
|
filterRerunTestPaths?: string[];
|
|
2190
2337
|
}): Promise<void> => {
|
|
2191
|
-
if (
|
|
2338
|
+
if (!isWatchMode) {
|
|
2192
2339
|
return;
|
|
2193
2340
|
}
|
|
2194
2341
|
|
|
@@ -2249,19 +2396,38 @@ export const runBrowserController = async (
|
|
|
2249
2396
|
}
|
|
2250
2397
|
}
|
|
2251
2398
|
|
|
2252
|
-
|
|
2399
|
+
let projectEntries = await resolveProjectEntries(
|
|
2253
2400
|
context,
|
|
2254
2401
|
options?.shardedEntries,
|
|
2402
|
+
browserProjects,
|
|
2255
2403
|
);
|
|
2256
|
-
|
|
2404
|
+
let totalTests = projectEntries.reduce(
|
|
2257
2405
|
(total, item) => total + item.testFiles.length,
|
|
2258
2406
|
0,
|
|
2259
2407
|
);
|
|
2260
2408
|
const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
|
|
2409
|
+
const shouldInitializeEmptyBrowserHooks =
|
|
2410
|
+
totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
|
|
2411
|
+
|
|
2412
|
+
const createEmptyRunResult = (): BrowserTestRunResult => {
|
|
2413
|
+
const elapsed = Math.max(0, Date.now() - buildStart);
|
|
2414
|
+
return {
|
|
2415
|
+
results: [],
|
|
2416
|
+
testResults: [],
|
|
2417
|
+
duration: {
|
|
2418
|
+
totalTime: elapsed,
|
|
2419
|
+
buildTime: elapsed,
|
|
2420
|
+
testTime: 0,
|
|
2421
|
+
},
|
|
2422
|
+
hasFailure: false,
|
|
2423
|
+
getSourcemap: getBrowserSourcemap,
|
|
2424
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
2425
|
+
};
|
|
2426
|
+
};
|
|
2261
2427
|
|
|
2262
|
-
|
|
2428
|
+
const reportEmptyTestSet = (): boolean => {
|
|
2263
2429
|
const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
|
|
2264
|
-
if (!
|
|
2430
|
+
if (isWatchMode || !allowEmptyRun) {
|
|
2265
2431
|
const message = shouldKeepWatchingWithEmptySet
|
|
2266
2432
|
? 'No test files found.'
|
|
2267
2433
|
: getNoTestFilesMessage({
|
|
@@ -2288,15 +2454,30 @@ export const runBrowserController = async (
|
|
|
2288
2454
|
}
|
|
2289
2455
|
}
|
|
2290
2456
|
|
|
2291
|
-
|
|
2457
|
+
// In non-watch runs the host returns a void outcome and core's
|
|
2458
|
+
// `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
|
|
2459
|
+
// the host must not set the code itself. Watch keeps its own exit code.
|
|
2460
|
+
if (
|
|
2461
|
+
isWatchMode &&
|
|
2462
|
+
code !== 0 &&
|
|
2463
|
+
!shouldKeepWatchingWithEmptySet &&
|
|
2464
|
+
!allowEmptyRun
|
|
2465
|
+
) {
|
|
2292
2466
|
ensureProcessExitCode(code);
|
|
2293
2467
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2468
|
+
|
|
2469
|
+
return !shouldKeepWatchingWithEmptySet;
|
|
2470
|
+
};
|
|
2471
|
+
|
|
2472
|
+
if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
|
|
2473
|
+
if (reportEmptyTestSet()) {
|
|
2474
|
+
return allowEmptyRun ? createEmptyRunResult() : undefined;
|
|
2296
2475
|
}
|
|
2297
2476
|
}
|
|
2298
2477
|
|
|
2299
|
-
|
|
2478
|
+
if (!filesOnly) {
|
|
2479
|
+
await notifyTestRunStart();
|
|
2480
|
+
}
|
|
2300
2481
|
|
|
2301
2482
|
const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
|
|
2302
2483
|
const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
|
|
@@ -2327,6 +2508,9 @@ export const runBrowserController = async (
|
|
|
2327
2508
|
runtime = await createBrowserRuntime({
|
|
2328
2509
|
context,
|
|
2329
2510
|
projectEntries,
|
|
2511
|
+
browserProjects,
|
|
2512
|
+
shardedEntries: options?.shardedEntries,
|
|
2513
|
+
freezeShardedEntries: options?.freezeShardedEntries,
|
|
2330
2514
|
tempDir,
|
|
2331
2515
|
isWatchMode,
|
|
2332
2516
|
onTriggerRerun: isWatchMode
|
|
@@ -2336,6 +2520,9 @@ export const runBrowserController = async (
|
|
|
2336
2520
|
: undefined,
|
|
2337
2521
|
containerDistPath,
|
|
2338
2522
|
containerDevServer,
|
|
2523
|
+
skipProviderLaunch: filesOnly,
|
|
2524
|
+
appliedModifyRstestConfigEnvironments:
|
|
2525
|
+
options?.appliedModifyRstestConfigEnvironments,
|
|
2339
2526
|
});
|
|
2340
2527
|
} catch (error) {
|
|
2341
2528
|
return failWithError(error, async () => {
|
|
@@ -2355,9 +2542,37 @@ export const runBrowserController = async (
|
|
|
2355
2542
|
}
|
|
2356
2543
|
}
|
|
2357
2544
|
|
|
2358
|
-
|
|
2545
|
+
projectEntries = runtime.projectEntries;
|
|
2546
|
+
totalTests = projectEntries.reduce(
|
|
2547
|
+
(total, item) => total + item.testFiles.length,
|
|
2548
|
+
0,
|
|
2549
|
+
);
|
|
2550
|
+
|
|
2359
2551
|
const buildTime = Date.now() - buildStart;
|
|
2360
2552
|
|
|
2553
|
+
if (filesOnly) {
|
|
2554
|
+
return {
|
|
2555
|
+
results: [],
|
|
2556
|
+
testResults: [],
|
|
2557
|
+
duration: {
|
|
2558
|
+
totalTime: buildTime,
|
|
2559
|
+
buildTime,
|
|
2560
|
+
testTime: 0,
|
|
2561
|
+
},
|
|
2562
|
+
hasFailure: false,
|
|
2563
|
+
getSourcemap: getBrowserSourcemap,
|
|
2564
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
2565
|
+
close: () => destroyBrowserRuntime(runtime),
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
if (totalTests === 0 && reportEmptyTestSet()) {
|
|
2570
|
+
await destroyBrowserRuntime(runtime);
|
|
2571
|
+
return allowEmptyRun ? createEmptyRunResult() : undefined;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
const { browser, browserLaunchOptions, wsPort, wss } = runtime;
|
|
2575
|
+
|
|
2361
2576
|
// Collect all test files from project entries with project info
|
|
2362
2577
|
// Normalize paths to posix format for cross-platform compatibility
|
|
2363
2578
|
const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
|
|
@@ -2374,17 +2589,17 @@ export const runBrowserController = async (
|
|
|
2374
2589
|
name: project.name,
|
|
2375
2590
|
environmentName: project.environmentName,
|
|
2376
2591
|
projectRoot: normalize(project.rootPath),
|
|
2377
|
-
runtimeConfig: serializableConfig(
|
|
2592
|
+
runtimeConfig: serializableConfig(
|
|
2593
|
+
// `env` is the post-globalSetup change-set from the core pre-cycle
|
|
2594
|
+
// stage; the projection layers it between the static base and the
|
|
2595
|
+
// user `test.env` config.
|
|
2596
|
+
projectRuntimeConfig(project, { envMode: 'static', envOverlay: env }),
|
|
2597
|
+
),
|
|
2378
2598
|
viewport: project.normalizedConfig.browser.viewport,
|
|
2379
2599
|
}),
|
|
2380
2600
|
);
|
|
2381
2601
|
|
|
2382
|
-
|
|
2383
|
-
const maxTestTimeoutForRpc = Math.max(
|
|
2384
|
-
...browserProjects.map(
|
|
2385
|
-
(p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
|
|
2386
|
-
),
|
|
2387
|
-
);
|
|
2602
|
+
const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
|
|
2388
2603
|
|
|
2389
2604
|
const projectRunnerUrls = Object.fromEntries(
|
|
2390
2605
|
[...runtime.projectServers].map(([name, server]) => [
|
|
@@ -2497,18 +2712,64 @@ export const runBrowserController = async (
|
|
|
2497
2712
|
const caseResults: TestResult[] = [];
|
|
2498
2713
|
let fatalError: Error | null = null;
|
|
2499
2714
|
|
|
2715
|
+
// Runner lifecycle events flow through the shared RunnerEventSink (the same
|
|
2716
|
+
// pump the node pool uses), so browser mode feeds stateManager and fans out to
|
|
2717
|
+
// reporters via one implementation. One sink is bound per browser project up
|
|
2718
|
+
// front from the executor's own project plan (`browserProjects`) — never from
|
|
2719
|
+
// `context.projects`, which planning mutates to also contain node projects. The
|
|
2720
|
+
// previous lazy resolver fell back to `context.projects[0]`, so a browser event
|
|
2721
|
+
// could be attributed to a *node* project's config; binding per browser project
|
|
2722
|
+
// here removes that fallback and keeps per-project `onConsoleLog` filtering and
|
|
2723
|
+
// `resolveSnapshotPath` correct across browser projects that share a relative
|
|
2724
|
+
// test path.
|
|
2725
|
+
const runnerSinks = new Map<string, RunnerEventSink>(
|
|
2726
|
+
browserProjects.map((project) => [
|
|
2727
|
+
project.name,
|
|
2728
|
+
createRunnerEventSink(context, project.normalizedConfig),
|
|
2729
|
+
]),
|
|
2730
|
+
);
|
|
2731
|
+
const firstBrowserSink = runnerSinks.get(browserProjects[0]!.name)!;
|
|
2732
|
+
|
|
2733
|
+
// testPath -> owning project name, stamped from the authoritative client
|
|
2734
|
+
// file-start event (it carries the manifest-resolved projectName) before any
|
|
2735
|
+
// other per-file event for that path fires — including on watch reruns, so the
|
|
2736
|
+
// mapping stays correct when a rerun adds a file. Fully eliminating this map in
|
|
2737
|
+
// favor of a project stamp on every wire event is deferred (it would add
|
|
2738
|
+
// `project` to the shared `TestResult`/`TestFileResult` payloads).
|
|
2739
|
+
const projectNameByTestPath = new Map<string, string>();
|
|
2740
|
+
|
|
2741
|
+
const sinkForProjectName = (projectName: string): RunnerEventSink =>
|
|
2742
|
+
runnerSinks.get(projectName) ?? firstBrowserSink;
|
|
2743
|
+
|
|
2744
|
+
const sinkForTestPath = (testPath: string): RunnerEventSink => {
|
|
2745
|
+
const projectName = projectNameByTestPath.get(testPath);
|
|
2746
|
+
return projectName ? sinkForProjectName(projectName) : firstBrowserSink;
|
|
2747
|
+
};
|
|
2748
|
+
|
|
2749
|
+
// Silent-console buffering runs through the shared controller — the same
|
|
2750
|
+
// engine the node worker uses — so `silent: 'passed-only'` buffers logs and
|
|
2751
|
+
// replays only the failing tasks'. Intercepted replays route through the
|
|
2752
|
+
// owning project's sink, so they honor per-project `onConsoleLog` and
|
|
2753
|
+
// `disableConsoleIntercept` (the browser host previously flushed straight to
|
|
2754
|
+
// reporters, bypassing both). `writeOriginalLog` is a host-side no-op: page
|
|
2755
|
+
// logs have no host "original stream" — the page console and headed terminal
|
|
2756
|
+
// forwarding already show them, so re-emitting here would double-print.
|
|
2757
|
+
const silentConsoleController = createSilentConsoleController({
|
|
2758
|
+
runtimeConfig: {
|
|
2759
|
+
silent: context.normalizedConfig.silent,
|
|
2760
|
+
disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
|
|
2761
|
+
},
|
|
2762
|
+
emitInterceptedLog: (log) =>
|
|
2763
|
+
sinkForTestPath(log.testPath).onConsoleLog(log),
|
|
2764
|
+
writeOriginalLog: () => {},
|
|
2765
|
+
});
|
|
2766
|
+
|
|
2500
2767
|
const snapshotRpcMethods = {
|
|
2501
2768
|
async resolveSnapshotPath(testPath: string): Promise<string> {
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
context.normalizedConfig.resolveSnapshotPath
|
|
2505
|
-
|
|
2506
|
-
join(
|
|
2507
|
-
dirname(testPath),
|
|
2508
|
-
'__snapshots__',
|
|
2509
|
-
`${basename(testPath)}${snapExtension}`,
|
|
2510
|
-
));
|
|
2511
|
-
return resolver(testPath, snapExtension);
|
|
2769
|
+
return resolveSnapshotPathDefault(
|
|
2770
|
+
testPath,
|
|
2771
|
+
context.normalizedConfig.resolveSnapshotPath,
|
|
2772
|
+
);
|
|
2512
2773
|
},
|
|
2513
2774
|
async readSnapshotFile(filepath: string): Promise<string | null> {
|
|
2514
2775
|
try {
|
|
@@ -2534,6 +2795,7 @@ export const runBrowserController = async (
|
|
|
2534
2795
|
const handleTestFileStart = async (
|
|
2535
2796
|
payload: TestFileStartPayload,
|
|
2536
2797
|
): Promise<void> => {
|
|
2798
|
+
projectNameByTestPath.set(payload.testPath, payload.projectName);
|
|
2537
2799
|
if (phaseTrackers) {
|
|
2538
2800
|
const tracker = new PhaseTracker({
|
|
2539
2801
|
trace: {
|
|
@@ -2545,51 +2807,37 @@ export const runBrowserController = async (
|
|
|
2545
2807
|
tracker.transition('prepare');
|
|
2546
2808
|
phaseTrackers.set(payload.testPath, tracker);
|
|
2547
2809
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
),
|
|
2556
|
-
);
|
|
2810
|
+
// The client sends `{ testPath, projectName }`; the sink adapter builds the
|
|
2811
|
+
// `TestFileInfo` the reporters and stateManager expect.
|
|
2812
|
+
await sinkForProjectName(payload.projectName).onTestFileStart({
|
|
2813
|
+
testId: getFileTaskId(payload.testPath),
|
|
2814
|
+
testPath: payload.testPath,
|
|
2815
|
+
tests: [],
|
|
2816
|
+
});
|
|
2557
2817
|
};
|
|
2558
2818
|
|
|
2559
2819
|
const handleTestFileReady = async (
|
|
2560
2820
|
payload: TestFileReadyPayload,
|
|
2561
2821
|
): Promise<void> => {
|
|
2562
2822
|
phaseTrackers?.get(payload.testPath)?.transition('tests');
|
|
2563
|
-
await
|
|
2564
|
-
context.reporters.map((reporter) =>
|
|
2565
|
-
(reporter as Reporter).onTestFileReady?.(payload),
|
|
2566
|
-
),
|
|
2567
|
-
);
|
|
2823
|
+
await sinkForTestPath(payload.testPath).onTestFileReady(payload);
|
|
2568
2824
|
};
|
|
2569
2825
|
|
|
2570
2826
|
const handleTestSuiteStart = async (
|
|
2571
2827
|
payload: TestSuiteStartPayload,
|
|
2572
2828
|
): Promise<void> => {
|
|
2573
2829
|
phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
|
|
2574
|
-
await
|
|
2575
|
-
context.reporters.map((reporter) =>
|
|
2576
|
-
(reporter as Reporter).onTestSuiteStart?.(payload),
|
|
2577
|
-
),
|
|
2578
|
-
);
|
|
2830
|
+
await sinkForTestPath(payload.testPath).onTestSuiteStart(payload);
|
|
2579
2831
|
};
|
|
2580
2832
|
|
|
2581
2833
|
const handleTestSuiteResult = async (
|
|
2582
2834
|
payload: TestSuiteResultPayload,
|
|
2583
2835
|
): Promise<void> => {
|
|
2584
2836
|
phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
|
|
2585
|
-
await
|
|
2586
|
-
context.reporters.map((reporter) =>
|
|
2587
|
-
(reporter as Reporter).onTestSuiteResult?.(payload),
|
|
2588
|
-
),
|
|
2589
|
-
);
|
|
2837
|
+
await sinkForTestPath(payload.testPath).onTestSuiteResult(payload);
|
|
2590
2838
|
|
|
2591
2839
|
if (context.normalizedConfig.silent === 'passed-only') {
|
|
2592
|
-
|
|
2840
|
+
silentConsoleController.flushBufferedLogsForTask({
|
|
2593
2841
|
taskId: payload.testId,
|
|
2594
2842
|
status: payload.status,
|
|
2595
2843
|
taskParentNames: payload.parentNames,
|
|
@@ -2603,24 +2851,17 @@ export const runBrowserController = async (
|
|
|
2603
2851
|
payload: TestCaseStartPayload,
|
|
2604
2852
|
): Promise<void> => {
|
|
2605
2853
|
phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
|
|
2606
|
-
await
|
|
2607
|
-
|
|
2608
|
-
(reporter as Reporter).onTestCaseStart?.(payload),
|
|
2609
|
-
),
|
|
2610
|
-
);
|
|
2854
|
+
// Fire-and-forget on both transports (the sink does not await case-start).
|
|
2855
|
+
sinkForTestPath(payload.testPath).onTestCaseStart(payload);
|
|
2611
2856
|
};
|
|
2612
2857
|
|
|
2613
2858
|
const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
|
|
2614
2859
|
caseResults.push(payload);
|
|
2615
2860
|
phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
|
|
2616
|
-
await
|
|
2617
|
-
context.reporters.map((reporter) =>
|
|
2618
|
-
(reporter as Reporter).onTestCaseResult?.(payload),
|
|
2619
|
-
),
|
|
2620
|
-
);
|
|
2861
|
+
await sinkForTestPath(payload.testPath).onTestCaseResult(payload);
|
|
2621
2862
|
|
|
2622
2863
|
if (context.normalizedConfig.silent === 'passed-only') {
|
|
2623
|
-
|
|
2864
|
+
silentConsoleController.flushBufferedLogsForTask({
|
|
2624
2865
|
taskId: payload.testId,
|
|
2625
2866
|
status: payload.status,
|
|
2626
2867
|
taskParentNames: payload.parentNames,
|
|
@@ -2635,9 +2876,6 @@ export const runBrowserController = async (
|
|
|
2635
2876
|
): Promise<void> => {
|
|
2636
2877
|
reporterResults.push(payload);
|
|
2637
2878
|
context.updateReporterResultState([payload], payload.results);
|
|
2638
|
-
if (payload.snapshotResult) {
|
|
2639
|
-
context.snapshotManager.add(payload.snapshotResult);
|
|
2640
|
-
}
|
|
2641
2879
|
|
|
2642
2880
|
if (phaseTrackers) {
|
|
2643
2881
|
const tracker = phaseTrackers.get(payload.testPath);
|
|
@@ -2650,7 +2888,7 @@ export const runBrowserController = async (
|
|
|
2650
2888
|
}
|
|
2651
2889
|
|
|
2652
2890
|
if (context.normalizedConfig.silent === 'passed-only') {
|
|
2653
|
-
|
|
2891
|
+
silentConsoleController.flushBufferedLogsForTask({
|
|
2654
2892
|
taskId: payload.testId,
|
|
2655
2893
|
status: payload.status,
|
|
2656
2894
|
taskParentNames: payload.parentNames,
|
|
@@ -2659,12 +2897,12 @@ export const runBrowserController = async (
|
|
|
2659
2897
|
});
|
|
2660
2898
|
}
|
|
2661
2899
|
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
);
|
|
2667
|
-
if (payload.status === 'fail') {
|
|
2900
|
+
// Feeds stateManager, fans out onTestFileResult to reporters, and ingests
|
|
2901
|
+
// payload.snapshotResult (the snapshotManager.add moved into the sink).
|
|
2902
|
+
await sinkForTestPath(payload.testPath).onTestFileResult(payload);
|
|
2903
|
+
// In non-watch runs core owns the exit code via `finalizeRunCycle` (the
|
|
2904
|
+
// failing file rides the returned outcome); watch reruns set it here.
|
|
2905
|
+
if (isWatchMode && payload.status === 'fail') {
|
|
2668
2906
|
ensureProcessExitCode(1);
|
|
2669
2907
|
}
|
|
2670
2908
|
};
|
|
@@ -2672,7 +2910,8 @@ export const runBrowserController = async (
|
|
|
2672
2910
|
const handleLog = async (payload: LogPayload): Promise<void> => {
|
|
2673
2911
|
const log: UserConsoleLog = {
|
|
2674
2912
|
content: payload.content,
|
|
2675
|
-
|
|
2913
|
+
// Same colored level label as the node worker's CustomConsole.
|
|
2914
|
+
name: getPrettyConsoleName(payload.level),
|
|
2676
2915
|
taskId: payload.taskId,
|
|
2677
2916
|
taskName: payload.taskName,
|
|
2678
2917
|
taskParentNames: payload.taskParentNames,
|
|
@@ -2681,135 +2920,17 @@ export const runBrowserController = async (
|
|
|
2681
2920
|
type: payload.type,
|
|
2682
2921
|
trace: payload.trace,
|
|
2683
2922
|
};
|
|
2684
|
-
|
|
2685
|
-
return;
|
|
2686
|
-
}
|
|
2687
|
-
|
|
2688
|
-
if (context.normalizedConfig.silent === 'passed-only') {
|
|
2689
|
-
bufferConsoleLog(log);
|
|
2690
|
-
return;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
if (context.normalizedConfig.disableConsoleIntercept) {
|
|
2694
|
-
return;
|
|
2695
|
-
}
|
|
2696
|
-
|
|
2697
|
-
await emitUserConsoleLog(log);
|
|
2923
|
+
silentConsoleController.onConsoleLog(log);
|
|
2698
2924
|
};
|
|
2699
2925
|
|
|
2700
2926
|
const handleFatal = async (payload: FatalPayload): Promise<void> => {
|
|
2701
2927
|
const error = new Error(payload.message);
|
|
2702
2928
|
error.stack = payload.stack;
|
|
2703
2929
|
fatalError = error;
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
const suiteIdsByChain = new Map<string, string>();
|
|
2709
|
-
|
|
2710
|
-
const getSuiteChainKey = (names: string[]): string => {
|
|
2711
|
-
return names.join('\u0000');
|
|
2712
|
-
};
|
|
2713
|
-
|
|
2714
|
-
const pushTaskId = (taskIds: string[], taskId: string): void => {
|
|
2715
|
-
if (!taskIds.includes(taskId)) {
|
|
2716
|
-
taskIds.push(taskId);
|
|
2717
|
-
}
|
|
2718
|
-
};
|
|
2719
|
-
|
|
2720
|
-
const shouldEmitUserConsoleLog = (log: UserConsoleLog): boolean => {
|
|
2721
|
-
return (
|
|
2722
|
-
context.normalizedConfig.onConsoleLog?.(log.content, log.type) !== false
|
|
2723
|
-
);
|
|
2724
|
-
};
|
|
2725
|
-
|
|
2726
|
-
const emitUserConsoleLog = async (log: UserConsoleLog): Promise<void> => {
|
|
2727
|
-
if (!shouldEmitUserConsoleLog(log)) {
|
|
2728
|
-
return;
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
|
-
await Promise.all(
|
|
2732
|
-
context.reporters.map((reporter) =>
|
|
2733
|
-
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
2734
|
-
),
|
|
2735
|
-
);
|
|
2736
|
-
};
|
|
2737
|
-
|
|
2738
|
-
const bufferConsoleLog = (log: UserConsoleLog): void => {
|
|
2739
|
-
const taskId = getBufferedLogTaskId(log);
|
|
2740
|
-
const logs = bufferedConsoleLogs.get(taskId) || [];
|
|
2741
|
-
logs.push(log);
|
|
2742
|
-
bufferedConsoleLogs.set(taskId, logs);
|
|
2743
|
-
|
|
2744
|
-
if (log.taskType === 'suite' && log.taskId) {
|
|
2745
|
-
suiteIdsByChain.set(
|
|
2746
|
-
getSuiteChainKey([...(log.taskParentNames || []), log.taskName || '']),
|
|
2747
|
-
log.taskId,
|
|
2748
|
-
);
|
|
2749
|
-
}
|
|
2750
|
-
};
|
|
2751
|
-
|
|
2752
|
-
const flushBufferedLogsForTask = async ({
|
|
2753
|
-
taskId,
|
|
2754
|
-
status,
|
|
2755
|
-
taskParentNames,
|
|
2756
|
-
taskType,
|
|
2757
|
-
testPath,
|
|
2758
|
-
}: {
|
|
2759
|
-
taskId: string;
|
|
2760
|
-
status: TestResult['status'];
|
|
2761
|
-
taskParentNames?: string[];
|
|
2762
|
-
taskType?: 'file' | 'suite' | 'case';
|
|
2763
|
-
testPath: string;
|
|
2764
|
-
}): Promise<void> => {
|
|
2765
|
-
if (status !== 'fail') {
|
|
2766
|
-
bufferedConsoleLogs.delete(taskId);
|
|
2767
|
-
return;
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
const taskIdsToFlush: string[] = [];
|
|
2771
|
-
|
|
2772
|
-
if (taskType === 'case') {
|
|
2773
|
-
pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
|
|
2774
|
-
|
|
2775
|
-
const suiteNames = taskParentNames || [];
|
|
2776
|
-
for (let i = 0; i < suiteNames.length; i++) {
|
|
2777
|
-
const suiteId = suiteIdsByChain.get(
|
|
2778
|
-
getSuiteChainKey(suiteNames.slice(0, i + 1)),
|
|
2779
|
-
);
|
|
2780
|
-
|
|
2781
|
-
if (suiteId) {
|
|
2782
|
-
pushTaskId(taskIdsToFlush, suiteId);
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
pushTaskId(taskIdsToFlush, taskId);
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
if (taskType === 'suite') {
|
|
2790
|
-
pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
|
|
2791
|
-
pushTaskId(taskIdsToFlush, taskId);
|
|
2792
|
-
}
|
|
2793
|
-
|
|
2794
|
-
if (taskType === 'file') {
|
|
2795
|
-
pushTaskId(taskIdsToFlush, taskId);
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
for (const bufferedTaskId of taskIdsToFlush) {
|
|
2799
|
-
const logs = bufferedConsoleLogs.get(bufferedTaskId);
|
|
2800
|
-
if (!logs) {
|
|
2801
|
-
continue;
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
bufferedConsoleLogs.delete(bufferedTaskId);
|
|
2805
|
-
|
|
2806
|
-
for (const log of logs) {
|
|
2807
|
-
await Promise.all(
|
|
2808
|
-
context.reporters.map((reporter) =>
|
|
2809
|
-
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
2810
|
-
),
|
|
2811
|
-
);
|
|
2812
|
-
}
|
|
2930
|
+
// Non-watch runs surface the fatal error through the returned outcome and
|
|
2931
|
+
// let core's `finalizeRunCycle` set the exit code; watch reruns set it here.
|
|
2932
|
+
if (isWatchMode) {
|
|
2933
|
+
ensureProcessExitCode(1);
|
|
2813
2934
|
}
|
|
2814
2935
|
};
|
|
2815
2936
|
|
|
@@ -3117,6 +3238,18 @@ export const runBrowserController = async (
|
|
|
3117
3238
|
}
|
|
3118
3239
|
};
|
|
3119
3240
|
|
|
3241
|
+
// Bailed files never run, so they carry no case results — mirror the node
|
|
3242
|
+
// pool's skip result (`runInPool.ts`) so the summary reports them as skipped
|
|
3243
|
+
// rather than dropping them silently.
|
|
3244
|
+
const makeSkippedFileResult = (file: TestFileInfo): TestFileResult => ({
|
|
3245
|
+
testId: getFileTaskId(file.testPath),
|
|
3246
|
+
status: 'skip',
|
|
3247
|
+
name: '',
|
|
3248
|
+
testPath: file.testPath,
|
|
3249
|
+
project: file.projectName,
|
|
3250
|
+
results: [],
|
|
3251
|
+
});
|
|
3252
|
+
|
|
3120
3253
|
const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
|
|
3121
3254
|
if (files.length === 0) {
|
|
3122
3255
|
return;
|
|
@@ -3134,6 +3267,7 @@ export const runBrowserController = async (
|
|
|
3134
3267
|
|
|
3135
3268
|
const queue = [...files];
|
|
3136
3269
|
const concurrency = getHeadlessConcurrency(context, queue.length);
|
|
3270
|
+
const bail = context.normalizedConfig.bail;
|
|
3137
3271
|
|
|
3138
3272
|
const worker = async (): Promise<void> => {
|
|
3139
3273
|
while (
|
|
@@ -3141,6 +3275,19 @@ export const runBrowserController = async (
|
|
|
3141
3275
|
!run.cancelled &&
|
|
3142
3276
|
runLifecycle.isTokenActive(run.token)
|
|
3143
3277
|
) {
|
|
3278
|
+
// Cross-file bail gate (parity with the node pool's pickup-time skip
|
|
3279
|
+
// at `runInPool.ts`): once the cycle-wide failed count reaches `bail`,
|
|
3280
|
+
// drain the remaining files as skipped instead of running them. The
|
|
3281
|
+
// count is cycle-scoped because `stateManager` is reset at the top of
|
|
3282
|
+
// every run/rerun (initial run and `prepareWatchRerunState`).
|
|
3283
|
+
if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
|
|
3284
|
+
let skipped = queue.shift();
|
|
3285
|
+
while (skipped) {
|
|
3286
|
+
await handleTestFileComplete(makeSkippedFileResult(skipped));
|
|
3287
|
+
skipped = queue.shift();
|
|
3288
|
+
}
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3144
3291
|
const next = queue.shift();
|
|
3145
3292
|
if (!next) {
|
|
3146
3293
|
return;
|
|
@@ -3173,6 +3320,12 @@ export const runBrowserController = async (
|
|
|
3173
3320
|
await cancelRun(run, false);
|
|
3174
3321
|
},
|
|
3175
3322
|
runFiles: async (files) => {
|
|
3323
|
+
// Clear the previous cycle's stateManager/snapshotManager before the
|
|
3324
|
+
// rerun streams new events through the shared sink — otherwise failed
|
|
3325
|
+
// counts (bail) and snapshot summaries accumulate across reruns. The
|
|
3326
|
+
// initial run does not reach here (it calls `runFilesWithPool`
|
|
3327
|
+
// directly), so only reruns reset.
|
|
3328
|
+
prepareWatchRerunState(context);
|
|
3176
3329
|
await notifyTestRunStart();
|
|
3177
3330
|
|
|
3178
3331
|
const rerunStartTime = Date.now();
|
|
@@ -3233,7 +3386,7 @@ export const runBrowserController = async (
|
|
|
3233
3386
|
hasFailure: false,
|
|
3234
3387
|
getSourcemap: getBrowserSourcemap,
|
|
3235
3388
|
resolveSourcemap: resolveBrowserSourcemap,
|
|
3236
|
-
close:
|
|
3389
|
+
close: !isWatchMode
|
|
3237
3390
|
? async () => {
|
|
3238
3391
|
sessionRegistry.clear();
|
|
3239
3392
|
await destroyBrowserRuntime(runtime);
|
|
@@ -3241,7 +3394,7 @@ export const runBrowserController = async (
|
|
|
3241
3394
|
: undefined,
|
|
3242
3395
|
};
|
|
3243
3396
|
|
|
3244
|
-
if (
|
|
3397
|
+
if (isWatchMode) {
|
|
3245
3398
|
await notifyTestRunEnd({ duration });
|
|
3246
3399
|
}
|
|
3247
3400
|
|
|
@@ -3365,7 +3518,9 @@ export const runBrowserController = async (
|
|
|
3365
3518
|
const isFailure = reporterResults.some(
|
|
3366
3519
|
(result: TestFileResult) => result.status === 'fail',
|
|
3367
3520
|
);
|
|
3368
|
-
|
|
3521
|
+
// Non-watch runs let core's `finalizeRunCycle` own the exit code from the
|
|
3522
|
+
// returned outcome; watch reruns set it here.
|
|
3523
|
+
if (isWatchMode && isFailure) {
|
|
3369
3524
|
ensureProcessExitCode(1);
|
|
3370
3525
|
}
|
|
3371
3526
|
|
|
@@ -3376,10 +3531,12 @@ export const runBrowserController = async (
|
|
|
3376
3531
|
hasFailure: isFailure,
|
|
3377
3532
|
getSourcemap: getBrowserSourcemap,
|
|
3378
3533
|
resolveSourcemap: resolveBrowserSourcemap,
|
|
3379
|
-
|
|
3534
|
+
// `closeHeadlessRuntime` is already `undefined` in watch mode, so the
|
|
3535
|
+
// non-watch caller (core) receives the deferred close and watch does not.
|
|
3536
|
+
close: closeHeadlessRuntime,
|
|
3380
3537
|
};
|
|
3381
3538
|
|
|
3382
|
-
if (
|
|
3539
|
+
if (isWatchMode) {
|
|
3383
3540
|
try {
|
|
3384
3541
|
await notifyTestRunEnd({ duration });
|
|
3385
3542
|
} finally {
|
|
@@ -3754,7 +3911,11 @@ export const runBrowserController = async (
|
|
|
3754
3911
|
}
|
|
3755
3912
|
} catch (error) {
|
|
3756
3913
|
fatalError = fatalError ?? toError(error);
|
|
3757
|
-
|
|
3914
|
+
// Non-watch: the fatal error rides the returned outcome and core owns the
|
|
3915
|
+
// exit code; watch reruns set it here.
|
|
3916
|
+
if (isWatchMode) {
|
|
3917
|
+
ensureProcessExitCode(1);
|
|
3918
|
+
}
|
|
3758
3919
|
}
|
|
3759
3920
|
|
|
3760
3921
|
testTime = Date.now() - testStart;
|
|
@@ -3800,6 +3961,10 @@ export const runBrowserController = async (
|
|
|
3800
3961
|
`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
|
|
3801
3962
|
),
|
|
3802
3963
|
);
|
|
3964
|
+
// Match the headless path: reset per-cycle state before the rerun
|
|
3965
|
+
// streams new events, so bail counts and snapshot summaries do not
|
|
3966
|
+
// accumulate across headed reruns.
|
|
3967
|
+
prepareWatchRerunState(context);
|
|
3803
3968
|
await notifyTestRunStart();
|
|
3804
3969
|
|
|
3805
3970
|
const rerunStartTime = Date.now();
|
|
@@ -3874,7 +4039,9 @@ export const runBrowserController = async (
|
|
|
3874
4039
|
const isFailure = reporterResults.some(
|
|
3875
4040
|
(result: TestFileResult) => result.status === 'fail',
|
|
3876
4041
|
);
|
|
3877
|
-
|
|
4042
|
+
// Non-watch runs let core's `finalizeRunCycle` own the exit code from the
|
|
4043
|
+
// returned outcome; watch reruns set it here.
|
|
4044
|
+
if (isWatchMode && isFailure) {
|
|
3878
4045
|
ensureProcessExitCode(1);
|
|
3879
4046
|
}
|
|
3880
4047
|
|
|
@@ -3885,10 +4052,12 @@ export const runBrowserController = async (
|
|
|
3885
4052
|
hasFailure: isFailure,
|
|
3886
4053
|
getSourcemap: getBrowserSourcemap,
|
|
3887
4054
|
resolveSourcemap: resolveBrowserSourcemap,
|
|
3888
|
-
|
|
4055
|
+
// `closeContainerRuntime` is already `undefined` in watch mode, so the
|
|
4056
|
+
// non-watch caller (core) receives the deferred close and watch does not.
|
|
4057
|
+
close: closeContainerRuntime,
|
|
3889
4058
|
};
|
|
3890
4059
|
|
|
3891
|
-
if (
|
|
4060
|
+
if (isWatchMode) {
|
|
3892
4061
|
try {
|
|
3893
4062
|
await notifyTestRunEnd({ duration });
|
|
3894
4063
|
} finally {
|
|
@@ -3926,20 +4095,20 @@ export type ListBrowserTestsResult = {
|
|
|
3926
4095
|
*/
|
|
3927
4096
|
export const listBrowserTests = async (
|
|
3928
4097
|
context: RstestContext,
|
|
3929
|
-
options?:
|
|
3930
|
-
shardedEntries?: Map<string, { entries: Record<string, string> }>;
|
|
3931
|
-
},
|
|
4098
|
+
options?: ListBrowserTestsOptions,
|
|
3932
4099
|
): Promise<ListBrowserTestsResult> => {
|
|
4100
|
+
const browserProjects = options?.projects ?? getBrowserProjects(context);
|
|
3933
4101
|
const projectEntries = await resolveProjectEntries(
|
|
3934
4102
|
context,
|
|
3935
4103
|
options?.shardedEntries,
|
|
4104
|
+
browserProjects,
|
|
3936
4105
|
);
|
|
3937
4106
|
const totalTests = projectEntries.reduce(
|
|
3938
4107
|
(total, item) => total + item.testFiles.length,
|
|
3939
4108
|
0,
|
|
3940
4109
|
);
|
|
3941
4110
|
|
|
3942
|
-
if (totalTests === 0) {
|
|
4111
|
+
if (totalTests === 0 && !hasUserRstestConfigPlugins(browserProjects)) {
|
|
3943
4112
|
return {
|
|
3944
4113
|
list: [],
|
|
3945
4114
|
close: async () => {},
|
|
@@ -3953,19 +4122,23 @@ export const listBrowserTests = async (
|
|
|
3953
4122
|
`list-${Date.now()}`,
|
|
3954
4123
|
);
|
|
3955
4124
|
|
|
3956
|
-
const browserProjects = getBrowserProjects(context);
|
|
3957
|
-
|
|
3958
4125
|
// Create a simplified browser runtime for collect mode
|
|
3959
4126
|
let runtime: BrowserRuntime;
|
|
3960
4127
|
try {
|
|
3961
4128
|
runtime = await createBrowserRuntime({
|
|
3962
4129
|
context,
|
|
3963
4130
|
projectEntries,
|
|
4131
|
+
browserProjects,
|
|
4132
|
+
shardedEntries: options?.shardedEntries,
|
|
4133
|
+
freezeShardedEntries: options?.freezeShardedEntries,
|
|
3964
4134
|
tempDir,
|
|
3965
4135
|
isWatchMode: false,
|
|
3966
4136
|
containerDistPath: undefined,
|
|
3967
4137
|
containerDevServer: undefined,
|
|
3968
4138
|
forceHeadless: true, // Always use headless for list command
|
|
4139
|
+
skipProviderLaunch: options?.filesOnly,
|
|
4140
|
+
appliedModifyRstestConfigEnvironments:
|
|
4141
|
+
options?.appliedModifyRstestConfigEnvironments,
|
|
3969
4142
|
});
|
|
3970
4143
|
} catch (error) {
|
|
3971
4144
|
const providers = [
|
|
@@ -3982,6 +4155,29 @@ export const listBrowserTests = async (
|
|
|
3982
4155
|
throw error;
|
|
3983
4156
|
}
|
|
3984
4157
|
|
|
4158
|
+
if (options?.filesOnly) {
|
|
4159
|
+
const list = runtime.projectEntries.flatMap((entry) =>
|
|
4160
|
+
entry.testFiles.map((testPath) => ({
|
|
4161
|
+
testPath,
|
|
4162
|
+
project: entry.project.name,
|
|
4163
|
+
tests: [],
|
|
4164
|
+
})),
|
|
4165
|
+
);
|
|
4166
|
+
await destroyBrowserRuntime(runtime);
|
|
4167
|
+
return {
|
|
4168
|
+
list,
|
|
4169
|
+
close: async () => {},
|
|
4170
|
+
};
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
if (!runtime.projectEntries.some((entry) => entry.testFiles.length > 0)) {
|
|
4174
|
+
await destroyBrowserRuntime(runtime);
|
|
4175
|
+
return {
|
|
4176
|
+
list: [],
|
|
4177
|
+
close: async () => {},
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
|
|
3985
4181
|
const { browser, browserLaunchOptions } = runtime;
|
|
3986
4182
|
|
|
3987
4183
|
// Get browser projects for runtime config
|
|
@@ -3991,17 +4187,14 @@ export const listBrowserTests = async (
|
|
|
3991
4187
|
name: project.name,
|
|
3992
4188
|
environmentName: project.environmentName,
|
|
3993
4189
|
projectRoot: normalize(project.rootPath),
|
|
3994
|
-
runtimeConfig: serializableConfig(
|
|
4190
|
+
runtimeConfig: serializableConfig(
|
|
4191
|
+
projectRuntimeConfig(project, { envMode: 'static' }),
|
|
4192
|
+
),
|
|
3995
4193
|
viewport: project.normalizedConfig.browser.viewport,
|
|
3996
4194
|
}),
|
|
3997
4195
|
);
|
|
3998
4196
|
|
|
3999
|
-
|
|
4000
|
-
const maxTestTimeoutForRpc = Math.max(
|
|
4001
|
-
...browserProjects.map(
|
|
4002
|
-
(p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
|
|
4003
|
-
),
|
|
4004
|
-
);
|
|
4197
|
+
const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
|
|
4005
4198
|
|
|
4006
4199
|
const hostOptions: BrowserHostConfig = {
|
|
4007
4200
|
rootPath: normalize(context.rootPath),
|
|
@@ -4026,6 +4219,10 @@ export const listBrowserTests = async (
|
|
|
4026
4219
|
|
|
4027
4220
|
const serializedOptions = serializeForInlineScript(hostOptions);
|
|
4028
4221
|
|
|
4222
|
+
// Per-page collect watchdog: a test file whose module evaluation stalls must
|
|
4223
|
+
// not hang `rstest list` forever.
|
|
4224
|
+
const collectTimeoutMs = 30_000;
|
|
4225
|
+
|
|
4029
4226
|
const collectFromServer = async (
|
|
4030
4227
|
server: BrowserProjectServer,
|
|
4031
4228
|
): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
|
|
@@ -4092,20 +4289,19 @@ export const listBrowserTests = async (
|
|
|
4092
4289
|
waitUntil: 'load',
|
|
4093
4290
|
});
|
|
4094
4291
|
|
|
4095
|
-
// Wait for collection to complete with timeout
|
|
4096
|
-
const timeoutMs = 30000;
|
|
4292
|
+
// Wait for collection to complete with the shared collect timeout.
|
|
4097
4293
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
4098
4294
|
const timeoutPromise = new Promise<void>((resolve) => {
|
|
4099
4295
|
timeoutId = setTimeout(() => {
|
|
4100
4296
|
if (!collectCompleted) {
|
|
4101
4297
|
logger.warn(
|
|
4102
4298
|
color.yellow(
|
|
4103
|
-
`[List] Browser test collection timed out after ${
|
|
4299
|
+
`[List] Browser test collection timed out after ${collectTimeoutMs}ms`,
|
|
4104
4300
|
),
|
|
4105
4301
|
);
|
|
4106
4302
|
}
|
|
4107
4303
|
resolve();
|
|
4108
|
-
},
|
|
4304
|
+
}, collectTimeoutMs);
|
|
4109
4305
|
});
|
|
4110
4306
|
|
|
4111
4307
|
await Promise.race([collectPromise, timeoutPromise]);
|