@rstest/browser 0.11.8 → 0.11.9

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.
@@ -23,6 +23,7 @@ export type BrowserProjectRuntime = {
23
23
  name: string;
24
24
  environmentName: string;
25
25
  projectRoot: string;
26
+ /** Setup modules are re-evaluated per file, so they cannot share a worker page. */ hasSetupFiles: boolean;
26
27
  runtimeConfig: SerializedRuntimeConfig;
27
28
  viewport?: BrowserViewport;
28
29
  };
@@ -33,7 +34,7 @@ export type BrowserProjectRuntime = {
33
34
  testPath: string;
34
35
  projectName: string;
35
36
  };
36
- export type FileCleanupDispatchMethod = 'start' | 'end';
37
+ export type FileCleanupDispatchMethod = 'start' | 'end' | 'worker-start' | 'worker-end';
37
38
  export type FileCleanupDispatchPayload = {
38
39
  projectName: string;
39
40
  result?: TestFileResult;
@@ -82,6 +83,7 @@ export type BrowserHostConfig = {
82
83
  updateSnapshot: SnapshotUpdateState;
83
84
  };
84
85
  testFile?: string;
86
+ /** Test files assigned to one browser worker session. */ testFiles?: string[];
85
87
  /**
86
88
  * The run identity this document executes under — the runner's SOLE identity
87
89
  * source, adopted once at boot and stamped on every outbound message. Headed:
@@ -1,5 +1,5 @@
1
1
  import { projectSetupLoaders, projectTestContexts, projects } from "__rstest_virtual_browser_manifest__";
2
- import { RSTEST_API_GLOBAL_KEY, RSTEST_ENV_SYMBOL_KEY, RSTEST_IMPORT_META_GLOBAL_KEY, SNAPSHOT_HEADER, createBrowserTaskContext, createRstestRuntime, formatConsoleArgs, globalApis, setRealTimers, unwrapRegex } from "@rstest/core/internal/browser-runtime";
2
+ import { FIXTURE_CLEANUP_TIMEOUT_MS, RSTEST_API_GLOBAL_KEY, RSTEST_ENV_SYMBOL_KEY, RSTEST_IMPORT_META_GLOBAL_KEY, SNAPSHOT_HEADER, cleanupWorkerFixtures as browser_runtime_cleanupWorkerFixtures, createBrowserTaskContext, createRstestRuntime, formatConsoleArgs, getRealTimers, globalApis, setRealTimers, unwrapRegex } from "@rstest/core/internal/browser-runtime";
3
3
  import { normalize } from "pathe";
4
4
  import { AnyMap, TraceMap, encodedMap, originalPositionFor } from "@jridgewell/trace-mapping";
5
5
  import convert_source_map from "convert-source-map";
@@ -186,6 +186,42 @@ class BrowserSnapshotEnvironment {
186
186
  const debugLog = (...args)=>{
187
187
  if (window.__RSTEST_BROWSER_OPTIONS__?.debug) console.log(...args);
188
188
  };
189
+ const cloneCoverage = (coverage)=>JSON.parse(JSON.stringify(coverage));
190
+ const subtractCounters = (current, previous)=>Object.fromEntries(Object.entries(current).map(([key, value])=>[
191
+ key,
192
+ value - (previous?.[key] ?? 0)
193
+ ]));
194
+ const getCoverageDelta = (current, previous)=>Object.fromEntries(Object.entries(current).map(([path, file])=>{
195
+ const previousFile = previous?.[path];
196
+ return [
197
+ path,
198
+ {
199
+ ...file,
200
+ s: subtractCounters(file.s, previousFile?.s),
201
+ f: subtractCounters(file.f, previousFile?.f),
202
+ b: Object.fromEntries(Object.entries(file.b).map(([key, values])=>[
203
+ key,
204
+ values.map((value, index)=>value - (previousFile?.b[key]?.[index] ?? 0))
205
+ ]))
206
+ }
207
+ ];
208
+ }));
209
+ const cleanupWorkerFixturesWithTimeout = async ()=>{
210
+ const realTimers = getRealTimers();
211
+ let timer;
212
+ try {
213
+ await Promise.race([
214
+ browser_runtime_cleanupWorkerFixtures(),
215
+ new Promise((_, reject)=>{
216
+ timer = realTimers.setTimeout?.(()=>{
217
+ reject(new Error(`Worker fixture cleanup did not finish within ${FIXTURE_CLEANUP_TIMEOUT_MS}ms`));
218
+ }, FIXTURE_CLEANUP_TIMEOUT_MS);
219
+ })
220
+ ]);
221
+ } finally{
222
+ if (timer) realTimers.clearTimeout?.(timer);
223
+ }
224
+ };
189
225
  const RSTEST_ENV_SYMBOL = Symbol.for(RSTEST_ENV_SYMBOL_KEY);
190
226
  const installRuntimeGlobals = (runtime, runtimeConfig)=>{
191
227
  Object.assign(globalThis, {
@@ -289,8 +325,9 @@ const waitForConfig = ()=>{
289
325
  if (window.parent === window || window.__RSTEST_BROWSER_OPTIONS__) return Promise.resolve();
290
326
  return new Promise((resolve, reject)=>{
291
327
  const handleMessage = (event)=>{
292
- if (event.data?.type === RSTEST_CONFIG_MESSAGE_TYPE && 'string' == typeof event.data.payload?.runId) {
293
- window.__RSTEST_BROWSER_OPTIONS__ = event.data.payload;
328
+ const payload = event.data?.payload;
329
+ if (event.data?.type === RSTEST_CONFIG_MESSAGE_TYPE && 'string' == typeof payload?.runId) {
330
+ window.__RSTEST_BROWSER_OPTIONS__ = payload;
294
331
  debugLog('[Runner] Received config from container:', event.data.payload);
295
332
  window.removeEventListener('message', handleMessage);
296
333
  resolve();
@@ -364,6 +401,9 @@ const run = async ()=>{
364
401
  setRealTimers();
365
402
  await preloadRunnerSourceMap();
366
403
  const targetTestFile = options.testFile;
404
+ const targetTestFiles = options.testFiles?.length ? options.testFiles : targetTestFile ? [
405
+ targetTestFile
406
+ ] : void 0;
367
407
  const currentProject = targetTestFile ? findProjectForTestFile(targetTestFile, projects) : projects["0"];
368
408
  if (!currentProject) {
369
409
  send({
@@ -404,12 +444,7 @@ const run = async ()=>{
404
444
  for (const loadSetup of currentSetupLoaders)await loadSetup();
405
445
  };
406
446
  let testKeysToRun;
407
- if (targetTestFile) {
408
- const key = toContextKey(targetTestFile, currentProject.projectRoot);
409
- testKeysToRun = [
410
- key
411
- ];
412
- } else testKeysToRun = currentTestContext.getTestKeys();
447
+ testKeysToRun = targetTestFiles ? targetTestFiles.map((testFile)=>toContextKey(testFile, currentProject.projectRoot)) : currentTestContext.getTestKeys();
413
448
  const executionMode = options.mode || 'run';
414
449
  if ('collect' === executionMode) {
415
450
  for (const key of testKeysToRun){
@@ -478,183 +513,269 @@ const run = async ()=>{
478
513
  };
479
514
  window.addEventListener('error', onWindowError);
480
515
  window.addEventListener('unhandledrejection', onUnhandledRejection);
481
- for (const key of testKeysToRun){
482
- const testPath = toAbsolutePath(key, currentProject.projectRoot);
483
- const taskStack = [
484
- {
485
- taskId: getFileTaskId(testPath),
486
- taskType: 'file',
487
- testPath
488
- }
489
- ];
490
- const taskContext = createBrowserTaskContext();
491
- const shouldInterceptConsole = !runtimeConfig.disableConsoleIntercept || true === runtimeConfig.silent || 'passed-only' === runtimeConfig.silent;
492
- const restoreConsole = shouldInterceptConsole ? interceptConsole(projectRuntime.name, ()=>taskContext.getCurrent() ?? taskStack[taskStack.length - 1], runtimeConfig.disableConsoleIntercept ? false : runtimeConfig.printConsoleTrace ?? false) : ()=>{};
493
- const workerState = {
494
- project: projectRuntime.name,
495
- projectRoot: projectRuntime.projectRoot,
496
- rootPath: options.rootPath,
497
- runtimeConfig,
498
- taskId: 0,
499
- buildId: 0,
500
- outputModule: false,
501
- environment: 'browser',
502
- currentTask: taskStack[0],
503
- testPath,
504
- distPath: testPath,
505
- snapshotOptions: {
506
- updateSnapshot: options.snapshot.updateSnapshot,
507
- snapshotEnvironment: new BrowserSnapshotEnvironment(),
508
- snapshotFormat: runtimeConfig.snapshotFormat
509
- }
510
- };
511
- const syncCurrentTask = ()=>{
512
- workerState.currentTask = taskStack[taskStack.length - 1];
513
- };
514
- const removeTaskFromStack = (taskId)=>{
515
- const taskIndex = taskStack.findLastIndex((task)=>task.taskId === taskId);
516
- if (taskIndex < 0) return;
517
- taskStack.splice(taskIndex, 1);
518
- syncCurrentTask();
519
- };
520
- const runtime = await createRstestRuntime(workerState, {
521
- taskContext
522
- });
523
- installRuntimeGlobals(runtime, runtimeConfig);
524
- let failedTestsCount = 0;
525
- const dispatchFileCleanup = async (method, result, waitForAcknowledgement = true)=>{
526
- const requestId = createRequestId(`file-cleanup-${method}`);
527
- const request = {
528
- requestId,
529
- namespace: DISPATCH_NAMESPACE_FILE_CLEANUP,
530
- method,
531
- args: {
532
- projectName: projectRuntime.name,
533
- result,
534
- runId: getRunIdentity(),
516
+ const keepWorkerFixtures = false === runtimeConfig.isolate && true !== projectRuntime.hasSetupFiles;
517
+ let restoreWorkerConsole;
518
+ let workerCleanupFailed = false;
519
+ let workerCleanupAttempted = false;
520
+ let setupListeners;
521
+ let previousIstanbulCoverage;
522
+ try {
523
+ for(let fileIndex = 0; fileIndex < testKeysToRun.length; fileIndex++){
524
+ const key = testKeysToRun[fileIndex];
525
+ const testPath = toAbsolutePath(key, currentProject.projectRoot);
526
+ options = {
527
+ ...options,
528
+ testFile: testPath
529
+ };
530
+ window.__RSTEST_BROWSER_OPTIONS__ = options;
531
+ const taskStack = [
532
+ {
533
+ taskId: getFileTaskId(testPath),
534
+ taskType: 'file',
535
535
  testPath
536
536
  }
537
+ ];
538
+ const taskContext = createBrowserTaskContext();
539
+ const shouldInterceptConsole = !runtimeConfig.disableConsoleIntercept || true === runtimeConfig.silent || 'passed-only' === runtimeConfig.silent;
540
+ const restoreConsole = shouldInterceptConsole ? interceptConsole(projectRuntime.name, ()=>taskContext.getCurrent() ?? taskStack[taskStack.length - 1], runtimeConfig.disableConsoleIntercept ? false : runtimeConfig.printConsoleTrace ?? false) : ()=>{};
541
+ if (keepWorkerFixtures) restoreWorkerConsole = restoreConsole;
542
+ const workerState = {
543
+ project: projectRuntime.name,
544
+ projectRoot: projectRuntime.projectRoot,
545
+ rootPath: options.rootPath,
546
+ runtimeConfig,
547
+ taskId: 0,
548
+ buildId: 0,
549
+ outputModule: false,
550
+ environment: 'browser',
551
+ currentTask: taskStack[0],
552
+ testPath,
553
+ distPath: testPath,
554
+ snapshotOptions: {
555
+ updateSnapshot: options.snapshot.updateSnapshot,
556
+ snapshotEnvironment: new BrowserSnapshotEnvironment(),
557
+ snapshotFormat: runtimeConfig.snapshotFormat
558
+ }
559
+ };
560
+ const syncCurrentTask = ()=>{
561
+ workerState.currentTask = taskStack[taskStack.length - 1];
562
+ };
563
+ const removeTaskFromStack = (taskId)=>{
564
+ const taskIndex = taskStack.findLastIndex((task)=>task.taskId === taskId);
565
+ if (taskIndex < 0) return;
566
+ taskStack.splice(taskIndex, 1);
567
+ syncCurrentTask();
568
+ };
569
+ let runtime;
570
+ try {
571
+ runtime = await createRstestRuntime(workerState, {
572
+ taskContext
573
+ });
574
+ installRuntimeGlobals(runtime, runtimeConfig);
575
+ } catch (error) {
576
+ restoreConsole();
577
+ throw error;
578
+ }
579
+ let failedTestsCount = 0;
580
+ const dispatchFileCleanup = async (method, result, waitForAcknowledgement = true)=>{
581
+ const requestId = createRequestId(`file-cleanup-${method}`);
582
+ const request = {
583
+ requestId,
584
+ namespace: DISPATCH_NAMESPACE_FILE_CLEANUP,
585
+ method,
586
+ args: {
587
+ projectName: projectRuntime.name,
588
+ result,
589
+ runId: getRunIdentity(),
590
+ testPath
591
+ }
592
+ };
593
+ if (!waitForAcknowledgement) return void sendDispatchRequest(request);
594
+ await dispatchRpc({
595
+ requestId,
596
+ request,
597
+ timeoutMs: getRpcTimeout('framework'),
598
+ timeoutMessage: `File cleanup ${method} acknowledgement timed out for ${testPath}.`,
599
+ staleMessage: `File cleanup ${method} became stale for ${testPath}.`
600
+ });
537
601
  };
538
- if (!waitForAcknowledgement) return void sendDispatchRequest(request);
539
- await dispatchRpc({
540
- requestId,
541
- request,
542
- timeoutMs: getRpcTimeout('framework'),
543
- timeoutMessage: `File cleanup ${method} acknowledgement timed out for ${testPath}.`,
544
- staleMessage: `File cleanup ${method} became stale for ${testPath}.`
602
+ const cleanupWorkerFixtures = async (result)=>{
603
+ await dispatchFileCleanup('worker-start', result);
604
+ try {
605
+ await cleanupWorkerFixturesWithTimeout();
606
+ } finally{
607
+ await dispatchFileCleanup('worker-end');
608
+ }
609
+ };
610
+ const updateIstanbulCoverage = (result)=>{
611
+ if (!globalThis.__coverage__) return;
612
+ const currentCoverage = globalThis.__coverage__;
613
+ result.coverage = getCoverageDelta(currentCoverage, previousIstanbulCoverage);
614
+ previousIstanbulCoverage = cloneCoverage(currentCoverage);
615
+ };
616
+ const runnerHooks = {
617
+ onFileCleanupStart: async (result)=>{
618
+ if (result && globalThis.__coverage__) result.coverage = globalThis.__coverage__;
619
+ await dispatchFileCleanup('start', result, window.parent !== window);
620
+ },
621
+ onFileCleanupEnd: ()=>dispatchFileCleanup('end', void 0, window.parent !== window),
622
+ onSnapshotSetupStart: async ()=>{
623
+ setRpcPhase('framework');
624
+ },
625
+ onSnapshotSetupEnd: async ()=>{
626
+ setRpcPhase('test');
627
+ },
628
+ onSnapshotFinishStart: async ()=>{
629
+ setRpcPhase('framework');
630
+ },
631
+ onSnapshotFinishEnd: async ()=>{
632
+ setRpcPhase('test');
633
+ },
634
+ onTestFileReady: async (test)=>{
635
+ dispatchRunnerLifecycle('file-ready', test);
636
+ },
637
+ onTestSuiteStart: async (test)=>{
638
+ taskStack.push({
639
+ taskId: test.testId,
640
+ taskName: test.name,
641
+ taskParentNames: test.parentNames,
642
+ taskType: 'suite',
643
+ testPath: test.testPath
644
+ });
645
+ syncCurrentTask();
646
+ dispatchRunnerLifecycle('suite-start', test);
647
+ },
648
+ onTestSuiteResult: async (result)=>{
649
+ removeTaskFromStack(result.testId);
650
+ dispatchRunnerLifecycle('suite-result', result);
651
+ },
652
+ onTestCaseStart: async (test)=>{
653
+ taskStack.push({
654
+ taskId: test.testId,
655
+ taskName: test.name,
656
+ taskParentNames: test.parentNames,
657
+ taskType: 'case',
658
+ testPath: test.testPath
659
+ });
660
+ syncCurrentTask();
661
+ dispatchRunnerLifecycle('case-start', test);
662
+ },
663
+ onTestCaseResult: async (result)=>{
664
+ removeTaskFromStack(result.testId);
665
+ if ('fail' === result.status) failedTestsCount++;
666
+ send({
667
+ type: 'case-result',
668
+ payload: result
669
+ });
670
+ },
671
+ getCountOfFailedTests: async ()=>failedTestsCount
672
+ };
673
+ send({
674
+ type: 'file-start',
675
+ payload: {
676
+ testPath,
677
+ projectName: projectRuntime.name
678
+ }
545
679
  });
546
- };
547
- const runnerHooks = {
548
- onFileCleanupStart: async (result)=>{
549
- if (result && globalThis.__coverage__) result.coverage = globalThis.__coverage__;
550
- await dispatchFileCleanup('start', result, window.parent !== window);
551
- },
552
- onFileCleanupEnd: ()=>dispatchFileCleanup('end', void 0, window.parent !== window),
553
- onSnapshotSetupStart: async ()=>{
554
- setRpcPhase('framework');
555
- },
556
- onSnapshotSetupEnd: async ()=>{
557
- setRpcPhase('test');
558
- },
559
- onSnapshotFinishStart: async ()=>{
680
+ const unhandledErrors = [];
681
+ activeUnhandledErrors = unhandledErrors;
682
+ try {
560
683
  setRpcPhase('framework');
561
- },
562
- onSnapshotFinishEnd: async ()=>{
563
- setRpcPhase('test');
564
- },
565
- onTestFileReady: async (test)=>{
566
- dispatchRunnerLifecycle('file-ready', test);
567
- },
568
- onTestSuiteStart: async (test)=>{
569
- taskStack.push({
570
- taskId: test.testId,
571
- taskName: test.name,
572
- taskParentNames: test.parentNames,
573
- taskType: 'suite',
574
- testPath: test.testPath
575
- });
576
- syncCurrentTask();
577
- dispatchRunnerLifecycle('suite-start', test);
578
- },
579
- onTestSuiteResult: async (result)=>{
580
- removeTaskFromStack(result.testId);
581
- dispatchRunnerLifecycle('suite-result', result);
582
- },
583
- onTestCaseStart: async (test)=>{
584
- taskStack.push({
585
- taskId: test.testId,
586
- taskName: test.name,
587
- taskParentNames: test.parentNames,
588
- taskType: 'case',
589
- testPath: test.testPath
684
+ if (setupListeners) runtime.runner.setRootSuiteListeners(setupListeners);
685
+ await loadSetupFiles();
686
+ setupListeners ??= runtime.runner.getRootSuiteListeners();
687
+ const beforeScripts = getScriptUrls();
688
+ await currentTestContext.loadTest(key);
689
+ const afterScripts = getScriptUrls();
690
+ const chunkUrl = findNewScriptUrl(beforeScripts, afterScripts);
691
+ if (chunkUrl) await preloadTestFileSourceMap(chunkUrl);
692
+ const result = await runtime.runner.runTests(testPath, runnerHooks, runtime.api);
693
+ const cleanupBeforeFileComplete = !keepWorkerFixtures || fileIndex === testKeysToRun.length - 1;
694
+ if (cleanupBeforeFileComplete) {
695
+ workerCleanupAttempted = true;
696
+ try {
697
+ await cleanupWorkerFixtures(result);
698
+ } catch (cleanupError) {
699
+ const formattedCleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError));
700
+ result.status = 'fail';
701
+ result.errors = [
702
+ ...result.errors ?? [],
703
+ {
704
+ fullStack: true,
705
+ message: `Worker fixture cleanup failed: ${formattedCleanupError.message}`,
706
+ name: formattedCleanupError.name,
707
+ stack: formattedCleanupError.stack
708
+ }
709
+ ];
710
+ }
711
+ }
712
+ updateIstanbulCoverage(result);
713
+ for(let i = 0; i < 2; i++)await new Promise((resolve)=>{
714
+ setTimeout(resolve, 0);
590
715
  });
591
- syncCurrentTask();
592
- dispatchRunnerLifecycle('case-start', test);
593
- },
594
- onTestCaseResult: async (result)=>{
595
- removeTaskFromStack(result.testId);
596
- if ('fail' === result.status) failedTestsCount++;
716
+ if (unhandledErrors.length > 0) {
717
+ result.status = 'fail';
718
+ result.errors = [
719
+ ...result.errors ?? [],
720
+ ...unhandledErrors.map((error)=>({
721
+ name: error.name,
722
+ message: error.message,
723
+ stack: error.stack
724
+ }))
725
+ ];
726
+ }
597
727
  send({
598
- type: 'case-result',
728
+ type: 'file-complete',
599
729
  payload: result
600
730
  });
601
- },
602
- getCountOfFailedTests: async ()=>failedTestsCount
603
- };
604
- send({
605
- type: 'file-start',
606
- payload: {
607
- testPath,
608
- projectName: projectRuntime.name
609
- }
610
- });
611
- const unhandledErrors = [];
612
- activeUnhandledErrors = unhandledErrors;
613
- try {
614
- setRpcPhase('framework');
615
- await loadSetupFiles();
616
- const beforeScripts = getScriptUrls();
617
- await currentTestContext.loadTest(key);
618
- const afterScripts = getScriptUrls();
619
- const chunkUrl = findNewScriptUrl(beforeScripts, afterScripts);
620
- if (chunkUrl) await preloadTestFileSourceMap(chunkUrl);
621
- const result = await runtime.runner.runTests(testPath, runnerHooks, runtime.api);
622
- for(let i = 0; i < 2; i++)await new Promise((resolve)=>{
623
- setTimeout(resolve, 0);
624
- });
625
- if (unhandledErrors.length > 0) {
626
- result.status = 'fail';
627
- result.errors = [
628
- ...result.errors ?? [],
629
- ...unhandledErrors.map((error)=>({
630
- name: error.name,
631
- message: error.message,
632
- stack: error.stack
633
- }))
634
- ];
731
+ } catch (_error) {
732
+ let error = _error instanceof Error ? _error : new Error(String(_error));
733
+ if (!workerCleanupAttempted) try {
734
+ workerCleanupAttempted = true;
735
+ await cleanupWorkerFixtures();
736
+ } catch (cleanupError) {
737
+ const formattedCleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError));
738
+ error = new Error(`${error.message}\nWorker fixture cleanup failed: ${formattedCleanupError.message}`, {
739
+ cause: error
740
+ });
741
+ }
742
+ send({
743
+ type: 'fatal',
744
+ payload: {
745
+ message: error.message,
746
+ stack: error.stack
747
+ }
748
+ });
749
+ window.__RSTEST_DONE__ = true;
750
+ return;
751
+ } finally{
752
+ if (keepWorkerFixtures && fileIndex === testKeysToRun.length - 1) restoreWorkerConsole = restoreConsole;
753
+ else restoreConsole();
754
+ activeUnhandledErrors = void 0;
635
755
  }
636
- send({
637
- type: 'file-complete',
638
- payload: result
639
- });
640
- } catch (_error) {
641
- const error = _error instanceof Error ? _error : new Error(String(_error));
756
+ }
757
+ } finally{
758
+ if (keepWorkerFixtures && !workerCleanupAttempted) try {
759
+ await cleanupWorkerFixturesWithTimeout();
760
+ } catch (error) {
761
+ workerCleanupFailed = true;
762
+ const cleanupError = error instanceof Error ? error : new Error(String(error));
642
763
  send({
643
764
  type: 'fatal',
644
765
  payload: {
645
- message: error.message,
646
- stack: error.stack
766
+ message: cleanupError.message,
767
+ stack: cleanupError.stack
647
768
  }
648
769
  });
649
- window.__RSTEST_DONE__ = true;
650
- return;
651
- } finally{
652
- restoreConsole();
653
- activeUnhandledErrors = void 0;
654
770
  }
771
+ restoreWorkerConsole?.();
655
772
  }
656
773
  window.removeEventListener('error', onWindowError);
657
774
  window.removeEventListener('unhandledrejection', onUnhandledRejection);
775
+ if (workerCleanupFailed) {
776
+ window.__RSTEST_DONE__ = true;
777
+ return;
778
+ }
658
779
  send({
659
780
  type: 'complete'
660
781
  });
package/dist/index.js CHANGED
@@ -1844,11 +1844,6 @@ const ignoredKeyWarnings = {
1844
1844
  isNonDefault: (config)=>'node' !== config.testEnvironment.name,
1845
1845
  message: (config)=>`Ignoring testEnvironment '${config.testEnvironment.name}' in browser mode: the browser itself is the test environment.`
1846
1846
  },
1847
- isolate: {
1848
- isNonDefault: (config)=>false === config.isolate,
1849
- message: ()=>"Ignoring isolate: false in browser mode: each test file still runs in a fresh context.",
1850
- browserOnly: true
1851
- },
1852
1847
  detectAsyncLeaks: {
1853
1848
  isNonDefault: (config)=>true === config.detectAsyncLeaks,
1854
1849
  message: ()=>"Ignoring detectAsyncLeaks in browser mode: it relies on node async_hooks."
@@ -1917,6 +1912,8 @@ const reportUnsupportedBrowserOptions = (context)=>{
1917
1912
  }
1918
1913
  }
1919
1914
  }
1915
+ if (isBrowserOnlyRun && browserProjects.some(({ normalizedConfig: config })=>false === config.isolate && false === config.browser.headless)) warnings.add('Browser mode keeps headed file frames isolated, so worker-scoped fixtures do not span files when browser.headless is false.');
1916
+ if (isBrowserOnlyRun && browserProjects.some(({ normalizedConfig: config })=>false === config.isolate && (config.setupFiles?.length ?? 0) > 0)) warnings.add('Browser projects with setupFiles stay file-isolated, even when isolate is false, so setup modules can run for every file.');
1920
1917
  for (const message of warnings)logger.warn(color.yellow(message));
1921
1918
  };
1922
1919
  const validateBrowserConfig = (context)=>{
@@ -2981,9 +2978,7 @@ const createBrowserRuntime = async ({ context, projectEntries: initialProjectEnt
2981
2978
  const refreshProjectEntries = async ()=>{
2982
2979
  validateBrowserConfig(context);
2983
2980
  browserLaunchOptions = ensureConsistentBrowserLaunchOptions(browserProjects);
2984
- const updatedShardedEntries = freezeShardedEntries ? shardedEntries : context.normalizedConfig.shard ? await resolveShardedEntries(context, {
2985
- silent: true
2986
- }) : shardedEntries;
2981
+ const updatedShardedEntries = freezeShardedEntries ? shardedEntries : context.normalizedConfig.shard ? await resolveShardedEntries(context) : shardedEntries;
2987
2982
  projectEntries = await resolveProjectEntries(context, updatedShardedEntries, browserProjects);
2988
2983
  for (const manifestModule of manifestModules)refreshManifestModule(manifestModule);
2989
2984
  };
@@ -4125,26 +4120,25 @@ const createHeadedScheduler = async ({ context, runtime, allTestFiles, hostOptio
4125
4120
  dispatchRouter.register(DISPATCH_NAMESPACE_FILE_CLEANUP, async (request)=>{
4126
4121
  const { runId } = request;
4127
4122
  if (!runId) return;
4128
- if ('start' === request.method) {
4123
+ if ('start' === request.method || 'worker-start' === request.method) {
4129
4124
  const payload = request.args;
4130
4125
  runs.armCleanupDeadline(runId, FIXTURE_CLEANUP_TIMEOUT_MS, ()=>{
4131
4126
  (async ()=>{
4132
4127
  try {
4133
- await collectCoverage();
4128
+ await recoverHeadedContainer();
4134
4129
  await handleTestFileComplete(createFileCleanupTimeoutResult({
4135
4130
  message: `File fixture cleanup did not finish within ${FIXTURE_CLEANUP_TIMEOUT_MS}ms`,
4136
4131
  projectName: payload.projectName,
4137
4132
  result: payload.result,
4138
4133
  testPath: payload.testPath
4139
4134
  }));
4140
- await recoverHeadedContainer();
4141
4135
  runs.resolve(runId);
4142
4136
  } catch (error) {
4143
4137
  runs.reject(runId, toError(error));
4144
4138
  }
4145
4139
  })();
4146
4140
  });
4147
- } else if ('end' === request.method) runs.disarmCleanupDeadline(runId);
4141
+ } else if ('end' === request.method || 'worker-end' === request.method) runs.disarmCleanupDeadline(runId);
4148
4142
  });
4149
4143
  const reloadTestFileAndWait = async (file, testNamePattern)=>{
4150
4144
  await startCoverage(file.projectName);
@@ -4462,6 +4456,10 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4462
4456
  project.name,
4463
4457
  project.projectRoot
4464
4458
  ]));
4459
+ const projectIsolation = new Map(projectRuntimeConfigs.map((project)=>[
4460
+ project.name,
4461
+ project.hasSetupFiles || project.runtimeConfig?.isolate !== false
4462
+ ]));
4465
4463
  const runLifecycle = new RunSessionLifecycle();
4466
4464
  const sessionRegistry = new RunnerSessionRegistry();
4467
4465
  setDispatchPageResolver((target)=>({
@@ -4505,10 +4503,15 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4505
4503
  const handler = fileCleanupHandlers.get(sessionId);
4506
4504
  if (!handler) return;
4507
4505
  const payload = request.args;
4508
- if ('start' === request.method) handler.start(payload.result);
4509
- else if ('end' === request.method) handler.end();
4506
+ if ('start' === request.method) await handler.start(payload);
4507
+ else if ('worker-start' === request.method) await handler.workerStart();
4508
+ else if ('end' === request.method) await handler.end(payload);
4509
+ else if ('worker-end' === request.method) await handler.workerEnd();
4510
4510
  });
4511
- const dispatchRunnerMessage = async (run, file, sessionId, message)=>{
4511
+ const dispatchRunnerMessage = async (run, files, sessionId, message)=>{
4512
+ const payload = 'payload' in message ? message.payload : void 0;
4513
+ const testPath = payload && 'object' == typeof payload && 'testPath' in payload ? String(payload.testPath) : files[0].testPath;
4514
+ const file = files.find((candidate)=>candidate.testPath === normalize(testPath)) ?? files[0];
4512
4515
  const response = await dispatchRouter.dispatch({
4513
4516
  requestId: nextDispatchRequestId("runner"),
4514
4517
  runToken: run.token,
@@ -4524,8 +4527,10 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4524
4527
  if (response.stale) return;
4525
4528
  if (response.error) throw new Error(response.error);
4526
4529
  };
4527
- const runSingleFile = async (run, file, rawCoverage)=>{
4530
+ const runWorkerFiles = async (run, files, rawCoverage)=>{
4528
4531
  if (run.cancelled || runLifecycle.isTokenStale(run.token)) return;
4532
+ const file = files[0];
4533
+ let activeFile = file;
4529
4534
  const viewport = viewportByProject.get(file.projectName);
4530
4535
  const browserContext = await browser.newContext({
4531
4536
  providerOptions: browserLaunchOptions.providerOptions,
@@ -4537,9 +4542,31 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4537
4542
  let coverageCollection;
4538
4543
  let settled = false;
4539
4544
  let resolveDone = null;
4540
- let cleanupTimer;
4541
- let fileCleanupFinished = false;
4542
- let provisionalResult;
4545
+ let workerCleanupTimer;
4546
+ let dispatchQueue = Promise.resolve();
4547
+ const completedFiles = new Set();
4548
+ const keepWorkerFixtures = false === projectIsolation.get(file.projectName);
4549
+ const fileCleanupStates = new Map();
4550
+ const handleWorkerCleanupTimeout = ()=>{
4551
+ if (settled) return;
4552
+ settled = true;
4553
+ (async ()=>{
4554
+ await closeContextSafely(browserContext);
4555
+ await handleFatal({
4556
+ message: `Worker fixture cleanup did not finish within ${FIXTURE_CLEANUP_TIMEOUT_MS}ms`
4557
+ });
4558
+ resolveDone?.();
4559
+ })();
4560
+ };
4561
+ const armWorkerCleanupDeadline = ()=>{
4562
+ if (workerCleanupTimer) clearTimeout(workerCleanupTimer);
4563
+ workerCleanupTimer = setTimeout(handleWorkerCleanupTimeout, FIXTURE_CLEANUP_TIMEOUT_MS);
4564
+ };
4565
+ const disarmWorkerCleanupDeadline = ()=>{
4566
+ if (!workerCleanupTimer) return;
4567
+ clearTimeout(workerCleanupTimer);
4568
+ workerCleanupTimer = void 0;
4569
+ };
4543
4570
  const markDone = ()=>{
4544
4571
  if (!settled) {
4545
4572
  settled = true;
@@ -4560,6 +4587,7 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4560
4587
  if (!coverageCollection) coverageCollection = (async ()=>{
4561
4588
  const coverage = await v8Coverage.take(page, projectRootByName.get(file.projectName) ?? context.rootPath, file.projectName);
4562
4589
  if (coverage) rawCoverage.push(coverage);
4590
+ if (keepWorkerFixtures) await v8Coverage.start(page);
4563
4591
  })();
4564
4592
  await coverageCollection;
4565
4593
  };
@@ -4567,8 +4595,8 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4567
4595
  page = await browserContext.newPage();
4568
4596
  await v8Coverage?.start(page);
4569
4597
  run.coverageCollectors.add(collectCoverage);
4570
- page.on('crash', ()=>onPageDead(`Browser page crashed while running ${file.testPath}.`));
4571
- page.on('close', ()=>onPageDead(`Browser page closed unexpectedly while running ${file.testPath}.`));
4598
+ page.on('crash', ()=>onPageDead(`Browser page crashed while running ${activeFile.testPath}.`));
4599
+ page.on('close', ()=>onPageDead(`Browser page closed unexpectedly while running ${activeFile.testPath}.`));
4572
4600
  const session = sessionRegistry.register({
4573
4601
  testFile: file.testPath,
4574
4602
  projectName: file.projectName,
@@ -4578,73 +4606,108 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4578
4606
  page
4579
4607
  });
4580
4608
  sessionId = session.id;
4581
- const clearFileCleanupTimeout = ()=>{
4582
- if (cleanupTimer) {
4583
- clearTimeout(cleanupTimer);
4584
- cleanupTimer = void 0;
4585
- }
4586
- };
4587
- const finishFileCleanup = ()=>{
4588
- fileCleanupFinished = true;
4589
- clearFileCleanupTimeout();
4590
- };
4591
4609
  fileCleanupHandlers.set(session.id, {
4592
- end: finishFileCleanup,
4593
- start: (result)=>{
4594
- if (fileCleanupFinished) return;
4595
- provisionalResult = result;
4596
- clearFileCleanupTimeout();
4597
- cleanupTimer = setTimeout(()=>{
4610
+ workerStart: armWorkerCleanupDeadline,
4611
+ end: (payload)=>{
4612
+ const state = fileCleanupStates.get(payload.testPath);
4613
+ if (!state) return;
4614
+ state.finished = true;
4615
+ if (state.timer) {
4616
+ clearTimeout(state.timer);
4617
+ state.timer = void 0;
4618
+ }
4619
+ return collectCoverage().finally(()=>{
4620
+ coverageCollection = void 0;
4621
+ });
4622
+ },
4623
+ workerEnd: ()=>{
4624
+ disarmWorkerCleanupDeadline();
4625
+ return collectCoverage().finally(()=>{
4626
+ coverageCollection = void 0;
4627
+ });
4628
+ },
4629
+ start: async (payload)=>{
4630
+ const state = fileCleanupStates.get(payload.testPath) ?? {
4631
+ finished: false
4632
+ };
4633
+ if (state.finished) return;
4634
+ state.result = payload.result;
4635
+ if (state.timer) clearTimeout(state.timer);
4636
+ state.timer = setTimeout(()=>{
4598
4637
  (async ()=>{
4599
4638
  if (settled) return;
4600
4639
  settled = true;
4601
4640
  const message = `File fixture cleanup did not finish within ${FIXTURE_CLEANUP_TIMEOUT_MS}ms`;
4602
4641
  try {
4603
- await collectCoverage();
4642
+ completedFiles.add(payload.testPath);
4604
4643
  await handleTestFileComplete(createFileCleanupTimeoutResult({
4605
4644
  message,
4606
4645
  projectName: file.projectName,
4607
- result: provisionalResult,
4608
- testPath: file.testPath
4646
+ result: state.result,
4647
+ testPath: payload.testPath
4609
4648
  }));
4649
+ for (const pendingFile of files)if (!completedFiles.has(pendingFile.testPath)) {
4650
+ completedFiles.add(pendingFile.testPath);
4651
+ await handleTestFileComplete(createFileCleanupTimeoutResult({
4652
+ message: `${message}; file was not run because the previous file cleanup timed out`,
4653
+ projectName: pendingFile.projectName,
4654
+ testPath: pendingFile.testPath
4655
+ }));
4656
+ }
4610
4657
  } catch (error) {
4611
4658
  const formatted = toError(error);
4659
+ await closeContextSafely(browserContext);
4612
4660
  await handleFatal({
4613
4661
  message: formatted.message,
4614
4662
  stack: formatted.stack
4615
4663
  });
4616
- await collectRunCoverage(run);
4617
- await cancelRun(run, false);
4618
4664
  } finally{
4619
4665
  resolveDone?.();
4620
4666
  }
4621
4667
  })();
4622
4668
  }, FIXTURE_CLEANUP_TIMEOUT_MS);
4669
+ fileCleanupStates.set(payload.testPath, state);
4623
4670
  }
4624
4671
  });
4625
4672
  await attachHeadlessRunnerTransport(page, {
4626
- onDispatchMessage: async (envelope)=>{
4627
- const message = envelope.message;
4628
- try {
4629
- if (settled) return;
4630
- if ('file-complete' === message.type || 'fatal' === message.type) await collectCoverage();
4631
- await dispatchRunnerMessage(run, file, session.id, message);
4632
- if ('file-complete' === message.type) markDone();
4633
- else if ('fatal' === message.type) {
4673
+ onDispatchMessage: (envelope)=>{
4674
+ const queued = dispatchQueue.then(async ()=>{
4675
+ try {
4676
+ const message = envelope.message;
4677
+ if (settled) return;
4678
+ if ('file-start' === message.type) {
4679
+ const startedPath = normalize(message.payload.testPath);
4680
+ activeFile = files.find((candidate)=>candidate.testPath === startedPath) ?? activeFile;
4681
+ }
4682
+ if ('fatal' === message.type) await collectCoverage();
4683
+ await dispatchRunnerMessage(run, files, session.id, message);
4684
+ if ('file-complete' === message.type) {
4685
+ const completedPath = message.payload.testPath;
4686
+ completedFiles.add(completedPath);
4687
+ }
4688
+ if ('complete' !== message.type && ('file-complete' !== message.type || 1 !== files.length || keepWorkerFixtures)) {
4689
+ if ('fatal' === message.type) {
4690
+ markDone();
4691
+ await collectRunCoverage(run);
4692
+ await cancelRun(run, false);
4693
+ }
4694
+ } else {
4695
+ disarmWorkerCleanupDeadline();
4696
+ markDone();
4697
+ }
4698
+ } catch (error) {
4699
+ const formatted = toError(error);
4700
+ await handleFatal({
4701
+ message: formatted.message,
4702
+ stack: formatted.stack
4703
+ });
4634
4704
  markDone();
4635
4705
  await collectRunCoverage(run);
4636
4706
  await cancelRun(run, false);
4637
4707
  }
4638
- } catch (error) {
4639
- const formatted = toError(error);
4640
- await handleFatal({
4641
- message: formatted.message,
4642
- stack: formatted.stack
4643
- });
4644
- markDone();
4645
- await collectRunCoverage(run);
4646
- await cancelRun(run, false);
4647
- }
4708
+ });
4709
+ dispatchQueue = queued.catch(()=>{});
4710
+ return queued;
4648
4711
  },
4649
4712
  onDispatchRpc: async (request)=>dispatchRouter.dispatch({
4650
4713
  ...request,
@@ -4663,6 +4726,7 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4663
4726
  updateSnapshot: context.snapshotManager.options.updateSnapshot
4664
4727
  },
4665
4728
  testFile: file.testPath,
4729
+ testFiles: files.map((item)=>item.testPath),
4666
4730
  runId: `${run.token}:${session.id}`
4667
4731
  };
4668
4732
  const serializedOptions = serializeForInlineScript(inlineOptions);
@@ -4703,7 +4767,8 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4703
4767
  await cancelRun(run, false);
4704
4768
  }
4705
4769
  } finally{
4706
- if (cleanupTimer) clearTimeout(cleanupTimer);
4770
+ for (const state of fileCleanupStates.values())if (state.timer) clearTimeout(state.timer);
4771
+ if (workerCleanupTimer) clearTimeout(workerCleanupTimer);
4707
4772
  if (sessionId) fileCleanupHandlers.delete(sessionId);
4708
4773
  run.coverageCollectors.delete(collectCoverage);
4709
4774
  const abandoned = run.cancelled || runLifecycle.isTokenStale(run.token);
@@ -4742,6 +4807,7 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4742
4807
  ];
4743
4808
  const concurrency = getHeadlessConcurrency(context, queue.length);
4744
4809
  const bail = context.normalizedConfig.bail;
4810
+ const nonIsolatedBatchSize = Math.max(1, bail ? 1 : Math.ceil(files.length / Math.max(concurrency, 1)));
4745
4811
  const worker = async ()=>{
4746
4812
  while(queue.length > 0 && !run.cancelled && runLifecycle.isTokenActive(run.token)){
4747
4813
  if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
@@ -4754,7 +4820,11 @@ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions,
4754
4820
  }
4755
4821
  const next = queue.shift();
4756
4822
  if (!next) return;
4757
- await runSingleFile(run, next, rawCoverage);
4823
+ const batch = [
4824
+ next
4825
+ ];
4826
+ if (false === projectIsolation.get(next.projectName)) while(batch.length < nonIsolatedBatchSize && queue[0]?.projectName === next.projectName)batch.push(queue.shift());
4827
+ await runWorkerFiles(run, batch, rawCoverage);
4758
4828
  }
4759
4829
  };
4760
4830
  run.done = Promise.all(Array.from({
@@ -5189,6 +5259,7 @@ const runBrowserController = async (context, options)=>{
5189
5259
  name: project.name,
5190
5260
  environmentName: project.environmentName,
5191
5261
  projectRoot: normalize(project.rootPath),
5262
+ hasSetupFiles: (project.normalizedConfig.setupFiles?.length ?? 0) > 0,
5192
5263
  runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
5193
5264
  envMode: 'static',
5194
5265
  envOverlay: env
@@ -5584,6 +5655,7 @@ const listBrowserTests = async (context, options)=>{
5584
5655
  name: project.name,
5585
5656
  environmentName: project.environmentName,
5586
5657
  projectRoot: normalize(project.rootPath),
5658
+ hasSetupFiles: (project.normalizedConfig.setupFiles?.length ?? 0) > 0,
5587
5659
  runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
5588
5660
  envMode: 'static',
5589
5661
  envOverlay: options?.env
@@ -23,6 +23,7 @@ export type BrowserProjectRuntime = {
23
23
  name: string;
24
24
  environmentName: string;
25
25
  projectRoot: string;
26
+ /** Setup modules are re-evaluated per file, so they cannot share a worker page. */ hasSetupFiles: boolean;
26
27
  runtimeConfig: SerializedRuntimeConfig;
27
28
  viewport?: BrowserViewport;
28
29
  };
