@rstest/browser 0.10.5 → 0.11.0

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/index.js CHANGED
@@ -9,11 +9,11 @@ import sirv from "sirv";
9
9
  import { WebSocketServer } from "ws";
10
10
  import convert_source_map from "convert-source-map";
11
11
  import { __webpack_require__ } from "./rslib-runtime.js";
12
- import { DISPATCH_RPC_BRIDGE_NAME, DISPATCH_NAMESPACE_SNAPSHOT, DISPATCH_MESSAGE_TYPE, DISPATCH_NAMESPACE_BROWSER } from "./626.js";
12
+ import { DISPATCH_RPC_BRIDGE_NAME, DISPATCH_NAMESPACE_SNAPSHOT, DISPATCH_MESSAGE_TYPE, DISPATCH_NAMESPACE_BROWSER } from "./33.js";
13
13
  __webpack_require__.add({
14
- "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/index.js" (module, __unused_rspack_exports, __webpack_require__) {
15
- const pico = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/picomatch.js");
16
- const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/utils.js");
14
+ "../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/index.js" (module, __unused_rspack_exports, __webpack_require__) {
15
+ const pico = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/picomatch.js");
16
+ const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/utils.js");
17
17
  function picomatch(glob, options, returnState = false) {
18
18
  if (options && (null === options.windows || void 0 === options.windows)) options = {
19
19
  ...options,
@@ -24,7 +24,7 @@ __webpack_require__.add({
24
24
  Object.assign(picomatch, pico);
25
25
  module.exports = picomatch;
26
26
  },
27
- "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/constants.js" (module) {
27
+ "../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/constants.js" (module) {
28
28
  const WIN_SLASH = '\\\\/';
29
29
  const WIN_NO_SLASH = `[^${WIN_SLASH}]`;
30
30
  const DEFAULT_MAX_EXTGLOB_RECURSION = 0;
@@ -187,9 +187,9 @@ __webpack_require__.add({
187
187
  }
188
188
  };
189
189
  },
190
- "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/parse.js" (module, __unused_rspack_exports, __webpack_require__) {
191
- const constants = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/constants.js");
192
- const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/utils.js");
190
+ "../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/parse.js" (module, __unused_rspack_exports, __webpack_require__) {
191
+ const constants = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/constants.js");
192
+ const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/utils.js");
193
193
  const { MAX_LENGTH, POSIX_REGEX_SOURCE, REGEX_NON_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_BACKREF, REPLACEMENTS } = constants;
194
194
  const expandRange = (args, options)=>{
195
195
  if ('function' == typeof options.expandRange) return options.expandRange(...args, options);
@@ -333,7 +333,11 @@ __webpack_require__.add({
333
333
  }
334
334
  }
335
335
  };
336
- const getStarExtglobSequenceOutput = (pattern)=>{
336
+ const buildCharClassStar = (chars)=>{
337
+ const source = 1 === chars.length ? utils.escapeRegex(chars[0]) : `[${chars.map((ch)=>utils.escapeRegex(ch)).join('')}]`;
338
+ return `${source}*`;
339
+ };
340
+ const getStarExtglobSequenceChars = (pattern)=>{
337
341
  let index = 0;
338
342
  const chars = [];
339
343
  while(index < pattern.length){
@@ -347,8 +351,7 @@ __webpack_require__.add({
347
351
  index += match.end + 1;
348
352
  }
349
353
  if (chars.length < 1) return;
350
- const source = 1 === chars.length ? utils.escapeRegex(chars[0]) : `[${chars.map((ch)=>utils.escapeRegex(ch)).join('')}]`;
351
- return `${source}*`;
354
+ return chars;
352
355
  };
353
356
  const repeatedExtglobRecursion = (pattern)=>{
354
357
  let depth = 0;
@@ -372,16 +375,34 @@ __webpack_require__.add({
372
375
  risky: true
373
376
  };
374
377
  }
378
+ const safeChars = [];
379
+ let sawStarSequence = false;
380
+ let combinable = true;
375
381
  for (const branch of branches){
376
- const safeOutput = getStarExtglobSequenceOutput(branch);
377
- if (safeOutput) return {
378
- risky: true,
379
- safeOutput
380
- };
382
+ const chars = getStarExtglobSequenceChars(branch);
383
+ if (chars) {
384
+ sawStarSequence = true;
385
+ safeChars.push(...chars);
386
+ continue;
387
+ }
388
+ const literal = normalizeSimpleBranch(branch);
389
+ if (literal && 1 === literal.length) {
390
+ safeChars.push(literal);
391
+ continue;
392
+ }
393
+ combinable = false;
381
394
  if (repeatedExtglobRecursion(branch) > max) return {
382
395
  risky: true
383
396
  };
384
397
  }
398
+ if (sawStarSequence) return combinable ? {
399
+ risky: true,
400
+ safeOutput: buildCharClassStar([
401
+ ...new Set(safeChars)
402
+ ])
403
+ } : {
404
+ risky: true
405
+ };
385
406
  return {
386
407
  risky: false
387
408
  };
@@ -1185,11 +1206,11 @@ __webpack_require__.add({
1185
1206
  };
1186
1207
  module.exports = parse;
1187
1208
  },
1188
- "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/picomatch.js" (module, __unused_rspack_exports, __webpack_require__) {
1189
- const scan = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/scan.js");
1190
- const parse = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/parse.js");
1191
- const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/utils.js");
1192
- const constants = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/constants.js");
1209
+ "../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/picomatch.js" (module, __unused_rspack_exports, __webpack_require__) {
1210
+ const scan = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/scan.js");
1211
+ const parse = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/parse.js");
1212
+ const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/utils.js");
1213
+ const constants = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/constants.js");
1193
1214
  const isObject = (val)=>val && 'object' == typeof val && !Array.isArray(val);
1194
1215
  const picomatch = (glob, options, returnState = false)=>{
1195
1216
  if (Array.isArray(glob)) {
@@ -1272,9 +1293,11 @@ __webpack_require__.add({
1272
1293
  output
1273
1294
  };
1274
1295
  };
1275
- picomatch.matchBase = (input, glob, options)=>{
1296
+ picomatch.matchBase = (input, glob, options, posix = options && options.windows)=>{
1276
1297
  const regex = glob instanceof RegExp ? glob : picomatch.makeRe(glob, options);
1277
- return regex.test(utils.basename(input));
1298
+ return regex.test(utils.basename(input, {
1299
+ windows: posix
1300
+ }));
1278
1301
  };
1279
1302
  picomatch.isMatch = (str, patterns, options)=>picomatch(patterns, options)(str);
1280
1303
  picomatch.parse = (pattern, options)=>{
@@ -1318,9 +1341,9 @@ __webpack_require__.add({
1318
1341
  picomatch.constants = constants;
1319
1342
  module.exports = picomatch;
1320
1343
  },
1321
- "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/scan.js" (module, __unused_rspack_exports, __webpack_require__) {
1322
- const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/utils.js");
1323
- const { CHAR_ASTERISK, CHAR_AT, CHAR_BACKWARD_SLASH, CHAR_COMMA, CHAR_DOT, CHAR_EXCLAMATION_MARK, CHAR_FORWARD_SLASH, CHAR_LEFT_CURLY_BRACE, CHAR_LEFT_PARENTHESES, CHAR_LEFT_SQUARE_BRACKET, CHAR_PLUS, CHAR_QUESTION_MARK, CHAR_RIGHT_CURLY_BRACE, CHAR_RIGHT_PARENTHESES, CHAR_RIGHT_SQUARE_BRACKET } = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/constants.js");
1344
+ "../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/scan.js" (module, __unused_rspack_exports, __webpack_require__) {
1345
+ const utils = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/utils.js");
1346
+ const { CHAR_ASTERISK, CHAR_AT, CHAR_BACKWARD_SLASH, CHAR_COMMA, CHAR_DOT, CHAR_EXCLAMATION_MARK, CHAR_FORWARD_SLASH, CHAR_LEFT_CURLY_BRACE, CHAR_LEFT_PARENTHESES, CHAR_LEFT_SQUARE_BRACKET, CHAR_PLUS, CHAR_QUESTION_MARK, CHAR_RIGHT_CURLY_BRACE, CHAR_RIGHT_PARENTHESES, CHAR_RIGHT_SQUARE_BRACKET } = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/constants.js");
1324
1347
  const isPathSeparator = (code)=>code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH;
1325
1348
  const depth = (token)=>{
1326
1349
  if (true !== token.isPrefix) token.depth = token.isGlobstar ? 1 / 0 : 1;
@@ -1586,8 +1609,8 @@ __webpack_require__.add({
1586
1609
  };
1587
1610
  module.exports = scan;
1588
1611
  },
1589
- "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/utils.js" (__unused_rspack_module, exports, __webpack_require__) {
1590
- const { REGEX_BACKSLASH, REGEX_REMOVE_BACKSLASH, REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL } = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/lib/constants.js");
1612
+ "../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/utils.js" (__unused_rspack_module, exports, __webpack_require__) {
1613
+ const { REGEX_BACKSLASH, REGEX_REMOVE_BACKSLASH, REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL } = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/lib/constants.js");
1591
1614
  exports.isObject = (val)=>null !== val && 'object' == typeof val && !Array.isArray(val);
1592
1615
  exports.hasRegexChars = (str)=>REGEX_SPECIAL_CHARS.test(str);
1593
1616
  exports.isRegexChar = (str)=>1 === str.length && exports.hasRegexChars(str);
@@ -2522,6 +2545,7 @@ const wrapPage = (page)=>{
2522
2545
  if ('popup' === event) return void originalOn(event, (popup)=>{
2523
2546
  listener(wrapPage(popup));
2524
2547
  });
2548
+ if ('crash' === event || 'close' === event) return void originalOn(event, listener);
2525
2549
  originalOn(event, listener);
2526
2550
  };
2527
2551
  return withAsyncDispose(page);
@@ -2810,12 +2834,11 @@ const planWatchRerun = ({ projectEntries, previousTestFiles, affectedTestFiles }
2810
2834
  affectedTestFiles: matchedAffectedFiles
2811
2835
  };
2812
2836
  };
2813
- const picomatch = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch/index.js");
2837
+ const picomatch = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/index.js");
2814
2838
  var picomatch_default = /*#__PURE__*/ __webpack_require__.n(picomatch);
2815
2839
  const { createRsbuild: createRsbuild, rspack: rspack } = rsbuild;
2816
2840
  const hostController_dirname = dirname(fileURLToPath(import.meta.url));
2817
2841
  const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
2818
- const PER_FILE_TIMEOUT_BUFFER_MS = 30000;
2819
2842
  let nextBrowserFilePid = 1000000000;
2820
2843
  const serializeForInlineScript = (value)=>JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
2821
2844
  const getBrowserProviderOptions = (project)=>{
@@ -2991,9 +3014,10 @@ const createBrowserLazyCompilationConfig = (setupFiles)=>{
2991
3014
  }
2992
3015
  };
2993
3016
  };
2994
- const createBrowserRsbuildDevConfig = (_isWatchMode)=>({
3017
+ const shouldEnableBrowserHmr = (isWatchMode, isHeadless)=>isWatchMode && !isHeadless;
3018
+ const createBrowserRsbuildDevConfig = (enableHmr)=>({
2995
3019
  writeToDisk: isDebug(),
2996
- hmr: true,
3020
+ hmr: enableHmr,
2997
3021
  client: {
2998
3022
  logLevel: 'error'
2999
3023
  }
@@ -3002,7 +3026,8 @@ const globToRegexp = (glob)=>{
3002
3026
  const regex = picomatch_default().makeRe(glob, {
3003
3027
  fastpaths: false,
3004
3028
  noglobstar: false,
3005
- bash: false
3029
+ bash: false,
3030
+ dot: true
3006
3031
  });
3007
3032
  if (!regex) throw new Error(`Invalid glob pattern: ${glob}`);
3008
3033
  if (!glob.startsWith('./')) return regex;
@@ -3022,20 +3047,76 @@ const globPatternsToRegExp = (patterns)=>{
3022
3047
  });
3023
3048
  return new RegExp(`(?:${regexParts.join('|')})$`);
3024
3049
  };
3025
- const excludePatternsToRegExp = (patterns)=>{
3026
- const keywords = [];
3027
- for (const pattern of patterns){
3028
- const match = pattern.match(/\*\*\/\.?\{?([^/*{}]+(?:,[^/*{}]+)*)\}?\/?\*?\*?/);
3029
- if (match) {
3030
- const parts = match[1].split(',');
3031
- for (const part of parts){
3032
- const cleaned = part.replace(/^\./, '');
3033
- if (cleaned && !keywords.includes(cleaned)) keywords.push(cleaned);
3034
- }
3050
+ const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
3051
+ const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
3052
+ const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
3053
+ const escapeRegExp = (value)=>value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
3054
+ const normalizePathForRegExp = (value)=>normalize(value).replaceAll('\\', '/');
3055
+ const normalizeExcludePatternForRegExp = (value)=>value.startsWith('./') ? `./${normalizePathForRegExp(value.substring(2))}` : normalizePathForRegExp(value);
3056
+ const isAbsolutePatternForRegExp = (value)=>value.startsWith('/') || /^[A-Za-z]:\//.test(value);
3057
+ const isEscapedRegExpCharacter = (source, index)=>{
3058
+ let backslashCount = 0;
3059
+ for(let current = index - 1; current >= 0 && '\\' === source[current]; current--)backslashCount++;
3060
+ return backslashCount % 2 === 1;
3061
+ };
3062
+ const replacePathSeparatorsInRegExpSource = (source)=>{
3063
+ let result = '';
3064
+ let inCharacterClass = false;
3065
+ for(let index = 0; index < source.length; index++){
3066
+ const character = source[index];
3067
+ const isEscaped = isEscapedRegExpCharacter(source, index);
3068
+ if ('[' === character && !isEscaped) inCharacterClass = true;
3069
+ if (!inCharacterClass && '\\' === character && '/' === source[index + 1]) {
3070
+ result += PATH_SEPARATOR_SOURCE;
3071
+ index++;
3072
+ continue;
3035
3073
  }
3074
+ result += character;
3075
+ if (']' === character && !isEscaped) inCharacterClass = false;
3076
+ }
3077
+ return result;
3078
+ };
3079
+ const createRelativeContextExcludeSource = (source, normalizedPattern)=>{
3080
+ if (normalizedPattern.startsWith('./')) return source;
3081
+ return normalizedPattern.startsWith('**/') ? `(?:(?:${source})|\\.(?:${source}))` : `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
3082
+ };
3083
+ const createProjectAbsoluteExcludeSource = (source, normalizedPattern)=>normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/') ? source : `${PATH_SEPARATOR_SOURCE}(?:${source})`;
3084
+ const excludePatternsToRegExpSources = (patterns)=>{
3085
+ const sources = patterns.map((pattern)=>{
3086
+ const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
3087
+ const regex = globToRegexp(normalizedPattern);
3088
+ let source = regex.source;
3089
+ if (source.startsWith('^')) source = source.substring(1);
3090
+ if (source.endsWith('$')) source = source.substring(0, source.length - 1);
3091
+ source = replacePathSeparatorsInRegExpSource(source);
3092
+ const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
3093
+ const absolute = normalizedPattern.startsWith('./') ? source.substring(2) : source;
3094
+ return {
3095
+ relative: isAbsolute ? source : createRelativeContextExcludeSource(source, normalizedPattern),
3096
+ absolute: isAbsolute ? absolute : createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
3097
+ isAbsolute
3098
+ };
3099
+ });
3100
+ if (0 === sources.length) return null;
3101
+ return sources;
3102
+ };
3103
+ const createBrowserContextExcludeRegExp = (patterns, projectRoot)=>{
3104
+ const excludeSources = excludePatternsToRegExpSources(patterns);
3105
+ if (!excludeSources) return null;
3106
+ const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(/[\\/]$/, '');
3107
+ const projectRootSource = normalizedProjectRoot.split('/').map(escapeRegExp).join(PATH_SEPARATOR_SOURCE);
3108
+ const relativeExcludeSources = excludeSources.filter((source)=>!source.isAbsolute);
3109
+ const absoluteExcludeSources = excludeSources.filter((source)=>source.isAbsolute);
3110
+ const sourceBranches = [];
3111
+ if (relativeExcludeSources.length > 0) {
3112
+ const relativePatternSource = `(?:${relativeExcludeSources.map((source)=>source.relative).join('|')})`;
3113
+ const absolutePatternSource = `(?:${relativeExcludeSources.map((source)=>source.absolute).join('|')})`;
3114
+ const relativeSource = `(?:${relativePatternSource})`;
3115
+ const absoluteSource = normalizedProjectRoot ? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})` : `(?:${absolutePatternSource})`;
3116
+ sourceBranches.push(`(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`, absoluteSource);
3036
3117
  }
3037
- if (0 === keywords.length) return null;
3038
- return new RegExp(`[\\\\/](${keywords.join('|')})[\\\\/]`);
3118
+ if (absoluteExcludeSources.length > 0) sourceBranches.push(`(?:${absoluteExcludeSources.map((source)=>source.relative).join('|')})`);
3119
+ return new RegExp(`^(?:${sourceBranches.join('|')})$`);
3039
3120
  };
3040
3121
  const findTestFileInModules = (modules, entryTestFiles)=>{
3041
3122
  if (!modules) return null;
@@ -3126,7 +3207,7 @@ const ensureConsistentBrowserLaunchOptions = (projects)=>{
3126
3207
  const firstOptions = getBrowserLaunchOptions(firstProject);
3127
3208
  for (const project of projects.slice(1)){
3128
3209
  const options = getBrowserLaunchOptions(project);
3129
- if (options.provider !== firstOptions.provider || options.browser !== firstOptions.browser || options.headless !== firstOptions.headless || options.port !== firstOptions.port || options.strictPort !== firstOptions.strictPort || !isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)) throw new Error(`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". All browser-enabled projects in one run must share provider/browser/headless/port/strictPort/providerOptions.`);
3210
+ if (options.provider !== firstOptions.provider || options.browser !== firstOptions.browser || options.headless !== firstOptions.headless || !isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)) throw new Error(`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". All browser-enabled projects in one run must share provider/browser/headless/providerOptions.`);
3130
3211
  }
3131
3212
  return firstOptions;
3132
3213
  };
@@ -3173,7 +3254,14 @@ const resolveContainerDist = ()=>{
3173
3254
  throw new Error(`Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`);
3174
3255
  };
3175
3256
  const toSafeVarName = (name)=>name.replace(/[^a-zA-Z0-9_]/g, '_');
3176
- const generateManifestModule = ({ manifestPath, entries })=>{
3257
+ const toContextKey = (filePath, projectRootPosix)=>{
3258
+ const posixPath = normalize(filePath);
3259
+ const withinRoot = posixPath === projectRootPosix || posixPath.startsWith(`${projectRootPosix}/`);
3260
+ if (!withinRoot) return posixPath;
3261
+ const rel = posixPath.slice(projectRootPosix.length);
3262
+ return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
3263
+ };
3264
+ const generateManifestModule = ({ manifestPath, entries, isWatchMode })=>{
3177
3265
  const manifestDirPosix = normalize(dirname(manifestPath));
3178
3266
  const toRelativeImport = (filePath)=>{
3179
3267
  const posixPath = normalize(filePath);
@@ -3206,18 +3294,30 @@ const generateManifestModule = ({ manifestPath, entries })=>{
3206
3294
  lines.push('};');
3207
3295
  lines.push('');
3208
3296
  lines.push('// Test context for each project');
3209
- for (const { project } of entries){
3297
+ for (const { project, testFiles } of entries){
3210
3298
  const varName = `context_${toSafeVarName(project.environmentName)}`;
3211
3299
  const projectRootPosix = normalize(project.rootPath);
3212
- const includeRegExp = globPatternsToRegExp(project.normalizedConfig.include);
3213
- const excludePatterns = project.normalizedConfig.exclude.patterns;
3214
- const excludeRegExp = excludePatternsToRegExp(excludePatterns);
3215
- lines.push(`const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`);
3216
- lines.push(' recursive: true,');
3217
- lines.push(` regExp: ${includeRegExp.toString()},`);
3218
- if (excludeRegExp) lines.push(` exclude: ${excludeRegExp.toString()},`);
3219
- lines.push(" mode: 'lazy',");
3220
- lines.push('});');
3300
+ if (isWatchMode) {
3301
+ const includeRegExp = globPatternsToRegExp(project.normalizedConfig.include);
3302
+ const excludeRegExp = createBrowserContextExcludeRegExp(project.normalizedConfig.exclude.patterns, projectRootPosix);
3303
+ lines.push(`const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`);
3304
+ lines.push(' recursive: true,');
3305
+ lines.push(` regExp: ${includeRegExp.toString()},`);
3306
+ if (excludeRegExp) lines.push(` exclude: ${excludeRegExp.toString()},`);
3307
+ lines.push(" mode: 'lazy',");
3308
+ lines.push('});');
3309
+ } else {
3310
+ lines.push(`const ${varName}_modules = {`);
3311
+ for (const filePath of testFiles){
3312
+ const key = toContextKey(filePath, projectRootPosix);
3313
+ const importPath = toRelativeImport(filePath);
3314
+ lines.push(` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`);
3315
+ }
3316
+ lines.push('};');
3317
+ lines.push(`const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`);
3318
+ lines.push(` keys: () => Object.keys(${varName}_modules),`);
3319
+ lines.push('});');
3320
+ }
3221
3321
  lines.push('');
3222
3322
  }
3223
3323
  lines.push('export const projectTestContexts = {');
@@ -3251,13 +3351,21 @@ const htmlTemplate = `<!DOCTYPE html>
3251
3351
  </html>
3252
3352
  `;
3253
3353
  const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
3354
+ const closeAllProjectServers = (servers)=>Promise.allSettled([
3355
+ ...servers
3356
+ ].map((server)=>server.devServer.close()));
3357
+ const copyProxyResponseHeaders = (response, res)=>{
3358
+ res.statusCode = response.status;
3359
+ response.headers.forEach((value, key)=>{
3360
+ if ('content-length' === key.toLowerCase()) return;
3361
+ res.setHeader(key, value);
3362
+ });
3363
+ };
3254
3364
  const destroyBrowserRuntime = async (runtime)=>{
3255
3365
  try {
3256
3366
  await runtime.browser?.close?.();
3257
3367
  } catch {}
3258
- try {
3259
- await runtime.devServer?.close?.();
3260
- } catch {}
3368
+ await closeAllProjectServers(runtime.projectServers.values());
3261
3369
  try {
3262
3370
  runtime.wss?.close();
3263
3371
  } catch {}
@@ -3291,10 +3399,7 @@ const registerWatchCleanup = ()=>{
3291
3399
  });
3292
3400
  watchContext.cleanupRegistered = true;
3293
3401
  };
3294
- const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tempDir, isWatchMode, onTriggerRerun, containerDistPath, containerDevServer, forceHeadless })=>{
3295
- const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
3296
- [manifestPath]: manifestSource
3297
- });
3402
+ const createBrowserRuntime = async ({ context, projectEntries, tempDir, isWatchMode, onTriggerRerun, containerDistPath, containerDevServer, forceHeadless })=>{
3298
3403
  const containerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'index.html'), 'utf-8') : null;
3299
3404
  let injectedContainerHtml = null;
3300
3405
  let serializedOptions = 'null';
@@ -3304,156 +3409,14 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
3304
3409
  if (containerHtmlTemplate) injectedContainerHtml = containerHtmlTemplate.replace(OPTIONS_PLACEHOLDER, serializedOptions);
3305
3410
  };
3306
3411
  const browserProjects = getBrowserProjects(context);
3307
- const projectByEnvironmentName = new Map(browserProjects.map((project)=>[
3308
- project.environmentName,
3309
- project
3310
- ]));
3311
- const userPlugins = browserProjects.flatMap((project)=>project.normalizedConfig.plugins || []);
3312
3412
  const browserLaunchOptions = ensureConsistentBrowserLaunchOptions(browserProjects);
3313
3413
  const browserRuntimePath = fileURLToPath(import.meta.resolve('@rstest/core/internal/browser-runtime'));
3314
- const rstestInternalAliases = {
3315
- '@rstest/browser-manifest': manifestPath,
3414
+ const staticRstestAliases = {
3316
3415
  '@rstest/core': resolveBrowserFile('client/public.ts'),
3317
3416
  '@rstest/browser': resolveBrowserFile('browser.ts'),
3318
- '@rstest/core/internal/browser-runtime': browserRuntimePath,
3319
- '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts')
3417
+ '@rstest/core/internal/browser-runtime': browserRuntimePath
3320
3418
  };
3321
- const rsbuildInstance = await createRsbuild({
3322
- callerName: 'rstest-browser',
3323
- rsbuildConfig: {
3324
- root: context.rootPath,
3325
- mode: 'development',
3326
- plugins: userPlugins,
3327
- server: {
3328
- printUrls: false,
3329
- port: browserLaunchOptions.port ?? 4000,
3330
- strictPort: browserLaunchOptions.strictPort
3331
- },
3332
- dev: createBrowserRsbuildDevConfig(isWatchMode),
3333
- environments: {
3334
- ...Object.fromEntries(browserProjects.map((project)=>[
3335
- project.environmentName,
3336
- {}
3337
- ]))
3338
- }
3339
- }
3340
- });
3341
- rsbuildInstance.addPlugins([
3342
- {
3343
- name: 'rstest:browser-user-config',
3344
- setup (api) {
3345
- api.expose?.('rstest:browser', {
3346
- registerDispatchHandler: (namespace, handler)=>{
3347
- dispatchHandlers.set(namespace, handler);
3348
- }
3349
- });
3350
- api.modifyEnvironmentConfig({
3351
- handler: (config, { mergeEnvironmentConfig, name })=>{
3352
- const project = projectByEnvironmentName.get(name);
3353
- if (!project) return config;
3354
- const userRsbuildConfig = project.normalizedConfig;
3355
- const buildCache = resolveProjectBuildCache({
3356
- context,
3357
- project
3358
- });
3359
- const setupFiles = Object.values(getSetupFiles(project.normalizedConfig.setupFiles, project.rootPath));
3360
- const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(RSTEST_ENV_SYMBOL_KEY)})]`;
3361
- const merged = mergeEnvironmentConfig(config, {
3362
- ...userRsbuildConfig,
3363
- performance: buildCache ? {
3364
- ...userRsbuildConfig.performance,
3365
- buildCache
3366
- } : userRsbuildConfig.performance
3367
- }, {
3368
- resolve: {
3369
- alias: rstestInternalAliases
3370
- },
3371
- source: {
3372
- define: {
3373
- 'process.env': rstestEnvDefine,
3374
- 'import.meta.env': rstestEnvDefine
3375
- }
3376
- },
3377
- output: {
3378
- target: 'web',
3379
- sourceMap: {
3380
- js: 'source-map'
3381
- }
3382
- },
3383
- tools: {
3384
- rspack: (rspackConfig)=>{
3385
- rspackConfig.mode = 'development';
3386
- rspackConfig.lazyCompilation = createBrowserLazyCompilationConfig(setupFiles);
3387
- rspackConfig.plugins = rspackConfig.plugins || [];
3388
- rspackConfig.plugins.push(virtualManifestPlugin);
3389
- applyDefaultWatchOptions(rspackConfig, isWatchMode);
3390
- const browserRuntimeDir = dirname(browserRuntimePath);
3391
- rspackConfig.module = rspackConfig.module || {};
3392
- rspackConfig.module.rules = rspackConfig.module.rules || [];
3393
- rspackConfig.module.rules.unshift({
3394
- test: /\.js$/,
3395
- include: browserRuntimeDir,
3396
- extractSourceMap: true
3397
- });
3398
- if (isDebug()) logger.log(`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`);
3399
- }
3400
- }
3401
- });
3402
- merged.source = merged.source || {};
3403
- merged.source.entry = {
3404
- runner: resolveBrowserFile('client/entry.ts')
3405
- };
3406
- return merged;
3407
- },
3408
- order: 'post'
3409
- });
3410
- }
3411
- }
3412
- ]);
3413
- if (isWatchMode && onTriggerRerun) rsbuildInstance.addPlugins([
3414
- {
3415
- name: 'rstest:browser-watch',
3416
- setup (api) {
3417
- api.onBeforeDevCompile(()=>{
3418
- if (!watchContext.hooksEnabled) return;
3419
- logger.log(color.cyan('\nFile changed, re-running tests...\n'));
3420
- });
3421
- api.onAfterDevCompile(async ({ stats })=>{
3422
- if (stats) {
3423
- const projectEntries = await collectProjectEntries(context);
3424
- const entryTestFiles = new Set(collectWatchTestFiles(projectEntries).map((file)=>file.testPath));
3425
- const statsJson = stats.toJson({
3426
- all: true
3427
- });
3428
- const affected = getAffectedTestFiles(statsJson.chunks, entryTestFiles);
3429
- watchContext.affectedTestFiles = affected;
3430
- if (affected.length > 0) logger.debug(`[Watch] Affected test files: ${affected.join(', ')}`);
3431
- }
3432
- if (!watchContext.hooksEnabled) return;
3433
- await onTriggerRerun();
3434
- });
3435
- }
3436
- }
3437
- ]);
3438
- const coverage = browserProjects.find((project)=>project.normalizedConfig.coverage?.enabled)?.normalizedConfig.coverage;
3439
- if (coverage?.enabled && 'list' !== context.command) {
3440
- const { pluginCoverage } = await loadCoverageProvider(coverage, context.rootPath);
3441
- rsbuildInstance.addPlugins([
3442
- pluginCoverage(coverage)
3443
- ]);
3444
- }
3445
- const devServer = await rsbuildInstance.createDevServer({
3446
- getPortSilently: true
3447
- });
3448
- if (isDebug()) await rsbuildInstance.inspectConfig({
3449
- writeToDisk: true,
3450
- extraConfigs: {
3451
- rstest: {
3452
- ...context.normalizedConfig,
3453
- projects: browserProjects.map((p)=>p.normalizedConfig)
3454
- }
3455
- }
3456
- });
3419
+ const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(RSTEST_ENV_SYMBOL_KEY)})]`;
3457
3420
  const serveContainer = containerDistPath ? sirv(containerDistPath, {
3458
3421
  dev: false,
3459
3422
  single: 'index.html'
@@ -3467,11 +3430,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
3467
3430
  if (!response.ok) return false;
3468
3431
  let html = await response.text();
3469
3432
  html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
3470
- res.statusCode = response.status;
3471
- response.headers.forEach((value, key)=>{
3472
- if ('content-length' === key.toLowerCase()) return;
3473
- res.setHeader(key, value);
3474
- });
3433
+ copyProxyResponseHeaders(response, res);
3475
3434
  res.setHeader('Content-Type', 'text/html');
3476
3435
  res.end(html);
3477
3436
  return true;
@@ -3487,11 +3446,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
3487
3446
  const response = await fetch(target);
3488
3447
  if (!response.ok) return false;
3489
3448
  const buffer = Buffer.from(await response.arrayBuffer());
3490
- res.statusCode = response.status;
3491
- response.headers.forEach((value, key)=>{
3492
- if ('content-length' === key.toLowerCase()) return;
3493
- res.setHeader(key, value);
3494
- });
3449
+ copyProxyResponseHeaders(response, res);
3495
3450
  res.end(buffer);
3496
3451
  return true;
3497
3452
  } catch (error) {
@@ -3499,59 +3454,241 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
3499
3454
  return false;
3500
3455
  }
3501
3456
  };
3502
- devServer.middlewares.use(async (req, res, next)=>{
3503
- if (!req.url) return void next();
3504
- const url = new URL(req.url, 'http://localhost');
3505
- if ('/__open-in-editor' === url.pathname) {
3506
- const file = url.searchParams.get('file');
3507
- if (!file) {
3508
- res.statusCode = 400;
3509
- res.end('Missing file');
3457
+ const entryByEnvironmentName = new Map(projectEntries.map((entry)=>[
3458
+ entry.project.environmentName,
3459
+ entry
3460
+ ]));
3461
+ const buildProjectServer = async (project, isContainerServer)=>{
3462
+ const manifestPath = join(tempDir, toSafeVarName(project.environmentName), VIRTUAL_MANIFEST_FILENAME);
3463
+ const entry = entryByEnvironmentName.get(project.environmentName);
3464
+ const manifestSource = generateManifestModule({
3465
+ manifestPath,
3466
+ entries: [
3467
+ {
3468
+ project,
3469
+ testFiles: entry?.testFiles ?? [],
3470
+ setupFiles: entry?.setupFiles ?? []
3471
+ }
3472
+ ],
3473
+ isWatchMode
3474
+ });
3475
+ const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
3476
+ [manifestPath]: manifestSource
3477
+ });
3478
+ const rstestInternalAliases = {
3479
+ '@rstest/browser-manifest': manifestPath,
3480
+ ...staticRstestAliases
3481
+ };
3482
+ const isHeadless = forceHeadless || project.normalizedConfig.browser.headless;
3483
+ const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
3484
+ const rsbuildInstance = await createRsbuild({
3485
+ callerName: 'rstest-browser',
3486
+ rsbuildConfig: {
3487
+ root: context.rootPath,
3488
+ mode: 'development',
3489
+ plugins: project.normalizedConfig.plugins || [],
3490
+ server: {
3491
+ printUrls: false,
3492
+ port: project.normalizedConfig.browser.port ?? (isContainerServer ? 4000 : 0),
3493
+ strictPort: project.normalizedConfig.browser.strictPort
3494
+ },
3495
+ dev: createBrowserRsbuildDevConfig(enableHmr),
3496
+ environments: {
3497
+ [project.environmentName]: {}
3498
+ }
3499
+ }
3500
+ });
3501
+ rsbuildInstance.addPlugins([
3502
+ {
3503
+ name: 'rstest:browser-user-config',
3504
+ setup (api) {
3505
+ api.expose?.('rstest:browser', {
3506
+ registerDispatchHandler: (namespace, handler)=>{
3507
+ dispatchHandlers.set(namespace, handler);
3508
+ }
3509
+ });
3510
+ api.modifyEnvironmentConfig({
3511
+ handler: (config, { mergeEnvironmentConfig, name })=>{
3512
+ if (name !== project.environmentName) return config;
3513
+ const userRsbuildConfig = project.normalizedConfig;
3514
+ const buildCache = resolveProjectBuildCache({
3515
+ context,
3516
+ project
3517
+ });
3518
+ const setupFiles = Object.values(getSetupFiles(project.normalizedConfig.setupFiles, project.rootPath));
3519
+ const merged = mergeEnvironmentConfig(config, {
3520
+ ...userRsbuildConfig,
3521
+ performance: buildCache ? {
3522
+ ...userRsbuildConfig.performance,
3523
+ buildCache
3524
+ } : userRsbuildConfig.performance
3525
+ }, {
3526
+ resolve: {
3527
+ alias: rstestInternalAliases
3528
+ },
3529
+ source: {
3530
+ define: {
3531
+ 'process.env': rstestEnvDefine,
3532
+ 'import.meta.env': rstestEnvDefine
3533
+ }
3534
+ },
3535
+ output: {
3536
+ target: 'web',
3537
+ sourceMap: {
3538
+ js: 'source-map'
3539
+ }
3540
+ },
3541
+ tools: {
3542
+ rspack: (rspackConfig)=>{
3543
+ rspackConfig.mode = 'development';
3544
+ rspackConfig.lazyCompilation = enableHmr ? createBrowserLazyCompilationConfig(setupFiles) : false;
3545
+ rspackConfig.plugins = rspackConfig.plugins || [];
3546
+ rspackConfig.plugins.push(virtualManifestPlugin);
3547
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
3548
+ const browserRuntimeDir = dirname(browserRuntimePath);
3549
+ rspackConfig.module = rspackConfig.module || {};
3550
+ rspackConfig.module.rules = rspackConfig.module.rules || [];
3551
+ rspackConfig.module.rules.unshift({
3552
+ test: /\.js$/,
3553
+ include: browserRuntimeDir,
3554
+ extractSourceMap: true
3555
+ });
3556
+ if (isDebug()) logger.log(`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`);
3557
+ }
3558
+ }
3559
+ });
3560
+ merged.source = merged.source || {};
3561
+ merged.source.entry = {
3562
+ runner: resolveBrowserFile('client/entry.ts')
3563
+ };
3564
+ return merged;
3565
+ },
3566
+ order: 'post'
3567
+ });
3568
+ }
3569
+ }
3570
+ ]);
3571
+ if (isWatchMode && onTriggerRerun) rsbuildInstance.addPlugins([
3572
+ {
3573
+ name: 'rstest:browser-watch',
3574
+ setup (api) {
3575
+ api.onBeforeDevCompile(()=>{
3576
+ if (!watchContext.hooksEnabled) return;
3577
+ logger.log(color.cyan('\nFile changed, re-running tests...\n'));
3578
+ });
3579
+ api.onAfterDevCompile(async ({ stats })=>{
3580
+ if (stats) {
3581
+ const allProjectEntries = await collectProjectEntries(context);
3582
+ const entryTestFiles = new Set(collectWatchTestFiles(allProjectEntries).map((file)=>file.testPath));
3583
+ const statsJson = stats.toJson({
3584
+ all: true
3585
+ });
3586
+ const affected = getAffectedTestFiles(statsJson.chunks, entryTestFiles);
3587
+ watchContext.affectedTestFiles = affected;
3588
+ if (affected.length > 0) logger.debug(`[Watch] Affected test files: ${affected.join(', ')}`);
3589
+ }
3590
+ if (!watchContext.hooksEnabled) return;
3591
+ await onTriggerRerun();
3592
+ });
3593
+ }
3594
+ }
3595
+ ]);
3596
+ const coverage = project.normalizedConfig.coverage;
3597
+ if (coverage?.enabled && 'list' !== context.command) {
3598
+ const { pluginCoverage } = await loadCoverageProvider(coverage, context.rootPath);
3599
+ rsbuildInstance.addPlugins([
3600
+ pluginCoverage(coverage)
3601
+ ]);
3602
+ }
3603
+ const devServer = await rsbuildInstance.createDevServer({
3604
+ getPortSilently: true
3605
+ });
3606
+ if (isDebug()) await rsbuildInstance.inspectConfig({
3607
+ writeToDisk: true,
3608
+ extraConfigs: {
3609
+ rstest: {
3610
+ ...context.normalizedConfig,
3611
+ projects: [
3612
+ project.normalizedConfig
3613
+ ]
3614
+ }
3615
+ }
3616
+ });
3617
+ devServer.middlewares.use(async (req, res, next)=>{
3618
+ if (!req.url) return void next();
3619
+ const url = new URL(req.url, 'http://localhost');
3620
+ if ('/__open-in-editor' === url.pathname) {
3621
+ const file = url.searchParams.get('file');
3622
+ if (!file) {
3623
+ res.statusCode = 400;
3624
+ res.end('Missing file');
3625
+ return;
3626
+ }
3627
+ try {
3628
+ await open_editor([
3629
+ {
3630
+ file
3631
+ }
3632
+ ]);
3633
+ res.statusCode = 204;
3634
+ res.end();
3635
+ } catch (error) {
3636
+ logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
3637
+ res.statusCode = 500;
3638
+ res.end('Failed to open editor');
3639
+ }
3510
3640
  return;
3511
3641
  }
3512
- try {
3513
- await open_editor([
3514
- {
3515
- file
3642
+ if (isContainerServer) {
3643
+ if ('/' === url.pathname) {
3644
+ if (await respondWithDevServerHtml(url, res)) return;
3645
+ const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
3646
+ if (html) {
3647
+ res.setHeader('Content-Type', 'text/html');
3648
+ res.end(html);
3649
+ return;
3516
3650
  }
3517
- ]);
3518
- res.statusCode = 204;
3519
- res.end();
3520
- } catch (error) {
3521
- logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
3522
- res.statusCode = 500;
3523
- res.end('Failed to open editor');
3651
+ res.statusCode = 502;
3652
+ res.end('Container UI is not available.');
3653
+ return;
3654
+ }
3655
+ if (url.pathname.startsWith('/container-static/')) {
3656
+ if (await proxyDevServerAsset(req, res)) return;
3657
+ if (serveContainer) return void serveContainer(req, res, next);
3658
+ res.statusCode = 502;
3659
+ res.end('Container assets are not available.');
3660
+ return;
3661
+ }
3524
3662
  }
3525
- return;
3526
- }
3527
- if ('/' === url.pathname) {
3528
- if (await respondWithDevServerHtml(url, res)) return;
3529
- const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
3530
- if (html) {
3663
+ if ('/runner.html' === url.pathname) {
3531
3664
  res.setHeader('Content-Type', 'text/html');
3532
- res.end(html);
3665
+ res.end(htmlTemplate);
3533
3666
  return;
3534
3667
  }
3535
- res.statusCode = 502;
3536
- res.end('Container UI is not available.');
3537
- return;
3538
- }
3539
- if (url.pathname.startsWith('/container-static/')) {
3540
- if (await proxyDevServerAsset(req, res)) return;
3541
- if (serveContainer) return void serveContainer(req, res, next);
3542
- res.statusCode = 502;
3543
- res.end('Container assets are not available.');
3544
- return;
3545
- }
3546
- if ('/runner.html' === url.pathname) {
3547
- res.setHeader('Content-Type', 'text/html');
3548
- res.end(htmlTemplate);
3549
- return;
3668
+ next();
3669
+ });
3670
+ const { port: listenPort } = await devServer.listen();
3671
+ const port = resolveListenPort(listenPort, devServer.httpServer);
3672
+ return {
3673
+ projectName: project.name,
3674
+ environmentName: project.environmentName,
3675
+ rsbuildInstance,
3676
+ devServer,
3677
+ port,
3678
+ manifestPath
3679
+ };
3680
+ };
3681
+ const projectServers = new Map();
3682
+ try {
3683
+ for (const [index, project] of browserProjects.entries()){
3684
+ const server = await buildProjectServer(project, 0 === index);
3685
+ projectServers.set(server.projectName, server);
3550
3686
  }
3551
- next();
3552
- });
3553
- const { port: listenPort } = await devServer.listen();
3554
- const port = resolveListenPort(listenPort, devServer.httpServer);
3687
+ } catch (error) {
3688
+ await closeAllProjectServers(projectServers.values());
3689
+ throw error;
3690
+ }
3691
+ const containerServer = projectServers.get(browserProjects[0].name);
3555
3692
  const wss = new WebSocketServer({
3556
3693
  port: 0
3557
3694
  });
@@ -3570,22 +3707,19 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
3570
3707
  providerOptions: browserLaunchOptions.providerOptions
3571
3708
  });
3572
3709
  return {
3573
- rsbuildInstance,
3574
- devServer,
3710
+ projectServers,
3711
+ containerServer,
3575
3712
  browser: runtime.browser,
3576
3713
  browserLaunchOptions,
3577
- port,
3578
3714
  wsPort,
3579
- manifestPath,
3580
3715
  tempDir,
3581
- manifestPlugin: virtualManifestPlugin,
3582
3716
  setContainerOptions,
3583
3717
  dispatchHandlers,
3584
3718
  wss
3585
3719
  };
3586
3720
  } catch (error) {
3587
3721
  wss.close();
3588
- await devServer.close();
3722
+ await closeAllProjectServers(projectServers.values());
3589
3723
  throw error;
3590
3724
  }
3591
3725
  };
@@ -3756,11 +3890,6 @@ const runBrowserController = async (context, options)=>{
3756
3890
  const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
3757
3891
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
3758
3892
  const tempDir = isWatchMode && watchContext.runtime ? watchContext.runtime.tempDir : isWatchMode ? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch') : join(context.rootPath, browserTempOutputRoot, 'browser', Date.now().toString());
3759
- const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
3760
- const manifestSource = generateManifestModule({
3761
- manifestPath,
3762
- entries: projectEntries
3763
- });
3764
3893
  if (isWatchMode) watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
3765
3894
  let runtime = isWatchMode ? watchContext.runtime : null;
3766
3895
  let triggerRerun;
@@ -3768,8 +3897,7 @@ const runBrowserController = async (context, options)=>{
3768
3897
  try {
3769
3898
  runtime = await createBrowserRuntime({
3770
3899
  context,
3771
- manifestPath,
3772
- manifestSource,
3900
+ projectEntries,
3773
3901
  tempDir,
3774
3902
  isWatchMode,
3775
3903
  onTriggerRerun: isWatchMode ? async ()=>{
@@ -3794,7 +3922,7 @@ const runBrowserController = async (context, options)=>{
3794
3922
  });
3795
3923
  }
3796
3924
  }
3797
- const { browser, browserLaunchOptions, port, wsPort, wss } = runtime;
3925
+ const { browser, browserLaunchOptions, wsPort, wss } = runtime;
3798
3926
  const buildTime = Date.now() - buildStart;
3799
3927
  const allTestFiles = projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
3800
3928
  testPath: normalize(testPath),
@@ -3808,13 +3936,20 @@ const runBrowserController = async (context, options)=>{
3808
3936
  viewport: project.normalizedConfig.browser.viewport
3809
3937
  }));
3810
3938
  const maxTestTimeoutForRpc = Math.max(...browserProjects.map((p)=>p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT));
3939
+ const projectRunnerUrls = Object.fromEntries([
3940
+ ...runtime.projectServers
3941
+ ].map(([name, server])=>[
3942
+ name,
3943
+ `http://localhost:${server.port}`
3944
+ ]));
3811
3945
  const hostOptions = {
3812
3946
  rootPath: normalize(context.rootPath),
3813
3947
  projects: projectRuntimeConfigs,
3814
3948
  snapshot: {
3815
3949
  updateSnapshot: context.snapshotManager.options.updateSnapshot
3816
3950
  },
3817
- runnerUrl: `http://localhost:${port}`,
3951
+ runnerUrl: `http://localhost:${runtime.containerServer.port}`,
3952
+ projectRunnerUrls,
3818
3953
  wsPort,
3819
3954
  debug: isDebug(),
3820
3955
  rpcTimeout: maxTestTimeoutForRpc
@@ -4133,11 +4268,16 @@ const runBrowserController = async (context, options)=>{
4133
4268
  const donePromise = new Promise((resolve)=>{
4134
4269
  resolveDone = resolve;
4135
4270
  });
4136
- const projectRuntime = projectRuntimeConfigs.find((project)=>project.name === file.projectName);
4137
- const perFileTimeoutMs = (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) + PER_FILE_TIMEOUT_BUFFER_MS;
4138
- let timeoutId;
4271
+ const crashDeferred = createDeferredPromise();
4272
+ const onPageDead = (reason)=>{
4273
+ if (settled || run.cancelled || !runLifecycle.isTokenActive(run.token)) return;
4274
+ settled = true;
4275
+ crashDeferred.resolve(reason);
4276
+ };
4139
4277
  try {
4140
4278
  page = await browserContext.newPage();
4279
+ page.on('crash', ()=>onPageDead(`Browser page crashed while running ${file.testPath}.`));
4280
+ page.on('close', ()=>onPageDead(`Browser page closed unexpectedly while running ${file.testPath}.`));
4141
4281
  const session = sessionRegistry.register({
4142
4282
  testFile: file.testPath,
4143
4283
  projectName: file.projectName,
@@ -4184,21 +4324,27 @@ const runBrowserController = async (context, options)=>{
4184
4324
  };
4185
4325
  const serializedOptions = serializeForInlineScript(inlineOptions);
4186
4326
  await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
4187
- await page.goto(`http://localhost:${port}/runner.html`, {
4327
+ const projectServer = runtime.projectServers.get(file.projectName);
4328
+ if (!projectServer) throw new Error(`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`);
4329
+ await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
4188
4330
  waitUntil: 'load'
4189
4331
  });
4190
- const timeoutPromise = new Promise((resolve)=>{
4191
- timeoutId = setTimeout(()=>resolve('timeout'), perFileTimeoutMs);
4192
- });
4193
4332
  const state = await Promise.race([
4194
- donePromise.then(()=>'done'),
4195
- timeoutPromise,
4196
- run.cancelSignal.then(()=>'cancelled')
4333
+ donePromise.then(()=>({
4334
+ type: 'done'
4335
+ })),
4336
+ crashDeferred.promise.then((reason)=>({
4337
+ type: 'crash',
4338
+ reason
4339
+ })),
4340
+ run.cancelSignal.then(()=>({
4341
+ type: 'cancelled'
4342
+ }))
4197
4343
  ]);
4198
- if ('cancelled' === state) return;
4199
- if ('timeout' === state && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4344
+ if ('cancelled' === state.type) return;
4345
+ if ('crash' === state.type && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4200
4346
  await handleFatal({
4201
- message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`
4347
+ message: state.reason
4202
4348
  });
4203
4349
  await cancelRun(run, false);
4204
4350
  }
@@ -4212,7 +4358,6 @@ const runBrowserController = async (context, options)=>{
4212
4358
  await cancelRun(run, false);
4213
4359
  }
4214
4360
  } finally{
4215
- if (timeoutId) clearTimeout(timeoutId);
4216
4361
  if (page) try {
4217
4362
  await page.close();
4218
4363
  } catch {}
@@ -4455,10 +4600,6 @@ const runBrowserController = async (context, options)=>{
4455
4600
  if (!fileInfo) throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
4456
4601
  return fileInfo;
4457
4602
  };
4458
- const getHeadedPerFileTimeoutMs = (file)=>{
4459
- const projectRuntime = projectRuntimeConfigs.find((project)=>project.name === file.projectName);
4460
- return (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) + PER_FILE_TIMEOUT_BUFFER_MS;
4461
- };
4462
4603
  let containerContext;
4463
4604
  let containerPage;
4464
4605
  let isNewPage = false;
@@ -4528,26 +4669,14 @@ const runBrowserController = async (context, options)=>{
4528
4669
  pendingHeadedReloads.delete(testPath);
4529
4670
  pending.deferred.resolve();
4530
4671
  };
4531
- const reloadTestFileWithTimeout = async (file, testNamePattern)=>{
4532
- const timeoutMs = getHeadedPerFileTimeoutMs(file);
4533
- let timeoutId;
4672
+ const reloadTestFileAndWait = async (file, testNamePattern)=>{
4534
4673
  let reloadAck;
4535
4674
  try {
4536
4675
  reloadAck = await rpcManager.reloadTestFile(file.testPath, testNamePattern);
4537
- const completionPromise = registerPendingHeadedReload(file.testPath, reloadAck.runId);
4538
- await Promise.race([
4539
- completionPromise,
4540
- new Promise((_, reject)=>{
4541
- timeoutId = setTimeout(()=>{
4542
- reject(new Error(`Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`));
4543
- }, timeoutMs);
4544
- })
4545
- ]);
4676
+ await registerPendingHeadedReload(file.testPath, reloadAck.runId);
4546
4677
  } catch (error) {
4547
4678
  if (reloadAck?.runId) rejectPendingHeadedReload(file.testPath, toError(error), reloadAck.runId);
4548
4679
  throw error;
4549
- } finally{
4550
- if (timeoutId) clearTimeout(timeoutId);
4551
4680
  }
4552
4681
  };
4553
4682
  const createRpcMethods = ()=>({
@@ -4604,14 +4733,15 @@ const runBrowserController = async (context, options)=>{
4604
4733
  }
4605
4734
  if (isNewPage) {
4606
4735
  const pagePath = '/';
4607
- await containerPage.goto(`http://localhost:${port}${pagePath}`, {
4736
+ const containerPort = runtime.containerServer.port;
4737
+ await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
4608
4738
  waitUntil: 'load'
4609
4739
  });
4610
- logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${port}${pagePath}\n`));
4740
+ logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`));
4611
4741
  }
4612
4742
  enqueueHeadedReload = async (file, testNamePattern)=>headedReloadQueue.enqueue(async ()=>{
4613
4743
  if (fatalError) return;
4614
- await reloadTestFileWithTimeout(file, testNamePattern);
4744
+ await reloadTestFileAndWait(file, testNamePattern);
4615
4745
  });
4616
4746
  let testTime = 0;
4617
4747
  if (currentTestFiles.length > 0) {
@@ -4732,18 +4862,12 @@ const listBrowserTests = async (context, options)=>{
4732
4862
  close: async ()=>{}
4733
4863
  };
4734
4864
  const tempDir = join(context.rootPath, context.normalizedConfig.output.distPath.root, 'browser', `list-${Date.now()}`);
4735
- const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
4736
- const manifestSource = generateManifestModule({
4737
- manifestPath,
4738
- entries: projectEntries
4739
- });
4740
4865
  const browserProjects = getBrowserProjects(context);
4741
4866
  let runtime;
4742
4867
  try {
4743
4868
  runtime = await createBrowserRuntime({
4744
4869
  context,
4745
- manifestPath,
4746
- manifestSource,
4870
+ projectEntries,
4747
4871
  tempDir,
4748
4872
  isWatchMode: false,
4749
4873
  containerDistPath: void 0,
@@ -4757,7 +4881,7 @@ const listBrowserTests = async (context, options)=>{
4757
4881
  logger.error(color.red(`Failed to initialize browser provider runtime (${providers.join(', ')}).`), error);
4758
4882
  throw error;
4759
4883
  }
4760
- const { browser, browserLaunchOptions, port } = runtime;
4884
+ const { browser, browserLaunchOptions } = runtime;
4761
4885
  const projectRuntimeConfigs = browserProjects.map((project)=>({
4762
4886
  name: project.name,
4763
4887
  environmentName: project.environmentName,
@@ -4777,70 +4901,81 @@ const listBrowserTests = async (context, options)=>{
4777
4901
  rpcTimeout: maxTestTimeoutForRpc
4778
4902
  };
4779
4903
  runtime.setContainerOptions(hostOptions);
4780
- const collectResults = [];
4781
- let fatalError = null;
4782
- let collectCompleted = false;
4783
- let resolveCollect;
4784
- const collectPromise = new Promise((resolve)=>{
4785
- resolveCollect = resolve;
4786
- });
4787
4904
  const browserContext = await browser.newContext({
4788
4905
  providerOptions: browserLaunchOptions.providerOptions,
4789
4906
  viewport: null
4790
4907
  });
4791
- const page = await browserContext.newPage();
4792
- await page.exposeFunction(DISPATCH_MESSAGE_TYPE, (message)=>{
4793
- switch(message.type){
4794
- case 'collect-result':
4795
- {
4796
- const payload = message.payload;
4797
- collectResults.push({
4798
- testPath: payload.testPath,
4799
- project: payload.project,
4800
- tests: payload.tests
4801
- });
4802
- break;
4803
- }
4804
- case 'collect-complete':
4805
- collectCompleted = true;
4806
- resolveCollect?.();
4807
- break;
4808
- case 'fatal':
4809
- {
4810
- const payload = message.payload;
4811
- fatalError = new Error(payload.message);
4812
- fatalError.stack = payload.stack;
4908
+ const serializedOptions = serializeForInlineScript(hostOptions);
4909
+ const collectFromServer = async (server)=>{
4910
+ const results = [];
4911
+ let error = null;
4912
+ let collectCompleted = false;
4913
+ let resolveCollect;
4914
+ const collectPromise = new Promise((resolve)=>{
4915
+ resolveCollect = resolve;
4916
+ });
4917
+ const page = await browserContext.newPage();
4918
+ await page.exposeFunction(DISPATCH_MESSAGE_TYPE, (message)=>{
4919
+ switch(message.type){
4920
+ case 'collect-result':
4921
+ {
4922
+ const payload = message.payload;
4923
+ results.push({
4924
+ testPath: payload.testPath,
4925
+ project: payload.project,
4926
+ tests: payload.tests
4927
+ });
4928
+ break;
4929
+ }
4930
+ case 'collect-complete':
4931
+ collectCompleted = true;
4813
4932
  resolveCollect?.();
4814
4933
  break;
4815
- }
4816
- case 'ready':
4817
- case 'log':
4818
- break;
4819
- default:
4820
- logger.debug(`[List] Unexpected message: ${message.type}`);
4821
- }
4822
- });
4823
- const serializedOptions = serializeForInlineScript(hostOptions);
4824
- await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
4825
- await page.goto(`http://localhost:${port}/runner.html`, {
4826
- waitUntil: 'load'
4827
- });
4828
- const timeoutMs = 30000;
4829
- let timeoutId;
4830
- const timeoutPromise = new Promise((resolve)=>{
4831
- timeoutId = setTimeout(()=>{
4832
- if (!collectCompleted) logger.warn(color.yellow(`[List] Browser test collection timed out after ${timeoutMs}ms`));
4833
- resolve();
4834
- }, timeoutMs);
4835
- });
4836
- await Promise.race([
4837
- collectPromise,
4838
- timeoutPromise
4839
- ]);
4840
- if (timeoutId) clearTimeout(timeoutId);
4934
+ case 'fatal':
4935
+ {
4936
+ const payload = message.payload;
4937
+ error = new Error(payload.message);
4938
+ error.stack = payload.stack;
4939
+ resolveCollect?.();
4940
+ break;
4941
+ }
4942
+ case 'ready':
4943
+ case 'log':
4944
+ break;
4945
+ default:
4946
+ logger.debug(`[List] Unexpected message: ${message.type}`);
4947
+ }
4948
+ });
4949
+ await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
4950
+ await page.goto(`http://localhost:${server.port}/runner.html`, {
4951
+ waitUntil: 'load'
4952
+ });
4953
+ const timeoutMs = 30000;
4954
+ let timeoutId;
4955
+ const timeoutPromise = new Promise((resolve)=>{
4956
+ timeoutId = setTimeout(()=>{
4957
+ if (!collectCompleted) logger.warn(color.yellow(`[List] Browser test collection timed out after ${timeoutMs}ms`));
4958
+ resolve();
4959
+ }, timeoutMs);
4960
+ });
4961
+ await Promise.race([
4962
+ collectPromise,
4963
+ timeoutPromise
4964
+ ]);
4965
+ if (timeoutId) clearTimeout(timeoutId);
4966
+ await page.close().catch(()=>{});
4967
+ return {
4968
+ results,
4969
+ error
4970
+ };
4971
+ };
4972
+ const collected = await Promise.all([
4973
+ ...runtime.projectServers.values()
4974
+ ].map((server)=>collectFromServer(server)));
4975
+ const collectResults = collected.flatMap((entry)=>entry.results);
4976
+ const fatalError = collected.find((entry)=>entry.error)?.error ?? null;
4841
4977
  const cleanup = async ()=>{
4842
4978
  try {
4843
- await page.close();
4844
4979
  await browserContext.close();
4845
4980
  } catch {}
4846
4981
  await destroyBrowserRuntime(runtime);