@@ -33,7 +34,7 @@ export type BrowserProjectRuntime = {
33
34
  testPath: string;
34
35
  projectName: string;
35
36
  };
36
- export type FileCleanupDispatchMethod = 'start' | 'end';
37
+ export type FileCleanupDispatchMethod = 'start' | 'end' | 'worker-start' | 'worker-end';
37
38
  export type FileCleanupDispatchPayload = {
38
39
  projectName: string;
39
40
  result?: TestFileResult;
@@ -82,6 +83,7 @@ export type BrowserHostConfig = {
82
83
  updateSnapshot: SnapshotUpdateState;
83
84
  };
84
85
  testFile?: string;
86
+ /** Test files assigned to one browser worker session. */ testFiles?: string[];
85
87
  /**
86
88
  * The run identity this document executes under — the runner's SOLE identity
87
89
  * source, adopted once at boot and stamped on every outbound message. Headed:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rstest/browser",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
4
4
  "description": "Browser mode support for Rstest testing framework.",
5
5
  "keywords": [
6
6
  "rstest",
@@ -48,7 +48,7 @@
48
48
  "ws": "^8.21.3"
49
49
  },
50
50
  "devDependencies": {
51
- "@rslib/core": "1.0.0-beta.2",
51
+ "@rslib/core": "1.0.0-beta.3",
52
52
  "@types/convert-source-map": "^2.0.3",
53
53
  "@types/picomatch": "^4.0.3",
54
54
  "@types/ws": "^8.18.1",
@@ -56,13 +56,13 @@
56
56
  "birpc": "^4.1.0",
57
57
  "picomatch": "^4.0.5",
58
58
  "playwright": "^1.62.1",
59
+ "@rstest/tsconfig": "0.0.1",
59
60
  "@rstest/browser-ui": "0.0.0",
60
- "@rstest/core": "0.11.8",
61
- "@rstest/tsconfig": "0.0.1"
61
+ "@rstest/core": "0.11.9"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "playwright": "^1.49.1",
65
- "@rstest/core": "0.11.8"
65
+ "@rstest/core": "0.11.9"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "playwright": {