@rstest/browser 0.8.4 → 0.9.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.
Files changed (79) hide show
  1. package/LICENSE-APACHE-2.0 +202 -0
  2. package/NOTICE +11 -0
  3. package/dist/361.js +8 -0
  4. package/dist/augmentExpect.d.ts +73 -0
  5. package/dist/browser-container/container-static/css/index.5c72297783.css +1 -0
  6. package/dist/browser-container/container-static/js/{392.28f9a733.js → 101.36a8ccdf84.js} +4068 -3904
  7. package/dist/browser-container/container-static/js/101.36a8ccdf84.js.LICENSE.txt +1 -0
  8. package/dist/browser-container/container-static/js/{index.129eaf9c.js → index.0687a8142a.js} +742 -692
  9. package/dist/browser-container/container-static/js/{lib-react.97ee79b0.js → lib-react.dcf2a5e57a.js} +10 -10
  10. package/dist/browser-container/container-static/js/lib-react.dcf2a5e57a.js.LICENSE.txt +1 -0
  11. package/dist/browser-container/index.html +1 -1
  12. package/dist/browser.d.ts +2 -0
  13. package/dist/browser.js +583 -0
  14. package/dist/browserRpcRegistry.d.ts +18 -0
  15. package/dist/client/api.d.ts +3 -0
  16. package/dist/client/browserRpc.d.ts +2 -0
  17. package/dist/client/dispatchTransport.d.ts +11 -0
  18. package/dist/client/entry.d.ts +1 -5
  19. package/dist/client/locator.d.ts +125 -0
  20. package/dist/client/snapshot.d.ts +0 -6
  21. package/dist/concurrency.d.ts +12 -0
  22. package/dist/dispatchCapabilities.d.ts +34 -0
  23. package/dist/dispatchRouter.d.ts +20 -0
  24. package/dist/headlessLatestRerunScheduler.d.ts +19 -0
  25. package/dist/headlessTransport.d.ts +12 -0
  26. package/dist/index.js +1608 -296
  27. package/dist/protocol.d.ts +44 -33
  28. package/dist/providers/index.d.ts +79 -0
  29. package/dist/providers/playwright/compileLocator.d.ts +3 -0
  30. package/dist/providers/playwright/dispatchBrowserRpc.d.ts +13 -0
  31. package/dist/providers/playwright/expectUtils.d.ts +24 -0
  32. package/dist/providers/playwright/implementation.d.ts +2 -0
  33. package/dist/providers/playwright/index.d.ts +1 -0
  34. package/dist/providers/playwright/runtime.d.ts +5 -0
  35. package/dist/providers/playwright/textMatcher.d.ts +8 -0
  36. package/dist/rpcProtocol.d.ts +145 -0
  37. package/dist/runSession.d.ts +33 -0
  38. package/dist/sessionRegistry.d.ts +34 -0
  39. package/dist/sourceMap/sourceMapLoader.d.ts +14 -0
  40. package/dist/watchRerunPlanner.d.ts +21 -0
  41. package/package.json +16 -11
  42. package/src/AGENTS.md +128 -0
  43. package/src/augmentExpect.ts +62 -0
  44. package/src/browser.ts +3 -0
  45. package/src/browserRpcRegistry.ts +57 -0
  46. package/src/client/AGENTS.md +82 -0
  47. package/src/client/api.ts +213 -0
  48. package/src/client/browserRpc.ts +86 -0
  49. package/src/client/dispatchTransport.ts +178 -0
  50. package/src/client/entry.ts +109 -39
  51. package/src/client/locator.ts +452 -0
  52. package/src/client/snapshot.ts +32 -97
  53. package/src/client/sourceMapSupport.ts +26 -37
  54. package/src/concurrency.ts +62 -0
  55. package/src/dispatchCapabilities.ts +162 -0
  56. package/src/dispatchRouter.ts +82 -0
  57. package/src/env.d.ts +8 -1
  58. package/src/headlessLatestRerunScheduler.ts +76 -0
  59. package/src/headlessTransport.ts +28 -0
  60. package/src/hostController.ts +1292 -367
  61. package/src/protocol.ts +66 -31
  62. package/src/providers/index.ts +103 -0
  63. package/src/providers/playwright/compileLocator.ts +130 -0
  64. package/src/providers/playwright/dispatchBrowserRpc.ts +372 -0
  65. package/src/providers/playwright/expectUtils.ts +57 -0
  66. package/src/providers/playwright/implementation.ts +33 -0
  67. package/src/providers/playwright/index.ts +1 -0
  68. package/src/providers/playwright/runtime.ts +32 -0
  69. package/src/providers/playwright/textMatcher.ts +10 -0
  70. package/src/rpcProtocol.ts +220 -0
  71. package/src/runSession.ts +110 -0
  72. package/src/sessionRegistry.ts +89 -0
  73. package/src/sourceMap/sourceMapLoader.ts +96 -0
  74. package/src/watchRerunPlanner.ts +77 -0
  75. package/dist/browser-container/container-static/css/index.5a71c757.css +0 -1
  76. package/dist/browser-container/container-static/js/392.28f9a733.js.LICENSE.txt +0 -1
  77. package/dist/browser-container/container-static/js/lib-react.97ee79b0.js.LICENSE.txt +0 -1
  78. package/dist/browser-container/container-static/js/scheduler.6976de44.js +0 -411
  79. package/dist/browser-container/scheduler.html +0 -19
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ import open_editor from "open-editor";
7
7
  import { basename, dirname, join, normalize, relative, resolve as external_pathe_resolve } from "pathe";
8
8
  import sirv from "sirv";
9
9
  import { WebSocketServer } from "ws";
10
+ import node_os from "node:os";
11
+ import convert_source_map from "convert-source-map";
12
+ import { DISPATCH_RPC_BRIDGE_NAME, DISPATCH_MESSAGE_TYPE, DISPATCH_NAMESPACE_RUNNER } from "./361.js";
10
13
  __webpack_require__.add({
11
14
  "../../node_modules/.pnpm/picomatch@4.0.3/node_modules/picomatch/index.js" (module, __unused_rspack_exports, __webpack_require__) {
12
15
  const pico = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.3/node_modules/picomatch/lib/picomatch.js");
@@ -1417,15 +1420,32 @@ __webpack_require__.add({
1417
1420
  });
1418
1421
  const TYPE_REQUEST = "q";
1419
1422
  const TYPE_RESPONSE = "s";
1420
- const DEFAULT_TIMEOUT = 6e4;
1421
- function defaultSerialize(i) {
1422
- return i;
1423
+ function createPromiseWithResolvers() {
1424
+ let resolve;
1425
+ let reject;
1426
+ return {
1427
+ promise: new Promise((res, rej)=>{
1428
+ resolve = res;
1429
+ reject = rej;
1430
+ }),
1431
+ resolve,
1432
+ reject
1433
+ };
1423
1434
  }
1435
+ const random = Math.random.bind(Math);
1436
+ const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
1437
+ function nanoid(size = 21) {
1438
+ let id = "";
1439
+ let i = size;
1440
+ while(i--)id += urlAlphabet[64 * random() | 0];
1441
+ return id;
1442
+ }
1443
+ const DEFAULT_TIMEOUT = 6e4;
1444
+ const defaultSerialize = (i)=>i;
1424
1445
  const defaultDeserialize = defaultSerialize;
1425
1446
  const { clearTimeout: dist_clearTimeout, setTimeout: dist_setTimeout } = globalThis;
1426
- const random = Math.random.bind(Math);
1427
1447
  function createBirpc($functions, options) {
1428
- const { post, on, off = ()=>{}, eventNames = [], serialize = defaultSerialize, deserialize = defaultDeserialize, resolver, bind = "rpc", timeout = DEFAULT_TIMEOUT } = options;
1448
+ const { post, on, off = ()=>{}, eventNames = [], serialize = defaultSerialize, deserialize = defaultDeserialize, resolver, bind = "rpc", timeout = DEFAULT_TIMEOUT, proxify = true } = options;
1429
1449
  let $closed = false;
1430
1450
  const _rpcPromiseMap = /* @__PURE__ */ new Map();
1431
1451
  let _promiseInit;
@@ -1453,8 +1473,7 @@ function createBirpc($functions, options) {
1453
1473
  if (timeout >= 0) {
1454
1474
  timeoutId = dist_setTimeout(()=>{
1455
1475
  try {
1456
- const handleResult = options.onTimeoutError?.call(rpc, method, args);
1457
- if (true !== handleResult) throw new Error(`[birpc] timeout on calling "${method}"`);
1476
+ if (options.onTimeoutError?.call(rpc, method, args) !== true) throw new Error(`[birpc] timeout on calling "${method}"`);
1458
1477
  } catch (e) {
1459
1478
  reject(e);
1460
1479
  }
@@ -1483,15 +1502,11 @@ function createBirpc($functions, options) {
1483
1502
  }
1484
1503
  return promise;
1485
1504
  }
1486
- const $call = (method, ...args)=>_call(method, args, false);
1487
- const $callOptional = (method, ...args)=>_call(method, args, false, true);
1488
- const $callEvent = (method, ...args)=>_call(method, args, true);
1489
- const $callRaw = (options2)=>_call(options2.method, options2.args, options2.event, options2.optional);
1490
1505
  const builtinMethods = {
1491
- $call,
1492
- $callOptional,
1493
- $callEvent,
1494
- $callRaw,
1506
+ $call: (method, ...args)=>_call(method, args, false),
1507
+ $callOptional: (method, ...args)=>_call(method, args, false, true),
1508
+ $callEvent: (method, ...args)=>_call(method, args, true),
1509
+ $callRaw: (options$1)=>_call(options$1.method, options$1.args, options$1.event, options$1.optional),
1495
1510
  $rejectPendingCalls,
1496
1511
  get $closed () {
1497
1512
  return $closed;
@@ -1502,7 +1517,7 @@ function createBirpc($functions, options) {
1502
1517
  $close,
1503
1518
  $functions
1504
1519
  };
1505
- rpc = new Proxy({}, {
1520
+ rpc = proxify ? new Proxy({}, {
1506
1521
  get (_, method) {
1507
1522
  if (Object.prototype.hasOwnProperty.call(builtinMethods, method)) return builtinMethods[method];
1508
1523
  if ("then" === method && !eventNames.includes("then") && !("then" in $functions)) return;
@@ -1515,11 +1530,11 @@ function createBirpc($functions, options) {
1515
1530
  sendCall.asEvent = sendEvent;
1516
1531
  return sendCall;
1517
1532
  }
1518
- });
1533
+ }) : builtinMethods;
1519
1534
  function $close(customError) {
1520
1535
  $closed = true;
1521
1536
  _rpcPromiseMap.forEach(({ reject, method })=>{
1522
- const error = new Error(`[birpc] rpc is closed, cannot call "${method}"`);
1537
+ const error = /* @__PURE__ */ new Error(`[birpc] rpc is closed, cannot call "${method}"`);
1523
1538
  if (customError) {
1524
1539
  customError.cause ??= error;
1525
1540
  return reject(customError);
@@ -1530,9 +1545,8 @@ function createBirpc($functions, options) {
1530
1545
  off(onMessage);
1531
1546
  }
1532
1547
  function $rejectPendingCalls(handler) {
1533
- const entries = Array.from(_rpcPromiseMap.values());
1534
- const handlerResults = entries.map(({ method, reject })=>{
1535
- if (!handler) return reject(new Error(`[birpc]: rejected pending call "${method}".`));
1548
+ const handlerResults = Array.from(_rpcPromiseMap.values()).map(({ method, reject })=>{
1549
+ if (!handler) return reject(/* @__PURE__ */ new Error(`[birpc]: rejected pending call "${method}".`));
1536
1550
  return handler({
1537
1551
  method,
1538
1552
  reject
@@ -1559,9 +1573,8 @@ function createBirpc($functions, options) {
1559
1573
  } catch (e) {
1560
1574
  error = e;
1561
1575
  }
1562
- else error = new Error(`[birpc] function "${method}" not found`);
1576
+ else error = /* @__PURE__ */ new Error(`[birpc] function "${method}" not found`);
1563
1577
  if (msg.i) {
1564
- if (error && options.onError) options.onError.call(rpc, error, method, args);
1565
1578
  if (error && options.onFunctionError) {
1566
1579
  if (true === options.onFunctionError.call(rpc, error, method, args)) return;
1567
1580
  }
@@ -1600,26 +1613,909 @@ function createBirpc($functions, options) {
1600
1613
  _promiseInit = on(onMessage);
1601
1614
  return rpc;
1602
1615
  }
1603
- function createPromiseWithResolvers() {
1604
- let resolve;
1605
- let reject;
1606
- const promise = new Promise((res, rej)=>{
1607
- resolve = res;
1608
- reject = rej;
1616
+ const DEFAULT_MAX_HEADLESS_WORKERS = 12;
1617
+ const getNumCpus = ()=>node_os.availableParallelism?.() ?? node_os.cpus().length;
1618
+ const parseWorkers = (maxWorkers, numCpus = getNumCpus())=>{
1619
+ const parsed = Number.parseInt(maxWorkers.toString(), 10);
1620
+ if ('string' == typeof maxWorkers && maxWorkers.trim().endsWith('%')) {
1621
+ const workers = Math.floor(parsed / 100 * numCpus);
1622
+ return Math.max(workers, 1);
1623
+ }
1624
+ return parsed > 0 ? parsed : 1;
1625
+ };
1626
+ const resolveDefaultHeadlessWorkers = (command, numCpus = getNumCpus())=>{
1627
+ const baseWorkers = Math.max(Math.min(DEFAULT_MAX_HEADLESS_WORKERS, numCpus - 1), 1);
1628
+ return 'watch' === command ? Math.max(Math.floor(baseWorkers / 2), 1) : baseWorkers;
1629
+ };
1630
+ const getHeadlessConcurrency = (context, totalTests)=>{
1631
+ if (totalTests <= 0) return 1;
1632
+ const maxWorkers = context.normalizedConfig.pool.maxWorkers;
1633
+ if (void 0 !== maxWorkers) return Math.min(parseWorkers(maxWorkers), totalTests);
1634
+ return Math.min(resolveDefaultHeadlessWorkers(context.command), totalTests);
1635
+ };
1636
+ const toErrorMessage = (error)=>error instanceof Error ? error.message : String(error);
1637
+ class HostDispatchRouter {
1638
+ handlers = new Map();
1639
+ options;
1640
+ constructor(options){
1641
+ this.options = options ?? {};
1642
+ }
1643
+ register(namespace, handler) {
1644
+ this.handlers.set(namespace, handler);
1645
+ }
1646
+ unregister(namespace) {
1647
+ this.handlers.delete(namespace);
1648
+ }
1649
+ has(namespace) {
1650
+ return this.handlers.has(namespace);
1651
+ }
1652
+ async dispatch(request) {
1653
+ const runToken = request.runToken;
1654
+ if ('number' == typeof runToken && this.options.isRunTokenStale?.(runToken)) {
1655
+ this.options.onStale?.(request);
1656
+ return {
1657
+ requestId: request.requestId,
1658
+ runToken,
1659
+ stale: true
1660
+ };
1661
+ }
1662
+ const handler = this.handlers.get(request.namespace);
1663
+ if (!handler) return {
1664
+ requestId: request.requestId,
1665
+ runToken,
1666
+ error: `No dispatch handler registered for namespace "${request.namespace}"`
1667
+ };
1668
+ try {
1669
+ const result = await handler(request);
1670
+ return {
1671
+ requestId: request.requestId,
1672
+ runToken,
1673
+ result
1674
+ };
1675
+ } catch (error) {
1676
+ return {
1677
+ requestId: request.requestId,
1678
+ runToken,
1679
+ error: toErrorMessage(error)
1680
+ };
1681
+ }
1682
+ }
1683
+ }
1684
+ const toSnapshotRpcRequest = (request)=>{
1685
+ switch(request.method){
1686
+ case 'resolveSnapshotPath':
1687
+ return {
1688
+ id: request.requestId,
1689
+ method: 'resolveSnapshotPath',
1690
+ args: request.args
1691
+ };
1692
+ case 'readSnapshotFile':
1693
+ return {
1694
+ id: request.requestId,
1695
+ method: 'readSnapshotFile',
1696
+ args: request.args
1697
+ };
1698
+ case 'saveSnapshotFile':
1699
+ return {
1700
+ id: request.requestId,
1701
+ method: 'saveSnapshotFile',
1702
+ args: request.args
1703
+ };
1704
+ case 'removeSnapshotFile':
1705
+ return {
1706
+ id: request.requestId,
1707
+ method: 'removeSnapshotFile',
1708
+ args: request.args
1709
+ };
1710
+ default:
1711
+ return null;
1712
+ }
1713
+ };
1714
+ const createHostDispatchRouter = ({ routerOptions, runnerCallbacks, runSnapshotRpc, extensionHandlers, onDuplicateNamespace })=>{
1715
+ const router = new HostDispatchRouter(routerOptions);
1716
+ router.register('runner', async (request)=>{
1717
+ switch(request.method){
1718
+ case 'file-start':
1719
+ await runnerCallbacks.onTestFileStart(request.args);
1720
+ break;
1721
+ case 'file-ready':
1722
+ await runnerCallbacks.onTestFileReady(request.args);
1723
+ break;
1724
+ case 'suite-start':
1725
+ await runnerCallbacks.onTestSuiteStart(request.args);
1726
+ break;
1727
+ case 'suite-result':
1728
+ await runnerCallbacks.onTestSuiteResult(request.args);
1729
+ break;
1730
+ case 'case-start':
1731
+ await runnerCallbacks.onTestCaseStart(request.args);
1732
+ break;
1733
+ case 'case-result':
1734
+ await runnerCallbacks.onTestCaseResult(request.args);
1735
+ break;
1736
+ case 'file-complete':
1737
+ await runnerCallbacks.onTestFileComplete(request.args);
1738
+ break;
1739
+ case 'log':
1740
+ await runnerCallbacks.onLog(request.args);
1741
+ break;
1742
+ case 'fatal':
1743
+ await runnerCallbacks.onFatal(request.args);
1744
+ break;
1745
+ default:
1746
+ break;
1747
+ }
1748
+ });
1749
+ router.register('snapshot', async (request)=>{
1750
+ const snapshotRequest = toSnapshotRpcRequest(request);
1751
+ if (!snapshotRequest) return;
1752
+ return runSnapshotRpc(snapshotRequest);
1609
1753
  });
1754
+ for (const [namespace, handler] of extensionHandlers ?? []){
1755
+ if (router.has(namespace)) {
1756
+ onDuplicateNamespace?.(namespace);
1757
+ continue;
1758
+ }
1759
+ router.register(namespace, handler);
1760
+ }
1761
+ return router;
1762
+ };
1763
+ const createHeadlessLatestRerunScheduler = (options)=>{
1764
+ let pendingFiles = null;
1765
+ let draining = null;
1766
+ let latestEnqueueVersion = 0;
1767
+ const runDrainLoop = async ()=>{
1768
+ while(pendingFiles){
1769
+ const nextFiles = pendingFiles;
1770
+ pendingFiles = null;
1771
+ try {
1772
+ await options.runFiles(nextFiles);
1773
+ } catch (error) {
1774
+ try {
1775
+ await options.onError?.(error);
1776
+ } catch {}
1777
+ }
1778
+ }
1779
+ };
1780
+ const ensureDrainLoop = ()=>{
1781
+ if (draining) return;
1782
+ draining = runDrainLoop().finally(()=>{
1783
+ draining = null;
1784
+ });
1785
+ };
1610
1786
  return {
1611
- promise,
1612
- resolve,
1613
- reject
1787
+ async enqueueLatest (files) {
1788
+ const enqueueVersion = ++latestEnqueueVersion;
1789
+ const activeRun = options.getActiveRun();
1790
+ if (activeRun && !options.isRunCancelled(activeRun)) {
1791
+ options.onInterrupt?.(activeRun);
1792
+ options.invalidateActiveRun();
1793
+ await options.interruptActiveRun(activeRun);
1794
+ }
1795
+ if (enqueueVersion !== latestEnqueueVersion) return;
1796
+ pendingFiles = files;
1797
+ ensureDrainLoop();
1798
+ },
1799
+ async whenIdle () {
1800
+ await draining;
1801
+ }
1802
+ };
1803
+ };
1804
+ const attachHeadlessRunnerTransport = async (page, handlers)=>{
1805
+ await page.exposeFunction(DISPATCH_MESSAGE_TYPE, handlers.onDispatchMessage);
1806
+ await page.exposeFunction(DISPATCH_RPC_BRIDGE_NAME, handlers.onDispatchRpc);
1807
+ };
1808
+ const isRecord = (value)=>'object' == typeof value && null !== value;
1809
+ const readString = (value, key, label)=>{
1810
+ const result = value[key];
1811
+ if ('string' != typeof result) throw new Error(`Invalid browser RPC request: ${label} must be a string`);
1812
+ return result;
1813
+ };
1814
+ const readUnknownArray = (value, key, label)=>{
1815
+ const result = value[key];
1816
+ if (!Array.isArray(result)) throw new Error(`Invalid browser RPC request: ${label} must be an array`);
1817
+ return result;
1818
+ };
1819
+ const parseBrowserLocatorIR = (value, label)=>{
1820
+ if (!isRecord(value)) throw new Error(`Invalid browser RPC request: ${label} must be an object`);
1821
+ const steps = value.steps;
1822
+ if (!Array.isArray(steps)) throw new Error(`Invalid browser RPC request: ${label}.steps must be an array`);
1823
+ return value;
1824
+ };
1825
+ const validateBrowserRpcRequest = (payload)=>{
1826
+ if (!isRecord(payload)) throw new Error('Invalid browser RPC request: payload must be an object');
1827
+ const kind = readString(payload, 'kind', 'kind');
1828
+ if ('locator' !== kind && 'expect' !== kind && 'config' !== kind) throw new Error(`Invalid browser RPC request: unsupported kind ${JSON.stringify(kind)}`);
1829
+ const request = {
1830
+ id: readString(payload, 'id', 'id'),
1831
+ testPath: readString(payload, 'testPath', 'testPath'),
1832
+ runId: readString(payload, 'runId', 'runId'),
1833
+ kind,
1834
+ locator: parseBrowserLocatorIR(payload.locator, 'locator'),
1835
+ method: readString(payload, 'method', 'method'),
1836
+ args: readUnknownArray(payload, 'args', 'args')
1614
1837
  };
1838
+ const isNot = payload.isNot;
1839
+ if (void 0 !== isNot) {
1840
+ if ('boolean' != typeof isNot) throw new Error('Invalid browser RPC request: isNot must be a boolean');
1841
+ request.isNot = isNot;
1842
+ }
1843
+ const timeout = payload.timeout;
1844
+ if (void 0 !== timeout) {
1845
+ if ('number' != typeof timeout) throw new Error('Invalid browser RPC request: timeout must be a number');
1846
+ request.timeout = timeout;
1847
+ }
1848
+ return request;
1849
+ };
1850
+ const supportedLocatorActions = new Set([
1851
+ 'click',
1852
+ 'dblclick',
1853
+ 'fill',
1854
+ 'hover',
1855
+ 'press',
1856
+ 'clear',
1857
+ 'check',
1858
+ 'uncheck',
1859
+ 'focus',
1860
+ 'blur',
1861
+ 'scrollIntoViewIfNeeded',
1862
+ 'waitFor',
1863
+ 'dispatchEvent',
1864
+ 'selectOption',
1865
+ 'setInputFiles'
1866
+ ]);
1867
+ const supportedExpectElementMatchers = new Set([
1868
+ 'toBeVisible',
1869
+ 'toBeHidden',
1870
+ 'toBeEnabled',
1871
+ 'toBeDisabled',
1872
+ 'toBeAttached',
1873
+ 'toBeDetached',
1874
+ 'toBeEditable',
1875
+ 'toBeFocused',
1876
+ 'toBeEmpty',
1877
+ 'toBeInViewport',
1878
+ 'toHaveText',
1879
+ 'toContainText',
1880
+ 'toHaveValue',
1881
+ 'toHaveAttribute',
1882
+ 'toHaveClass',
1883
+ 'toHaveCount',
1884
+ 'toBeChecked',
1885
+ 'toBeUnchecked',
1886
+ 'toHaveId',
1887
+ 'toHaveCSS',
1888
+ 'toHaveJSProperty'
1889
+ ]);
1890
+ const reviveBrowserLocatorText = (text)=>{
1891
+ if ('string' === text.type) return text.value;
1892
+ return new RegExp(text.source, text.flags);
1893
+ };
1894
+ const compilePlaywrightLocator = (frame, locatorIR)=>{
1895
+ const compileFromFrame = (ir)=>{
1896
+ let current = frame;
1897
+ const ensureLocator = ()=>{
1898
+ if (current.filter) return current;
1899
+ current = current.locator(':root');
1900
+ return current;
1901
+ };
1902
+ for (const step of ir.steps)switch(step.type){
1903
+ case 'getByRole':
1904
+ {
1905
+ const name = step.options?.name ? reviveBrowserLocatorText(step.options.name) : void 0;
1906
+ const options = step.options ? {
1907
+ ...step.options,
1908
+ name
1909
+ } : void 0;
1910
+ current = current.getByRole(step.role, options);
1911
+ break;
1912
+ }
1913
+ case 'locator':
1914
+ current = current.locator(step.selector);
1915
+ break;
1916
+ case 'getByText':
1917
+ current = current.getByText(reviveBrowserLocatorText(step.text), step.options);
1918
+ break;
1919
+ case 'getByLabel':
1920
+ current = current.getByLabel(reviveBrowserLocatorText(step.text), step.options);
1921
+ break;
1922
+ case 'getByPlaceholder':
1923
+ current = current.getByPlaceholder(reviveBrowserLocatorText(step.text), step.options);
1924
+ break;
1925
+ case 'getByAltText':
1926
+ current = current.getByAltText(reviveBrowserLocatorText(step.text), step.options);
1927
+ break;
1928
+ case 'getByTitle':
1929
+ current = current.getByTitle(reviveBrowserLocatorText(step.text), step.options);
1930
+ break;
1931
+ case 'getByTestId':
1932
+ current = current.getByTestId(reviveBrowserLocatorText(step.text));
1933
+ break;
1934
+ case 'filter':
1935
+ {
1936
+ const locator = ensureLocator();
1937
+ const options = {};
1938
+ if (step.options?.hasText) options.hasText = reviveBrowserLocatorText(step.options.hasText);
1939
+ if (step.options?.hasNotText) options.hasNotText = reviveBrowserLocatorText(step.options.hasNotText);
1940
+ if (step.options?.has) options.has = compileFromFrame(step.options.has);
1941
+ if (step.options?.hasNot) options.hasNot = compileFromFrame(step.options.hasNot);
1942
+ current = locator.filter(options);
1943
+ break;
1944
+ }
1945
+ case 'and':
1946
+ {
1947
+ const locator = ensureLocator();
1948
+ const other = compileFromFrame(step.locator);
1949
+ current = locator.and(other);
1950
+ break;
1951
+ }
1952
+ case 'or':
1953
+ {
1954
+ const locator = ensureLocator();
1955
+ const other = compileFromFrame(step.locator);
1956
+ current = locator.or(other);
1957
+ break;
1958
+ }
1959
+ case 'nth':
1960
+ {
1961
+ const locator = ensureLocator();
1962
+ current = locator.nth(step.index);
1963
+ break;
1964
+ }
1965
+ case 'first':
1966
+ {
1967
+ const locator = ensureLocator();
1968
+ current = locator.first();
1969
+ break;
1970
+ }
1971
+ case 'last':
1972
+ {
1973
+ const locator = ensureLocator();
1974
+ current = locator.last();
1975
+ break;
1976
+ }
1977
+ default:
1978
+ throw new Error(`Unknown locator step: ${String(step?.type)}`);
1979
+ }
1980
+ return ensureLocator();
1981
+ };
1982
+ return compileFromFrame(locatorIR);
1983
+ };
1984
+ const serializeExpectedText = (text, options)=>{
1985
+ const base = {
1986
+ matchSubstring: options?.matchSubstring,
1987
+ ignoreCase: options?.ignoreCase,
1988
+ normalizeWhiteSpace: options?.normalizeWhiteSpace
1989
+ };
1990
+ if ('string' === text.type) return [
1991
+ {
1992
+ ...base,
1993
+ string: text.value
1994
+ }
1995
+ ];
1996
+ return [
1997
+ {
1998
+ ...base,
1999
+ regexSource: text.source,
2000
+ regexFlags: text.flags
2001
+ }
2002
+ ];
2003
+ };
2004
+ const formatExpectError = (result)=>{
2005
+ const parts = [];
2006
+ if (result.errorMessage) parts.push(result.errorMessage);
2007
+ if (result.log?.length) {
2008
+ parts.push('Call log:');
2009
+ parts.push(...result.log.map((l)=>`- ${l}`));
2010
+ }
2011
+ return parts.join('\n');
2012
+ };
2013
+ const escapeCssAttrValue = (value)=>value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
2014
+ const getRunnerFrame = async (containerPage, testPath, timeoutMs)=>{
2015
+ const selector = `iframe[data-test-file='${escapeCssAttrValue(testPath)}']`;
2016
+ const iframe = containerPage.locator(selector);
2017
+ const count = await iframe.count();
2018
+ if (0 === count) {
2019
+ const known = await containerPage.locator('iframe[data-test-file]').evaluateAll((nodes)=>nodes.map((n)=>n.dataset.testFile));
2020
+ throw new Error(`Runner iframe not found for testPath: ${JSON.stringify(testPath)}. Known iframes: ${JSON.stringify(known)}. Timeout: ${timeoutMs}ms`);
2021
+ }
2022
+ return containerPage.frameLocator(selector);
2023
+ };
2024
+ const callExpect = async (locator, expectMethod, options, fallbackMessage)=>{
2025
+ const result = await locator._expect(expectMethod, options);
2026
+ if (!options.isNot !== result.matches) throw new Error(formatExpectError(result) || fallbackMessage);
2027
+ return null;
2028
+ };
2029
+ const assertSerializedText = (value, matcherName)=>{
2030
+ const t = value;
2031
+ if (!t || 'string' !== t.type && 'regexp' !== t.type) throw new Error(`${matcherName} expects a serialized text matcher`);
2032
+ return t;
2033
+ };
2034
+ const assertStringArg = (value, matcherName, label)=>{
2035
+ if ('string' != typeof value || !value) throw new Error(`${matcherName} expects ${label}`);
2036
+ return value;
2037
+ };
2038
+ const simpleMatchers = {
2039
+ toBeVisible: 'to.be.visible',
2040
+ toBeHidden: 'to.be.hidden',
2041
+ toBeEnabled: 'to.be.enabled',
2042
+ toBeDisabled: 'to.be.disabled',
2043
+ toBeAttached: 'to.be.attached',
2044
+ toBeDetached: 'to.be.detached',
2045
+ toBeEditable: 'to.be.editable',
2046
+ toBeFocused: 'to.be.focused',
2047
+ toBeEmpty: 'to.be.empty'
2048
+ };
2049
+ const textMatchers = {
2050
+ toHaveId: {
2051
+ expectMethod: 'to.have.id'
2052
+ },
2053
+ toHaveText: {
2054
+ expectMethod: 'to.have.text',
2055
+ textOptions: {
2056
+ normalizeWhiteSpace: true
2057
+ }
2058
+ },
2059
+ toContainText: {
2060
+ expectMethod: 'to.have.text',
2061
+ textOptions: {
2062
+ matchSubstring: true,
2063
+ normalizeWhiteSpace: true
2064
+ }
2065
+ },
2066
+ toHaveValue: {
2067
+ expectMethod: 'to.have.value'
2068
+ },
2069
+ toHaveClass: {
2070
+ expectMethod: 'to.have.class'
2071
+ }
2072
+ };
2073
+ const dispatchExpectMatcher = (locator, request, isNot, timeout)=>{
2074
+ const { method, args } = request;
2075
+ const simpleExpect = simpleMatchers[method];
2076
+ if (simpleExpect) return callExpect(locator, simpleExpect, {
2077
+ isNot,
2078
+ timeout
2079
+ }, `Expected element ${method.replace('toBe', 'to be ').replace(/([A-Z])/g, ' $1').trim().toLowerCase()}`);
2080
+ const textDef = textMatchers[method];
2081
+ if (textDef) {
2082
+ const expected = assertSerializedText(args[0], method);
2083
+ return callExpect(locator, textDef.expectMethod, {
2084
+ isNot,
2085
+ timeout,
2086
+ expectedText: serializeExpectedText(expected, textDef.textOptions)
2087
+ }, `Expected element ${method}`);
2088
+ }
2089
+ switch(method){
2090
+ case 'toBeInViewport':
2091
+ {
2092
+ const ratio = args[0];
2093
+ if (void 0 !== ratio && 'number' != typeof ratio) throw new Error(`toBeInViewport expects ratio to be a number, got ${typeof ratio}`);
2094
+ return callExpect(locator, 'to.be.in.viewport', {
2095
+ isNot,
2096
+ timeout,
2097
+ expectedNumber: ratio
2098
+ }, 'Expected element to be in viewport');
2099
+ }
2100
+ case 'toBeChecked':
2101
+ return callExpect(locator, 'to.be.checked', {
2102
+ isNot,
2103
+ timeout,
2104
+ expectedValue: {
2105
+ checked: true
2106
+ }
2107
+ }, 'Expected element to be checked');
2108
+ case 'toBeUnchecked':
2109
+ return callExpect(locator, 'to.be.checked', {
2110
+ isNot,
2111
+ timeout,
2112
+ expectedValue: {
2113
+ checked: false
2114
+ }
2115
+ }, 'Expected element to be unchecked');
2116
+ case 'toHaveCount':
2117
+ {
2118
+ const expected = args[0];
2119
+ if ('number' != typeof expected) throw new Error(`toHaveCount expects a number, got ${typeof expected}`);
2120
+ return callExpect(locator, 'to.have.count', {
2121
+ isNot,
2122
+ timeout,
2123
+ expectedNumber: expected
2124
+ }, `Expected count ${expected}`);
2125
+ }
2126
+ case 'toHaveAttribute':
2127
+ {
2128
+ const name = assertStringArg(args[0], 'toHaveAttribute', 'an attribute name');
2129
+ if (args.length < 2) return callExpect(locator, 'to.have.attribute', {
2130
+ isNot,
2131
+ timeout,
2132
+ expressionArg: name
2133
+ }, `Expected attribute ${name} to be present`);
2134
+ const expected = assertSerializedText(args[1], 'toHaveAttribute');
2135
+ return callExpect(locator, 'to.have.attribute.value', {
2136
+ isNot,
2137
+ timeout,
2138
+ expressionArg: name,
2139
+ expectedText: serializeExpectedText(expected)
2140
+ }, `Expected attribute ${name} to match`);
2141
+ }
2142
+ case 'toHaveCSS':
2143
+ {
2144
+ const name = assertStringArg(args[0], 'toHaveCSS', 'a CSS property name');
2145
+ const expected = assertSerializedText(args[1], 'toHaveCSS');
2146
+ return callExpect(locator, 'to.have.css', {
2147
+ isNot,
2148
+ timeout,
2149
+ expressionArg: name,
2150
+ expectedText: serializeExpectedText(expected)
2151
+ }, `Expected CSS ${name} to match`);
2152
+ }
2153
+ case 'toHaveJSProperty':
2154
+ {
2155
+ const name = assertStringArg(args[0], 'toHaveJSProperty', 'a property name');
2156
+ const expectedValue = args[1];
2157
+ try {
2158
+ JSON.stringify(expectedValue);
2159
+ } catch {
2160
+ throw new Error('toHaveJSProperty expects a JSON-serializable expected value');
2161
+ }
2162
+ return callExpect(locator, 'to.have.property', {
2163
+ isNot,
2164
+ timeout,
2165
+ expressionArg: name,
2166
+ expectedValue
2167
+ }, `Expected JS property ${name} to match`);
2168
+ }
2169
+ }
2170
+ throw new Error(`Unhandled expect matcher: ${method}`);
2171
+ };
2172
+ const dispatchConfigMethod = async (request)=>{
2173
+ switch(request.method){
2174
+ case 'setTestIdAttribute':
2175
+ {
2176
+ const attr = request.args[0];
2177
+ if ('string' != typeof attr || !attr) throw new Error('setTestIdAttribute expects a non-empty string argument');
2178
+ const playwright = await import("playwright");
2179
+ playwright.selectors.setTestIdAttribute(attr);
2180
+ return null;
2181
+ }
2182
+ default:
2183
+ throw new Error(`Unknown config method: ${request.method}`);
2184
+ }
2185
+ };
2186
+ async function dispatchPlaywrightBrowserRpc({ containerPage, runnerPage, request, timeoutFallbackMs }) {
2187
+ if ('config' === request.kind) return dispatchConfigMethod(request);
2188
+ const testPath = request.testPath;
2189
+ if (!testPath) throw new Error('Browser RPC request is missing testPath');
2190
+ const timeout = 'number' == typeof request.timeout ? request.timeout : timeoutFallbackMs;
2191
+ const locatorRoot = runnerPage ? runnerPage : await getRunnerFrame(containerPage ?? (()=>{
2192
+ throw new Error('Browser container page is not initialized');
2193
+ })(), testPath, timeout);
2194
+ const locator = compilePlaywrightLocator(locatorRoot, request.locator);
2195
+ if ('locator' === request.kind) {
2196
+ if (!supportedLocatorActions.has(request.method)) throw new Error(`Locator method not supported: ${request.method}`);
2197
+ const target = locator;
2198
+ return await target[request.method](...request.args);
2199
+ }
2200
+ if ('expect' === request.kind) {
2201
+ if (!supportedExpectElementMatchers.has(request.method)) throw new Error(`Expect matcher not supported: ${request.method}`);
2202
+ return dispatchExpectMatcher(locator, request, !!request.isNot, timeout);
2203
+ }
2204
+ throw new Error(`Unknown browser rpc kind: ${request.kind}`);
1615
2205
  }
1616
- const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
1617
- function nanoid(size = 21) {
1618
- let id = "";
1619
- let i = size;
1620
- while(i--)id += urlAlphabet[64 * random() | 0];
1621
- return id;
2206
+ async function launchPlaywrightBrowser({ browserName, headless }) {
2207
+ const playwright = await import("playwright");
2208
+ const browserType = playwright[browserName];
2209
+ const browser = await browserType.launch({
2210
+ headless,
2211
+ args: 'chromium' === browserName ? [
2212
+ '--disable-popup-blocking',
2213
+ '--no-first-run',
2214
+ '--no-default-browser-check'
2215
+ ] : void 0
2216
+ });
2217
+ return {
2218
+ browser: browser
2219
+ };
2220
+ }
2221
+ const playwrightProviderImplementation = {
2222
+ name: 'playwright',
2223
+ async launchRuntime ({ browserName, headless }) {
2224
+ return launchPlaywrightBrowser({
2225
+ browserName,
2226
+ headless
2227
+ });
2228
+ },
2229
+ async dispatchRpc ({ containerPage, runnerPage, request, timeoutFallbackMs }) {
2230
+ return dispatchPlaywrightBrowserRpc({
2231
+ containerPage: containerPage,
2232
+ runnerPage: runnerPage,
2233
+ request,
2234
+ timeoutFallbackMs
2235
+ });
2236
+ }
2237
+ };
2238
+ const providerImplementations = {
2239
+ playwright: playwrightProviderImplementation
2240
+ };
2241
+ function getBrowserProviderImplementation(provider) {
2242
+ const implementation = providerImplementations[provider];
2243
+ if (!implementation) throw new Error(`Unsupported browser provider: ${String(provider)}`);
2244
+ return implementation;
1622
2245
  }
2246
+ const createCancelSignal = ()=>{
2247
+ let settled = false;
2248
+ let resolveSignal = ()=>{};
2249
+ const signal = new Promise((resolve)=>{
2250
+ resolveSignal = ()=>{
2251
+ if (!settled) {
2252
+ settled = true;
2253
+ resolve();
2254
+ }
2255
+ };
2256
+ });
2257
+ return {
2258
+ signal,
2259
+ resolve: resolveSignal
2260
+ };
2261
+ };
2262
+ const createRunSession = (token)=>{
2263
+ const { signal, resolve } = createCancelSignal();
2264
+ return {
2265
+ token,
2266
+ cancelled: false,
2267
+ cancelSignal: signal,
2268
+ signalCancel: resolve
2269
+ };
2270
+ };
2271
+ class RunSessionLifecycle {
2272
+ currentToken = 0;
2273
+ active = null;
2274
+ get activeSession() {
2275
+ return this.active;
2276
+ }
2277
+ get activeToken() {
2278
+ return this.currentToken;
2279
+ }
2280
+ createSession(factory) {
2281
+ const session = factory(++this.currentToken);
2282
+ this.active = session;
2283
+ return session;
2284
+ }
2285
+ isTokenActive(token) {
2286
+ return token === this.currentToken;
2287
+ }
2288
+ isTokenStale(token) {
2289
+ return !this.isTokenActive(token);
2290
+ }
2291
+ invalidateActiveToken() {
2292
+ this.currentToken += 1;
2293
+ return this.currentToken;
2294
+ }
2295
+ clearIfActive(session) {
2296
+ if (this.active === session) this.active = null;
2297
+ }
2298
+ async cancel(session, options) {
2299
+ const waitForDone = options?.waitForDone ?? true;
2300
+ if (!session.cancelled) {
2301
+ session.cancelled = true;
2302
+ session.signalCancel();
2303
+ await options?.onCancel?.(session);
2304
+ }
2305
+ if (waitForDone) await session.done?.catch(()=>{});
2306
+ }
2307
+ async cancelActive(options) {
2308
+ if (!this.active) return;
2309
+ await this.cancel(this.active, options);
2310
+ }
2311
+ }
2312
+ class RunnerSessionRegistry {
2313
+ nextId = 0;
2314
+ sessionsById = new Map();
2315
+ sessionIdByTestFile = new Map();
2316
+ register(input) {
2317
+ const id = input.id ?? `runner-session-${++this.nextId}`;
2318
+ const createdAt = input.createdAt ?? Date.now();
2319
+ const record = {
2320
+ ...input,
2321
+ id,
2322
+ createdAt
2323
+ };
2324
+ this.sessionsById.set(id, record);
2325
+ this.sessionIdByTestFile.set(record.testFile, id);
2326
+ return record;
2327
+ }
2328
+ getById(id) {
2329
+ return this.sessionsById.get(id);
2330
+ }
2331
+ getByTestFile(testFile) {
2332
+ const id = this.sessionIdByTestFile.get(testFile);
2333
+ if (!id) return;
2334
+ return this.sessionsById.get(id);
2335
+ }
2336
+ list() {
2337
+ return Array.from(this.sessionsById.values());
2338
+ }
2339
+ listByRunToken(runToken) {
2340
+ return this.list().filter((session)=>session.runToken === runToken);
2341
+ }
2342
+ deleteById(id) {
2343
+ const record = this.sessionsById.get(id);
2344
+ if (!record) return false;
2345
+ this.sessionsById.delete(id);
2346
+ if (this.sessionIdByTestFile.get(record.testFile) === id) this.sessionIdByTestFile.delete(record.testFile);
2347
+ return true;
2348
+ }
2349
+ deleteByTestFile(testFile) {
2350
+ const id = this.sessionIdByTestFile.get(testFile);
2351
+ if (!id) return false;
2352
+ return this.deleteById(id);
2353
+ }
2354
+ clear() {
2355
+ this.sessionsById.clear();
2356
+ this.sessionIdByTestFile.clear();
2357
+ }
2358
+ }
2359
+ const normalizeJavaScriptUrl = (value, options)=>{
2360
+ try {
2361
+ const url = options?.origin ? new URL(value, options.origin) : new URL(value);
2362
+ if ('http:' !== url.protocol && 'https:' !== url.protocol) return null;
2363
+ url.search = '';
2364
+ url.hash = '';
2365
+ return url.toString();
2366
+ } catch {
2367
+ return null;
2368
+ }
2369
+ };
2370
+ const resolveInlineSourceMap = (code)=>{
2371
+ const converter = convert_source_map.fromSource(code);
2372
+ if (!converter) return null;
2373
+ return converter.toObject();
2374
+ };
2375
+ const fetchSourceMap = async (jsUrl, fetcher)=>{
2376
+ const jsResponse = await fetcher(jsUrl);
2377
+ if (!jsResponse.ok) return null;
2378
+ const code = await jsResponse.text();
2379
+ const inlineMap = resolveInlineSourceMap(code);
2380
+ if (inlineMap) return inlineMap;
2381
+ const mapResponse = await fetcher(`${jsUrl}.map`);
2382
+ if (!mapResponse.ok) return null;
2383
+ return await mapResponse.json();
2384
+ };
2385
+ const loadSourceMapWithCache = async ({ jsUrl, cache, force = false, origin, fetcher = fetch })=>{
2386
+ const normalizedUrl = normalizeJavaScriptUrl(jsUrl, {
2387
+ origin
2388
+ });
2389
+ if (!normalizedUrl) return null;
2390
+ if (!force && cache.has(normalizedUrl)) return cache.get(normalizedUrl) ?? null;
2391
+ try {
2392
+ const sourceMap = await fetchSourceMap(normalizedUrl, fetcher);
2393
+ cache.set(normalizedUrl, sourceMap);
2394
+ return sourceMap;
2395
+ } catch {
2396
+ cache.set(normalizedUrl, null);
2397
+ return null;
2398
+ }
2399
+ };
2400
+ const BROWSER_VIEWPORT_PRESET_IDS = [
2401
+ 'iPhoneSE',
2402
+ 'iPhoneXR',
2403
+ 'iPhone12Pro',
2404
+ 'iPhone14ProMax',
2405
+ 'Pixel7',
2406
+ 'SamsungGalaxyS8Plus',
2407
+ 'SamsungGalaxyS20Ultra',
2408
+ 'iPadMini',
2409
+ 'iPadAir',
2410
+ 'iPadPro',
2411
+ 'SurfacePro7',
2412
+ 'SurfaceDuo',
2413
+ 'GalaxyZFold5',
2414
+ 'AsusZenbookFold',
2415
+ 'SamsungGalaxyA51A71',
2416
+ 'NestHub',
2417
+ 'NestHubMax'
2418
+ ];
2419
+ const BROWSER_VIEWPORT_PRESET_DIMENSIONS = {
2420
+ iPhoneSE: {
2421
+ width: 375,
2422
+ height: 667
2423
+ },
2424
+ iPhoneXR: {
2425
+ width: 414,
2426
+ height: 896
2427
+ },
2428
+ iPhone12Pro: {
2429
+ width: 390,
2430
+ height: 844
2431
+ },
2432
+ iPhone14ProMax: {
2433
+ width: 430,
2434
+ height: 932
2435
+ },
2436
+ Pixel7: {
2437
+ width: 412,
2438
+ height: 915
2439
+ },
2440
+ SamsungGalaxyS8Plus: {
2441
+ width: 360,
2442
+ height: 740
2443
+ },
2444
+ SamsungGalaxyS20Ultra: {
2445
+ width: 412,
2446
+ height: 915
2447
+ },
2448
+ iPadMini: {
2449
+ width: 768,
2450
+ height: 1024
2451
+ },
2452
+ iPadAir: {
2453
+ width: 820,
2454
+ height: 1180
2455
+ },
2456
+ iPadPro: {
2457
+ width: 1024,
2458
+ height: 1366
2459
+ },
2460
+ SurfacePro7: {
2461
+ width: 912,
2462
+ height: 1368
2463
+ },
2464
+ SurfaceDuo: {
2465
+ width: 540,
2466
+ height: 720
2467
+ },
2468
+ GalaxyZFold5: {
2469
+ width: 344,
2470
+ height: 882
2471
+ },
2472
+ AsusZenbookFold: {
2473
+ width: 853,
2474
+ height: 1280
2475
+ },
2476
+ SamsungGalaxyA51A71: {
2477
+ width: 412,
2478
+ height: 914
2479
+ },
2480
+ NestHub: {
2481
+ width: 1024,
2482
+ height: 600
2483
+ },
2484
+ NestHubMax: {
2485
+ width: 1280,
2486
+ height: 800
2487
+ }
2488
+ };
2489
+ const resolveBrowserViewportPreset = (presetId)=>{
2490
+ const size = BROWSER_VIEWPORT_PRESET_DIMENSIONS[presetId];
2491
+ return size ?? null;
2492
+ };
2493
+ const serializeTestFiles = (files)=>JSON.stringify(files.map((f)=>`${f.projectName}:${f.testPath}`).sort());
2494
+ const normalizeTestFiles = (files)=>files.map((file)=>({
2495
+ ...file,
2496
+ testPath: normalize(file.testPath)
2497
+ }));
2498
+ const collectWatchTestFiles = (projectEntries)=>projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
2499
+ testPath: normalize(testPath),
2500
+ projectName: entry.project.name
2501
+ })));
2502
+ const planWatchRerun = ({ projectEntries, previousTestFiles, affectedTestFiles })=>{
2503
+ const currentTestFiles = collectWatchTestFiles(projectEntries);
2504
+ const normalizedPrevious = normalizeTestFiles(previousTestFiles);
2505
+ const filesChanged = serializeTestFiles(currentTestFiles) !== serializeTestFiles(normalizedPrevious);
2506
+ const normalizedAffectedTestFiles = affectedTestFiles.map((testFile)=>normalize(testFile));
2507
+ const currentFileMap = new Map(currentTestFiles.map((file)=>[
2508
+ file.testPath,
2509
+ file
2510
+ ]));
2511
+ const matchedAffectedFiles = normalizedAffectedTestFiles.map((testFile)=>currentFileMap.get(testFile)).filter((file)=>Boolean(file));
2512
+ return {
2513
+ currentTestFiles,
2514
+ filesChanged,
2515
+ normalizedAffectedTestFiles,
2516
+ affectedTestFiles: matchedAffectedFiles
2517
+ };
2518
+ };
1623
2519
  const picomatch = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.3/node_modules/picomatch/index.js");
1624
2520
  const { createRsbuild: createRsbuild, rspack: rspack } = rsbuild;
1625
2521
  const hostController_dirname = dirname(fileURLToPath(import.meta.url));
@@ -1695,6 +2591,23 @@ const watchContext = {
1695
2591
  chunkHashes: new Map(),
1696
2592
  affectedTestFiles: []
1697
2593
  };
2594
+ const resolveViewport = (viewport)=>{
2595
+ if (!viewport) return null;
2596
+ if ('string' == typeof viewport) return resolveBrowserViewportPreset(viewport);
2597
+ if ('number' == typeof viewport.width && Number.isFinite(viewport.width) && viewport.width > 0 && 'number' == typeof viewport.height && Number.isFinite(viewport.height) && viewport.height > 0) return {
2598
+ width: viewport.width,
2599
+ height: viewport.height
2600
+ };
2601
+ return null;
2602
+ };
2603
+ const mapViewportByProject = (projects)=>{
2604
+ const map = new Map();
2605
+ for (const project of projects){
2606
+ const viewport = resolveViewport(project.viewport);
2607
+ if (viewport) map.set(project.name, viewport);
2608
+ }
2609
+ return map;
2610
+ };
1698
2611
  const ensureProcessExitCode = (code)=>{
1699
2612
  if (void 0 === process.exitCode || 0 === process.exitCode) process.exitCode = code;
1700
2613
  };
@@ -1806,6 +2719,31 @@ const getRuntimeConfigFromProject = (project)=>{
1806
2719
  };
1807
2720
  };
1808
2721
  const getBrowserProjects = (context)=>context.projects.filter((project)=>project.normalizedConfig.browser.enabled);
2722
+ const getBrowserLaunchOptions = (project)=>({
2723
+ provider: project.normalizedConfig.browser.provider,
2724
+ browser: project.normalizedConfig.browser.browser,
2725
+ headless: project.normalizedConfig.browser.headless,
2726
+ port: project.normalizedConfig.browser.port,
2727
+ strictPort: project.normalizedConfig.browser.strictPort
2728
+ });
2729
+ const ensureConsistentBrowserLaunchOptions = (projects)=>{
2730
+ if (0 === projects.length) throw new Error('No browser-enabled projects found.');
2731
+ const firstProject = projects[0];
2732
+ const firstOptions = getBrowserLaunchOptions(firstProject);
2733
+ for (const project of projects.slice(1)){
2734
+ const options = getBrowserLaunchOptions(project);
2735
+ if (options.provider !== firstOptions.provider || options.browser !== firstOptions.browser || options.headless !== firstOptions.headless || options.port !== firstOptions.port || options.strictPort !== firstOptions.strictPort) 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.`);
2736
+ }
2737
+ return firstOptions;
2738
+ };
2739
+ const resolveProviderForTestPath = ({ testPath, browserProjects })=>{
2740
+ const normalizedTestPath = normalize(testPath);
2741
+ const sortedProjects = [
2742
+ ...browserProjects
2743
+ ].sort((a, b)=>b.rootPath.length - a.rootPath.length);
2744
+ for (const project of sortedProjects)if (normalizedTestPath.startsWith(project.rootPath)) return project.provider;
2745
+ throw new Error(`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. Known project roots: ${JSON.stringify(sortedProjects.map((p)=>p.rootPath))}`);
2746
+ };
1809
2747
  const collectProjectEntries = async (context)=>{
1810
2748
  const projectEntries = [];
1811
2749
  const browserProjects = getBrowserProjects(context);
@@ -1919,20 +2857,6 @@ const htmlTemplate = `<!DOCTYPE html>
1919
2857
  </body>
1920
2858
  </html>
1921
2859
  `;
1922
- const fallbackSchedulerHtmlTemplate = `<!DOCTYPE html>
1923
- <html lang="en">
1924
- <head>
1925
- <meta charset="UTF-8" />
1926
- <title>Rstest Browser Scheduler</title>
1927
- <script>
1928
- window.__RSTEST_BROWSER_OPTIONS__ = ${OPTIONS_PLACEHOLDER};
1929
- </script>
1930
- </head>
1931
- <body>
1932
- <script type="module" src="/container-static/js/scheduler.js"></script>
1933
- </body>
1934
- </html>
1935
- `;
1936
2860
  const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1937
2861
  const destroyBrowserRuntime = async (runtime)=>{
1938
2862
  try {
@@ -1972,24 +2896,25 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
1972
2896
  [manifestPath]: manifestSource
1973
2897
  });
1974
2898
  const containerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'index.html'), 'utf-8') : null;
1975
- const schedulerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'scheduler.html'), 'utf-8').catch(()=>null) : null;
1976
2899
  let injectedContainerHtml = null;
1977
- let injectedSchedulerHtml = null;
1978
2900
  let serializedOptions = 'null';
2901
+ const dispatchHandlers = new Map();
1979
2902
  const setContainerOptions = (options)=>{
1980
2903
  serializedOptions = serializeForInlineScript(options);
1981
2904
  if (containerHtmlTemplate) injectedContainerHtml = containerHtmlTemplate.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1982
- injectedSchedulerHtml = (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(OPTIONS_PLACEHOLDER, serializedOptions);
1983
2905
  };
1984
2906
  const browserProjects = getBrowserProjects(context);
1985
- const firstProject = browserProjects[0];
1986
- const userPlugins = firstProject?.normalizedConfig.plugins || [];
1987
- const userRsbuildConfig = firstProject?.normalizedConfig ?? {};
1988
- const browserConfig = firstProject?.normalizedConfig.browser ?? context.normalizedConfig.browser;
2907
+ const projectByEnvironmentName = new Map(browserProjects.map((project)=>[
2908
+ project.environmentName,
2909
+ project
2910
+ ]));
2911
+ const userPlugins = browserProjects.flatMap((project)=>project.normalizedConfig.plugins || []);
2912
+ const browserLaunchOptions = ensureConsistentBrowserLaunchOptions(browserProjects);
1989
2913
  const browserRuntimePath = fileURLToPath(import.meta.resolve('@rstest/core/browser-runtime'));
1990
2914
  const rstestInternalAliases = {
1991
2915
  '@rstest/browser-manifest': manifestPath,
1992
2916
  '@rstest/core': resolveBrowserFile('client/public.ts'),
2917
+ '@rstest/browser': resolveBrowserFile('browser.ts'),
1993
2918
  '@rstest/core/browser-runtime': browserRuntimePath,
1994
2919
  '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts')
1995
2920
  };
@@ -2001,8 +2926,8 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2001
2926
  plugins: userPlugins,
2002
2927
  server: {
2003
2928
  printUrls: false,
2004
- port: browserConfig.port ?? 4000,
2005
- strictPort: browserConfig.strictPort
2929
+ port: browserLaunchOptions.port ?? 4000,
2930
+ strictPort: browserLaunchOptions.strictPort
2006
2931
  },
2007
2932
  dev: {
2008
2933
  client: {
@@ -2010,7 +2935,10 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2010
2935
  }
2011
2936
  },
2012
2937
  environments: {
2013
- [firstProject?.environmentName || 'web']: {}
2938
+ ...Object.fromEntries(browserProjects.map((project)=>[
2939
+ project.environmentName,
2940
+ {}
2941
+ ]))
2014
2942
  }
2015
2943
  }
2016
2944
  });
@@ -2018,12 +2946,26 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2018
2946
  {
2019
2947
  name: 'rstest:browser-user-config',
2020
2948
  setup (api) {
2949
+ api.expose?.('rstest:browser', {
2950
+ registerDispatchHandler: (namespace, handler)=>{
2951
+ dispatchHandlers.set(namespace, handler);
2952
+ }
2953
+ });
2021
2954
  api.modifyEnvironmentConfig({
2022
- handler: (config, { mergeEnvironmentConfig })=>{
2955
+ handler: (config, { mergeEnvironmentConfig, name })=>{
2956
+ const project = projectByEnvironmentName.get(name);
2957
+ if (!project) return config;
2958
+ const userRsbuildConfig = project.normalizedConfig;
2023
2959
  const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
2024
2960
  resolve: {
2025
2961
  alias: rstestInternalAliases
2026
2962
  },
2963
+ source: {
2964
+ define: {
2965
+ 'process.env': 'globalThis[Symbol.for("rstest.env")]',
2966
+ 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]'
2967
+ }
2968
+ },
2027
2969
  output: {
2028
2970
  target: 'web',
2029
2971
  sourceMap: {
@@ -2073,7 +3015,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2073
3015
  api.onAfterDevCompile(async ({ stats })=>{
2074
3016
  if (stats) {
2075
3017
  const projectEntries = await collectProjectEntries(context);
2076
- const entryTestFiles = new Set(projectEntries.flatMap((entry)=>entry.testFiles.map((f)=>normalize(f))));
3018
+ const entryTestFiles = new Set(collectWatchTestFiles(projectEntries).map((file)=>file.testPath));
2077
3019
  const statsJson = stats.toJson({
2078
3020
  all: true
2079
3021
  });
@@ -2087,7 +3029,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2087
3029
  }
2088
3030
  }
2089
3031
  ]);
2090
- const coverage = firstProject?.normalizedConfig.coverage;
3032
+ const coverage = browserProjects.find((project)=>project.normalizedConfig.coverage?.enabled)?.normalizedConfig.coverage;
2091
3033
  if (coverage?.enabled && 'list' !== context.command) {
2092
3034
  const { pluginCoverage } = await loadCoverageProvider(coverage, context.rootPath);
2093
3035
  rsbuildInstance.addPlugins([
@@ -2167,13 +3109,8 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2167
3109
  }
2168
3110
  return;
2169
3111
  }
2170
- if ('/' === url.pathname || '/scheduler.html' === url.pathname) {
3112
+ if ('/' === url.pathname) {
2171
3113
  if (await respondWithDevServerHtml(url, res)) return;
2172
- if ('/scheduler.html' === url.pathname) {
2173
- res.setHeader('Content-Type', 'text/html');
2174
- res.end(injectedSchedulerHtml || (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(OPTIONS_PLACEHOLDER, 'null'));
2175
- return;
2176
- }
2177
3114
  const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
2178
3115
  if (html) {
2179
3116
  res.setHeader('Content-Type', 'text/html');
@@ -2207,44 +3144,32 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2207
3144
  wss.once('error', reject);
2208
3145
  });
2209
3146
  const wsPort = wss.address().port;
2210
- logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
2211
- let browserLauncher;
2212
- const browserName = browserConfig.browser;
2213
- try {
2214
- const playwright = await import("playwright");
2215
- browserLauncher = playwright[browserName];
2216
- } catch (_error) {
2217
- wss.close();
2218
- await devServer.close();
2219
- throw _error;
2220
- }
2221
- let browser;
3147
+ logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
3148
+ const browserName = browserLaunchOptions.browser ?? 'chromium';
2222
3149
  try {
2223
- browser = await browserLauncher.launch({
2224
- headless: forceHeadless ?? browserConfig.headless,
2225
- args: 'chromium' === browserName ? [
2226
- '--disable-popup-blocking',
2227
- '--no-first-run',
2228
- '--no-default-browser-check'
2229
- ] : void 0
3150
+ const providerImplementation = getBrowserProviderImplementation(browserLaunchOptions.provider);
3151
+ const runtime = await providerImplementation.launchRuntime({
3152
+ browserName,
3153
+ headless: forceHeadless ?? browserLaunchOptions.headless
2230
3154
  });
3155
+ return {
3156
+ rsbuildInstance,
3157
+ devServer,
3158
+ browser: runtime.browser,
3159
+ port,
3160
+ wsPort,
3161
+ manifestPath,
3162
+ tempDir,
3163
+ manifestPlugin: virtualManifestPlugin,
3164
+ setContainerOptions,
3165
+ dispatchHandlers,
3166
+ wss
3167
+ };
2231
3168
  } catch (_error) {
2232
3169
  wss.close();
2233
3170
  await devServer.close();
2234
3171
  throw _error;
2235
3172
  }
2236
- return {
2237
- rsbuildInstance,
2238
- devServer,
2239
- browser,
2240
- port,
2241
- wsPort,
2242
- manifestPath,
2243
- tempDir,
2244
- manifestPlugin: virtualManifestPlugin,
2245
- setContainerOptions,
2246
- wss
2247
- };
2248
3173
  };
2249
3174
  async function resolveProjectEntries(context, shardedEntries) {
2250
3175
  if (shardedEntries) {
@@ -2269,8 +3194,36 @@ const runBrowserController = async (context, options)=>{
2269
3194
  const { skipOnTestRunEnd = false } = options ?? {};
2270
3195
  const buildStart = Date.now();
2271
3196
  const browserProjects = getBrowserProjects(context);
2272
- const useSchedulerPage = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
2273
- const buildErrorResult = async (error)=>{
3197
+ const useHeadlessDirect = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
3198
+ const browserSourceMapCache = new Map();
3199
+ const isHttpLikeFile = (file)=>/^https?:\/\//.test(file);
3200
+ const resolveBrowserSourcemap = async (sourcePath)=>{
3201
+ if (!isHttpLikeFile(sourcePath)) return {
3202
+ handled: false,
3203
+ sourcemap: null
3204
+ };
3205
+ const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
3206
+ if (!normalizedUrl) return {
3207
+ handled: true,
3208
+ sourcemap: null
3209
+ };
3210
+ if (browserSourceMapCache.has(normalizedUrl)) return {
3211
+ handled: true,
3212
+ sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null
3213
+ };
3214
+ return {
3215
+ handled: true,
3216
+ sourcemap: await loadSourceMapWithCache({
3217
+ jsUrl: normalizedUrl,
3218
+ cache: browserSourceMapCache
3219
+ })
3220
+ };
3221
+ };
3222
+ const getBrowserSourcemap = async (sourcePath)=>{
3223
+ const result = await resolveBrowserSourcemap(sourcePath);
3224
+ return result.handled ? result.sourcemap : null;
3225
+ };
3226
+ const buildErrorResult = async (error, close)=>{
2274
3227
  const elapsed = Math.max(0, Date.now() - buildStart);
2275
3228
  const errorResult = {
2276
3229
  results: [],
@@ -2283,14 +3236,17 @@ const runBrowserController = async (context, options)=>{
2283
3236
  hasFailure: true,
2284
3237
  unhandledErrors: [
2285
3238
  error
2286
- ]
3239
+ ],
3240
+ getSourcemap: getBrowserSourcemap,
3241
+ resolveSourcemap: resolveBrowserSourcemap,
3242
+ close
2287
3243
  };
2288
3244
  if (!skipOnTestRunEnd) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
2289
3245
  results: [],
2290
3246
  testResults: [],
2291
3247
  duration: errorResult.duration,
2292
3248
  snapshotSummary: context.snapshotManager.summary,
2293
- getSourcemap: async ()=>null,
3249
+ getSourcemap: getBrowserSourcemap,
2294
3250
  unhandledErrors: errorResult.unhandledErrors
2295
3251
  });
2296
3252
  return errorResult;
@@ -2298,24 +3254,51 @@ const runBrowserController = async (context, options)=>{
2298
3254
  const toError = (error)=>error instanceof Error ? error : new Error(String(error));
2299
3255
  const failWithError = async (error, cleanup)=>{
2300
3256
  ensureProcessExitCode(1);
2301
- await cleanup?.();
2302
- return buildErrorResult(toError(error));
3257
+ const normalizedError = toError(error);
3258
+ if (cleanup && skipOnTestRunEnd) return buildErrorResult(normalizedError, cleanup);
3259
+ try {
3260
+ return await buildErrorResult(normalizedError);
3261
+ } finally{
3262
+ await cleanup?.();
3263
+ }
3264
+ };
3265
+ const collectDeletedTestPaths = (previous, current)=>{
3266
+ const currentPathSet = new Set(current.map((file)=>file.testPath));
3267
+ return previous.map((file)=>file.testPath).filter((testPath)=>!currentPathSet.has(testPath));
3268
+ };
3269
+ const notifyTestRunStart = async ()=>{
3270
+ if (skipOnTestRunEnd) return;
3271
+ for (const reporter of context.reporters)await reporter.onTestRunStart?.();
3272
+ };
3273
+ const notifyTestRunEnd = async ({ duration, unhandledErrors, filterRerunTestPaths })=>{
3274
+ if (skipOnTestRunEnd) return;
3275
+ for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
3276
+ results: context.reporterResults.results,
3277
+ testResults: context.reporterResults.testResults,
3278
+ duration,
3279
+ snapshotSummary: context.snapshotManager.summary,
3280
+ getSourcemap: getBrowserSourcemap,
3281
+ unhandledErrors,
3282
+ filterRerunTestPaths
3283
+ });
2303
3284
  };
2304
3285
  const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
2305
3286
  let containerDevServer;
2306
3287
  let containerDistPath;
2307
- if (containerDevServerEnv) try {
2308
- containerDevServer = new URL(containerDevServerEnv).toString();
2309
- logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
2310
- } catch (error) {
2311
- const originalError = toError(error);
2312
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
2313
- return failWithError(originalError);
2314
- }
2315
- if (!containerDevServer) try {
2316
- containerDistPath = resolveContainerDist();
2317
- } catch (error) {
2318
- return failWithError(error);
3288
+ if (!useHeadlessDirect) {
3289
+ if (containerDevServerEnv) try {
3290
+ containerDevServer = new URL(containerDevServerEnv).toString();
3291
+ logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
3292
+ } catch (error) {
3293
+ const originalError = toError(error);
3294
+ originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
3295
+ return failWithError(originalError);
3296
+ }
3297
+ if (!containerDevServer) try {
3298
+ containerDistPath = resolveContainerDist();
3299
+ } catch (error) {
3300
+ return failWithError(error);
3301
+ }
2319
3302
  }
2320
3303
  const projectEntries = await resolveProjectEntries(context, options?.shardedEntries);
2321
3304
  const totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
@@ -2329,6 +3312,7 @@ const runBrowserController = async (context, options)=>{
2329
3312
  if (0 !== code) ensureProcessExitCode(code);
2330
3313
  return;
2331
3314
  }
3315
+ await notifyTestRunStart();
2332
3316
  const isWatchMode = 'watch' === context.command;
2333
3317
  const tempDir = isWatchMode && watchContext.runtime ? watchContext.runtime.tempDir : isWatchMode ? join(context.rootPath, TEMP_RSTEST_OUTPUT_DIR, 'browser', 'watch') : join(context.rootPath, TEMP_RSTEST_OUTPUT_DIR, 'browser', Date.now().toString());
2334
3318
  const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
@@ -2336,10 +3320,7 @@ const runBrowserController = async (context, options)=>{
2336
3320
  manifestPath,
2337
3321
  entries: projectEntries
2338
3322
  });
2339
- if (isWatchMode) watchContext.lastTestFiles = projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
2340
- testPath,
2341
- projectName: entry.project.name
2342
- })));
3323
+ if (isWatchMode) watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
2343
3324
  let runtime = isWatchMode ? watchContext.runtime : null;
2344
3325
  let triggerRerun;
2345
3326
  if (!runtime) {
@@ -2394,11 +3375,442 @@ const runBrowserController = async (context, options)=>{
2394
3375
  debug: isDebug(),
2395
3376
  rpcTimeout: maxTestTimeoutForRpc
2396
3377
  };
3378
+ const browserProviderProjects = browserProjects.map((project)=>({
3379
+ rootPath: normalize(project.rootPath),
3380
+ provider: project.normalizedConfig.browser.provider
3381
+ }));
3382
+ const implementationByProvider = new Map();
3383
+ for (const browserProject of browserProviderProjects)if (!implementationByProvider.has(browserProject.provider)) implementationByProvider.set(browserProject.provider, getBrowserProviderImplementation(browserProject.provider));
3384
+ let activeContainerPage = null;
3385
+ let getHeadlessRunnerPageBySessionId;
3386
+ const dispatchBrowserRpcRequest = async ({ request, target })=>{
3387
+ const timeoutFallbackMs = maxTestTimeoutForRpc;
3388
+ const provider = resolveProviderForTestPath({
3389
+ testPath: request.testPath,
3390
+ browserProjects: browserProviderProjects
3391
+ });
3392
+ const implementation = implementationByProvider.get(provider);
3393
+ if (!implementation) throw new Error(`Browser provider implementation not found: ${provider}`);
3394
+ const runnerPage = target?.sessionId ? getHeadlessRunnerPageBySessionId?.(target.sessionId) : void 0;
3395
+ if (target?.sessionId && !runnerPage) throw new Error(`Runner page session not found for browser dispatch: ${target.sessionId}`);
3396
+ if (!runnerPage && !activeContainerPage) throw new Error('Browser container page is not initialized');
3397
+ try {
3398
+ return await implementation.dispatchRpc({
3399
+ containerPage: runnerPage ? void 0 : activeContainerPage ?? void 0,
3400
+ runnerPage,
3401
+ request,
3402
+ timeoutFallbackMs
3403
+ });
3404
+ } catch (error) {
3405
+ if (error instanceof Error) throw error.message;
3406
+ throw String(error);
3407
+ }
3408
+ };
3409
+ runtime.dispatchHandlers.set('browser', async (dispatchRequest)=>{
3410
+ const request = validateBrowserRpcRequest(dispatchRequest.args);
3411
+ return dispatchBrowserRpcRequest({
3412
+ request,
3413
+ target: dispatchRequest.target
3414
+ });
3415
+ });
2397
3416
  runtime.setContainerOptions(hostOptions);
2398
3417
  const reporterResults = [];
2399
3418
  const caseResults = [];
2400
- let completedTests = 0;
2401
3419
  let fatalError = null;
3420
+ const snapshotRpcMethods = {
3421
+ async resolveSnapshotPath (testPath) {
3422
+ const snapExtension = '.snap';
3423
+ const resolver = context.normalizedConfig.resolveSnapshotPath || (()=>join(dirname(testPath), '__snapshots__', `${basename(testPath)}${snapExtension}`));
3424
+ return resolver(testPath, snapExtension);
3425
+ },
3426
+ async readSnapshotFile (filepath) {
3427
+ try {
3428
+ return await promises.readFile(filepath, 'utf-8');
3429
+ } catch {
3430
+ return null;
3431
+ }
3432
+ },
3433
+ async saveSnapshotFile (filepath, content) {
3434
+ const dir = dirname(filepath);
3435
+ await promises.mkdir(dir, {
3436
+ recursive: true
3437
+ });
3438
+ await promises.writeFile(filepath, content, 'utf-8');
3439
+ },
3440
+ async removeSnapshotFile (filepath) {
3441
+ try {
3442
+ await promises.unlink(filepath);
3443
+ } catch {}
3444
+ }
3445
+ };
3446
+ const handleTestFileStart = async (payload)=>{
3447
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileStart?.({
3448
+ testPath: payload.testPath,
3449
+ tests: []
3450
+ })));
3451
+ };
3452
+ const handleTestFileReady = async (payload)=>{
3453
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileReady?.(payload)));
3454
+ };
3455
+ const handleTestSuiteStart = async (payload)=>{
3456
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestSuiteStart?.(payload)));
3457
+ };
3458
+ const handleTestSuiteResult = async (payload)=>{
3459
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestSuiteResult?.(payload)));
3460
+ };
3461
+ const handleTestCaseStart = async (payload)=>{
3462
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseStart?.(payload)));
3463
+ };
3464
+ const handleTestCaseResult = async (payload)=>{
3465
+ caseResults.push(payload);
3466
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseResult?.(payload)));
3467
+ };
3468
+ const handleTestFileComplete = async (payload)=>{
3469
+ reporterResults.push(payload);
3470
+ context.updateReporterResultState([
3471
+ payload
3472
+ ], payload.results);
3473
+ if (payload.snapshotResult) context.snapshotManager.add(payload.snapshotResult);
3474
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileResult?.(payload)));
3475
+ if ('fail' === payload.status) ensureProcessExitCode(1);
3476
+ };
3477
+ const handleLog = async (payload)=>{
3478
+ const log = {
3479
+ content: payload.content,
3480
+ name: payload.level,
3481
+ testPath: payload.testPath,
3482
+ type: payload.type,
3483
+ trace: payload.trace
3484
+ };
3485
+ const shouldLog = context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
3486
+ if (shouldLog) await Promise.all(context.reporters.map((reporter)=>reporter.onUserConsoleLog?.(log)));
3487
+ };
3488
+ const handleFatal = async (payload)=>{
3489
+ const error = new Error(payload.message);
3490
+ error.stack = payload.stack;
3491
+ fatalError = error;
3492
+ ensureProcessExitCode(1);
3493
+ };
3494
+ const runSnapshotRpc = async (request)=>{
3495
+ switch(request.method){
3496
+ case 'resolveSnapshotPath':
3497
+ return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
3498
+ case 'readSnapshotFile':
3499
+ return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
3500
+ case 'saveSnapshotFile':
3501
+ return snapshotRpcMethods.saveSnapshotFile(request.args.filepath, request.args.content);
3502
+ case 'removeSnapshotFile':
3503
+ return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
3504
+ default:
3505
+ return;
3506
+ }
3507
+ };
3508
+ const createDispatchRouter = (options)=>createHostDispatchRouter({
3509
+ routerOptions: options,
3510
+ runnerCallbacks: {
3511
+ onTestFileStart: handleTestFileStart,
3512
+ onTestFileReady: handleTestFileReady,
3513
+ onTestSuiteStart: handleTestSuiteStart,
3514
+ onTestSuiteResult: handleTestSuiteResult,
3515
+ onTestCaseStart: handleTestCaseStart,
3516
+ onTestCaseResult: handleTestCaseResult,
3517
+ onTestFileComplete: handleTestFileComplete,
3518
+ onLog: handleLog,
3519
+ onFatal: handleFatal
3520
+ },
3521
+ runSnapshotRpc,
3522
+ extensionHandlers: runtime.dispatchHandlers,
3523
+ onDuplicateNamespace: (namespace)=>{
3524
+ logger.debug(`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`);
3525
+ }
3526
+ });
3527
+ if (useHeadlessDirect) {
3528
+ const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
3529
+ const runLifecycle = new RunSessionLifecycle();
3530
+ const sessionRegistry = new RunnerSessionRegistry();
3531
+ getHeadlessRunnerPageBySessionId = (sessionId)=>sessionRegistry.getById(sessionId)?.page;
3532
+ let dispatchRequestCounter = 0;
3533
+ const nextDispatchRequestId = (namespace)=>`${namespace}-${++dispatchRequestCounter}`;
3534
+ const closeContextSafely = async (browserContext)=>{
3535
+ try {
3536
+ await browserContext.close();
3537
+ } catch {}
3538
+ };
3539
+ const cancelRun = async (run, waitForDone = true)=>{
3540
+ await runLifecycle.cancel(run, {
3541
+ waitForDone,
3542
+ onCancel: async (session)=>{
3543
+ await Promise.all(Array.from(session.contexts).map((browserContext)=>closeContextSafely(browserContext)));
3544
+ }
3545
+ });
3546
+ };
3547
+ const dispatchRouter = createDispatchRouter({
3548
+ isRunTokenStale: (runToken)=>runLifecycle.isTokenStale(runToken),
3549
+ onStale: (request)=>{
3550
+ if (request.namespace === DISPATCH_NAMESPACE_RUNNER) logger.debug(`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`);
3551
+ }
3552
+ });
3553
+ const dispatchRunnerMessage = async (run, file, sessionId, message)=>{
3554
+ const response = await dispatchRouter.dispatch({
3555
+ requestId: nextDispatchRequestId('runner'),
3556
+ runToken: run.token,
3557
+ namespace: DISPATCH_NAMESPACE_RUNNER,
3558
+ method: message.type,
3559
+ args: 'payload' in message ? message.payload : void 0,
3560
+ target: {
3561
+ sessionId,
3562
+ testFile: file.testPath,
3563
+ projectName: file.projectName
3564
+ }
3565
+ });
3566
+ if (response.stale) return;
3567
+ if (response.error) throw new Error(response.error);
3568
+ };
3569
+ const runSingleFile = async (run, file)=>{
3570
+ if (run.cancelled || runLifecycle.isTokenStale(run.token)) return;
3571
+ const viewport = viewportByProject.get(file.projectName);
3572
+ const browserContext = await browser.newContext({
3573
+ viewport: viewport ?? null
3574
+ });
3575
+ run.contexts.add(browserContext);
3576
+ let page = null;
3577
+ let sessionId = null;
3578
+ let settled = false;
3579
+ let resolveDone = null;
3580
+ const markDone = ()=>{
3581
+ if (!settled) {
3582
+ settled = true;
3583
+ resolveDone?.();
3584
+ }
3585
+ };
3586
+ const donePromise = new Promise((resolve)=>{
3587
+ resolveDone = resolve;
3588
+ });
3589
+ const projectRuntime = projectRuntimeConfigs.find((project)=>project.name === file.projectName);
3590
+ const perFileTimeoutMs = (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) + 30000;
3591
+ let timeoutId;
3592
+ try {
3593
+ page = await browserContext.newPage();
3594
+ const session = sessionRegistry.register({
3595
+ testFile: file.testPath,
3596
+ projectName: file.projectName,
3597
+ runToken: run.token,
3598
+ mode: 'headless-page',
3599
+ context: browserContext,
3600
+ page
3601
+ });
3602
+ sessionId = session.id;
3603
+ await attachHeadlessRunnerTransport(page, {
3604
+ onDispatchMessage: async (message)=>{
3605
+ try {
3606
+ await dispatchRunnerMessage(run, file, session.id, message);
3607
+ if ('file-complete' === message.type || 'complete' === message.type) markDone();
3608
+ else if ('fatal' === message.type) {
3609
+ markDone();
3610
+ await cancelRun(run, false);
3611
+ }
3612
+ } catch (error) {
3613
+ const formatted = toError(error);
3614
+ await handleFatal({
3615
+ message: formatted.message,
3616
+ stack: formatted.stack
3617
+ });
3618
+ markDone();
3619
+ await cancelRun(run, false);
3620
+ }
3621
+ },
3622
+ onDispatchRpc: async (request)=>dispatchRouter.dispatch({
3623
+ ...request,
3624
+ runToken: run.token,
3625
+ target: {
3626
+ sessionId: session.id,
3627
+ testFile: file.testPath,
3628
+ projectName: file.projectName,
3629
+ ...request.target
3630
+ }
3631
+ })
3632
+ });
3633
+ const inlineOptions = {
3634
+ ...hostOptions,
3635
+ testFile: file.testPath,
3636
+ runId: `${run.token}:${session.id}`
3637
+ };
3638
+ const serializedOptions = serializeForInlineScript(inlineOptions);
3639
+ await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
3640
+ await page.goto(`http://localhost:${port}/runner.html`, {
3641
+ waitUntil: 'load'
3642
+ });
3643
+ const timeoutPromise = new Promise((resolve)=>{
3644
+ timeoutId = setTimeout(()=>resolve('timeout'), perFileTimeoutMs);
3645
+ });
3646
+ const state = await Promise.race([
3647
+ donePromise.then(()=>'done'),
3648
+ timeoutPromise,
3649
+ run.cancelSignal.then(()=>'cancelled')
3650
+ ]);
3651
+ if ('cancelled' === state) return;
3652
+ if ('timeout' === state && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
3653
+ await handleFatal({
3654
+ message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`
3655
+ });
3656
+ await cancelRun(run, false);
3657
+ }
3658
+ } catch (error) {
3659
+ if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
3660
+ const formatted = toError(error);
3661
+ await handleFatal({
3662
+ message: formatted.message,
3663
+ stack: formatted.stack
3664
+ });
3665
+ await cancelRun(run, false);
3666
+ }
3667
+ } finally{
3668
+ if (timeoutId) clearTimeout(timeoutId);
3669
+ if (page) try {
3670
+ await page.close();
3671
+ } catch {}
3672
+ if (sessionId) sessionRegistry.deleteById(sessionId);
3673
+ run.contexts.delete(browserContext);
3674
+ await closeContextSafely(browserContext);
3675
+ }
3676
+ };
3677
+ const runFilesWithPool = async (files)=>{
3678
+ if (0 === files.length) return;
3679
+ const previous = runLifecycle.activeSession;
3680
+ if (previous) await cancelRun(previous);
3681
+ const run = runLifecycle.createSession((token)=>({
3682
+ ...createRunSession(token),
3683
+ contexts: new Set()
3684
+ }));
3685
+ const queue = [
3686
+ ...files
3687
+ ];
3688
+ const concurrency = getHeadlessConcurrency(context, queue.length);
3689
+ const worker = async ()=>{
3690
+ while(queue.length > 0 && !run.cancelled && runLifecycle.isTokenActive(run.token)){
3691
+ const next = queue.shift();
3692
+ if (!next) return;
3693
+ await runSingleFile(run, next);
3694
+ }
3695
+ };
3696
+ run.done = Promise.all(Array.from({
3697
+ length: Math.min(queue.length, Math.max(concurrency, 1))
3698
+ }, ()=>worker())).then(()=>{});
3699
+ await run.done;
3700
+ runLifecycle.clearIfActive(run);
3701
+ };
3702
+ const latestRerunScheduler = createHeadlessLatestRerunScheduler({
3703
+ getActiveRun: ()=>runLifecycle.activeSession,
3704
+ isRunCancelled: (run)=>run.cancelled,
3705
+ invalidateActiveRun: ()=>{
3706
+ runLifecycle.invalidateActiveToken();
3707
+ },
3708
+ interruptActiveRun: async (run)=>{
3709
+ await cancelRun(run, false);
3710
+ },
3711
+ runFiles: async (files)=>{
3712
+ await notifyTestRunStart();
3713
+ const rerunStartTime = Date.now();
3714
+ const fatalErrorBeforeRun = fatalError;
3715
+ let rerunError;
3716
+ try {
3717
+ await runFilesWithPool(files);
3718
+ } catch (error) {
3719
+ rerunError = toError(error);
3720
+ throw error;
3721
+ } finally{
3722
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
3723
+ const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
3724
+ await notifyTestRunEnd({
3725
+ duration: {
3726
+ totalTime: testTime,
3727
+ buildTime: 0,
3728
+ testTime
3729
+ },
3730
+ filterRerunTestPaths: files.map((file)=>file.testPath),
3731
+ unhandledErrors: rerunError ? [
3732
+ rerunError
3733
+ ] : rerunFatalError ? [
3734
+ rerunFatalError
3735
+ ] : void 0
3736
+ });
3737
+ }
3738
+ },
3739
+ onError: async (error)=>{
3740
+ const formatted = toError(error);
3741
+ await handleFatal({
3742
+ message: formatted.message,
3743
+ stack: formatted.stack
3744
+ });
3745
+ },
3746
+ onInterrupt: (run)=>{
3747
+ logger.debug(`[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`);
3748
+ }
3749
+ });
3750
+ const testStart = Date.now();
3751
+ await runFilesWithPool(allTestFiles);
3752
+ const testTime = Date.now() - testStart;
3753
+ if (isWatchMode) triggerRerun = async ()=>{
3754
+ const newProjectEntries = await collectProjectEntries(context);
3755
+ const rerunPlan = planWatchRerun({
3756
+ projectEntries: newProjectEntries,
3757
+ previousTestFiles: watchContext.lastTestFiles,
3758
+ affectedTestFiles: watchContext.affectedTestFiles
3759
+ });
3760
+ watchContext.affectedTestFiles = [];
3761
+ if (rerunPlan.filesChanged) {
3762
+ const deletedTestPaths = collectDeletedTestPaths(watchContext.lastTestFiles, rerunPlan.currentTestFiles);
3763
+ if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
3764
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3765
+ if (0 === rerunPlan.currentTestFiles.length) {
3766
+ await latestRerunScheduler.enqueueLatest([]);
3767
+ logger.log(color.cyan('No browser test files remain after update.\n'));
3768
+ return;
3769
+ }
3770
+ logger.log(color.cyan(`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`));
3771
+ latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3772
+ return;
3773
+ }
3774
+ if (0 === rerunPlan.affectedTestFiles.length) return void logger.log(color.cyan('No affected browser test files detected, skipping re-run.\n'));
3775
+ logger.log(color.cyan(`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`));
3776
+ latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3777
+ };
3778
+ const closeHeadlessRuntime = isWatchMode ? void 0 : async ()=>{
3779
+ sessionRegistry.clear();
3780
+ await destroyBrowserRuntime(runtime);
3781
+ };
3782
+ if (fatalError) return failWithError(fatalError, closeHeadlessRuntime);
3783
+ const duration = {
3784
+ totalTime: buildTime + testTime,
3785
+ buildTime,
3786
+ testTime
3787
+ };
3788
+ context.updateReporterResultState(reporterResults, caseResults);
3789
+ const isFailure = reporterResults.some((result)=>'fail' === result.status);
3790
+ if (isFailure) ensureProcessExitCode(1);
3791
+ const result = {
3792
+ results: reporterResults,
3793
+ testResults: caseResults,
3794
+ duration,
3795
+ hasFailure: isFailure,
3796
+ getSourcemap: getBrowserSourcemap,
3797
+ resolveSourcemap: resolveBrowserSourcemap,
3798
+ close: skipOnTestRunEnd ? closeHeadlessRuntime : void 0
3799
+ };
3800
+ if (!skipOnTestRunEnd) try {
3801
+ await notifyTestRunEnd({
3802
+ duration
3803
+ });
3804
+ } finally{
3805
+ await closeHeadlessRuntime?.();
3806
+ }
3807
+ if (isWatchMode && triggerRerun) {
3808
+ watchContext.hooksEnabled = true;
3809
+ logger.log(color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'));
3810
+ }
3811
+ return result;
3812
+ }
3813
+ let completedTests = 0;
2402
3814
  let resolveAllTests;
2403
3815
  const allTestsPromise = new Promise((resolve)=>{
2404
3816
  resolveAllTests = resolve;
@@ -2428,9 +3840,11 @@ const runBrowserController = async (context, options)=>{
2428
3840
  }
2429
3841
  containerPage.on('console', (msg)=>{
2430
3842
  const text = msg.text();
2431
- if (text.startsWith('[Container]') || text.startsWith('[Runner]') || text.startsWith('[Scheduler]')) logger.log(color.gray(`[Browser Console] ${text}`));
3843
+ if (text.startsWith('[Container]') || text.startsWith('[Runner]')) logger.log(color.gray(`[Browser Console] ${text}`));
2432
3844
  });
2433
3845
  }
3846
+ activeContainerPage = containerPage;
3847
+ const dispatchRouter = createDispatchRouter();
2434
3848
  const createRpcMethods = ()=>({
2435
3849
  async rerunTest (testFile, testNamePattern) {
2436
3850
  const projectName = context.normalizedConfig.name || 'project';
@@ -2443,61 +3857,25 @@ const runBrowserController = async (context, options)=>{
2443
3857
  return allTestFiles;
2444
3858
  },
2445
3859
  async onTestFileStart (payload) {
2446
- await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileStart?.({
2447
- testPath: payload.testPath,
2448
- tests: []
2449
- })));
3860
+ await handleTestFileStart(payload);
2450
3861
  },
2451
3862
  async onTestCaseResult (payload) {
2452
- caseResults.push(payload);
2453
- await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseResult?.(payload)));
3863
+ await handleTestCaseResult(payload);
2454
3864
  },
2455
3865
  async onTestFileComplete (payload) {
2456
- reporterResults.push(payload);
2457
- if (payload.snapshotResult) context.snapshotManager.add(payload.snapshotResult);
2458
- await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileResult?.(payload)));
3866
+ await handleTestFileComplete(payload);
2459
3867
  completedTests++;
2460
3868
  if (completedTests >= allTestFiles.length && resolveAllTests) resolveAllTests();
2461
3869
  },
2462
3870
  async onLog (payload) {
2463
- const log = {
2464
- content: payload.content,
2465
- name: payload.level,
2466
- testPath: payload.testPath,
2467
- type: payload.type,
2468
- trace: payload.trace
2469
- };
2470
- const shouldLog = context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
2471
- if (shouldLog) await Promise.all(context.reporters.map((reporter)=>reporter.onUserConsoleLog?.(log)));
3871
+ await handleLog(payload);
2472
3872
  },
2473
3873
  async onFatal (payload) {
2474
- fatalError = new Error(payload.message);
2475
- fatalError.stack = payload.stack;
3874
+ await handleFatal(payload);
2476
3875
  if (resolveAllTests) resolveAllTests();
2477
3876
  },
2478
- async resolveSnapshotPath (testPath) {
2479
- const snapExtension = '.snap';
2480
- const resolver = context.normalizedConfig.resolveSnapshotPath || (()=>join(dirname(testPath), '__snapshots__', `${basename(testPath)}${snapExtension}`));
2481
- return resolver(testPath, snapExtension);
2482
- },
2483
- async readSnapshotFile (filepath) {
2484
- try {
2485
- return await promises.readFile(filepath, 'utf-8');
2486
- } catch {
2487
- return null;
2488
- }
2489
- },
2490
- async saveSnapshotFile (filepath, content) {
2491
- const dir = dirname(filepath);
2492
- await promises.mkdir(dir, {
2493
- recursive: true
2494
- });
2495
- await promises.writeFile(filepath, content, 'utf-8');
2496
- },
2497
- async removeSnapshotFile (filepath) {
2498
- try {
2499
- await promises.unlink(filepath);
2500
- } catch {}
3877
+ async dispatch (request) {
3878
+ return dispatchRouter.dispatch(request);
2501
3879
  }
2502
3880
  });
2503
3881
  let rpcManager;
@@ -2511,11 +3889,7 @@ const runBrowserController = async (context, options)=>{
2511
3889
  if (isWatchMode) runtime.rpcManager = rpcManager;
2512
3890
  }
2513
3891
  if (isNewPage) {
2514
- const pagePath = useSchedulerPage ? '/scheduler.html' : '/';
2515
- if (useSchedulerPage) {
2516
- const serializedOptions = serializeForInlineScript(hostOptions);
2517
- await containerPage.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
2518
- }
3892
+ const pagePath = '/';
2519
3893
  await containerPage.goto(`http://localhost:${port}${pagePath}`, {
2520
3894
  waitUntil: 'load'
2521
3895
  });
@@ -2539,24 +3913,49 @@ const runBrowserController = async (context, options)=>{
2539
3913
  const testTime = Date.now() - testStart;
2540
3914
  if (isWatchMode) triggerRerun = async ()=>{
2541
3915
  const newProjectEntries = await collectProjectEntries(context);
2542
- const currentTestFiles = newProjectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
2543
- testPath: normalize(testPath),
2544
- projectName: entry.project.name
2545
- })));
2546
- const serialize = (files)=>JSON.stringify(files.map((f)=>`${f.projectName}:${f.testPath}`).sort());
2547
- const filesChanged = serialize(currentTestFiles) !== serialize(watchContext.lastTestFiles);
2548
- if (filesChanged) {
2549
- watchContext.lastTestFiles = currentTestFiles;
2550
- await rpcManager.notifyTestFileUpdate(currentTestFiles);
2551
- }
2552
- const affectedFiles = watchContext.affectedTestFiles;
3916
+ const rerunPlan = planWatchRerun({
3917
+ projectEntries: newProjectEntries,
3918
+ previousTestFiles: watchContext.lastTestFiles,
3919
+ affectedTestFiles: watchContext.affectedTestFiles
3920
+ });
2553
3921
  watchContext.affectedTestFiles = [];
2554
- if (affectedFiles.length > 0) {
2555
- logger.log(color.cyan(`Re-running ${affectedFiles.length} affected test file(s)...\n`));
2556
- for (const testFile of affectedFiles)await rpcManager.reloadTestFile(testFile);
2557
- } else if (!filesChanged) logger.log(color.cyan('Tests will be re-executed automatically\n'));
3922
+ if (rerunPlan.filesChanged) {
3923
+ const deletedTestPaths = collectDeletedTestPaths(watchContext.lastTestFiles, rerunPlan.currentTestFiles);
3924
+ if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
3925
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3926
+ await rpcManager.notifyTestFileUpdate(rerunPlan.currentTestFiles);
3927
+ }
3928
+ if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
3929
+ logger.log(color.cyan(`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`));
3930
+ await notifyTestRunStart();
3931
+ const rerunStartTime = Date.now();
3932
+ const fatalErrorBeforeRun = fatalError;
3933
+ let rerunError;
3934
+ try {
3935
+ for (const testFile of rerunPlan.normalizedAffectedTestFiles)await rpcManager.reloadTestFile(testFile);
3936
+ } catch (error) {
3937
+ rerunError = toError(error);
3938
+ throw error;
3939
+ } finally{
3940
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
3941
+ const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
3942
+ await notifyTestRunEnd({
3943
+ duration: {
3944
+ totalTime: testTime,
3945
+ buildTime: 0,
3946
+ testTime
3947
+ },
3948
+ filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
3949
+ unhandledErrors: rerunError ? [
3950
+ rerunError
3951
+ ] : rerunFatalError ? [
3952
+ rerunFatalError
3953
+ ] : void 0
3954
+ });
3955
+ }
3956
+ } else if (!rerunPlan.filesChanged) logger.log(color.cyan('Tests will be re-executed automatically\n'));
2558
3957
  };
2559
- if (!isWatchMode) {
3958
+ const closeContainerRuntime = isWatchMode ? void 0 : async ()=>{
2560
3959
  try {
2561
3960
  await containerPage.close();
2562
3961
  } catch {}
@@ -2564,8 +3963,8 @@ const runBrowserController = async (context, options)=>{
2564
3963
  await containerContext.close();
2565
3964
  } catch {}
2566
3965
  await destroyBrowserRuntime(runtime);
2567
- }
2568
- if (fatalError) return failWithError(fatalError);
3966
+ };
3967
+ if (fatalError) return failWithError(fatalError, closeContainerRuntime);
2569
3968
  const duration = {
2570
3969
  totalTime: buildTime + testTime,
2571
3970
  buildTime,
@@ -2578,15 +3977,18 @@ const runBrowserController = async (context, options)=>{
2578
3977
  results: reporterResults,
2579
3978
  testResults: caseResults,
2580
3979
  duration,
2581
- hasFailure: isFailure
3980
+ hasFailure: isFailure,
3981
+ getSourcemap: getBrowserSourcemap,
3982
+ resolveSourcemap: resolveBrowserSourcemap,
3983
+ close: skipOnTestRunEnd ? closeContainerRuntime : void 0
2582
3984
  };
2583
- if (!skipOnTestRunEnd) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
2584
- results: context.reporterResults.results,
2585
- testResults: context.reporterResults.testResults,
2586
- duration,
2587
- snapshotSummary: context.snapshotManager.summary,
2588
- getSourcemap: async ()=>null
2589
- });
3985
+ if (!skipOnTestRunEnd) try {
3986
+ await notifyTestRunEnd({
3987
+ duration
3988
+ });
3989
+ } finally{
3990
+ await closeContainerRuntime?.();
3991
+ }
2590
3992
  if (isWatchMode && triggerRerun) {
2591
3993
  watchContext.hooksEnabled = true;
2592
3994
  logger.log(color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'));
@@ -2606,6 +4008,7 @@ const listBrowserTests = async (context, options)=>{
2606
4008
  manifestPath,
2607
4009
  entries: projectEntries
2608
4010
  });
4011
+ const browserProjects = getBrowserProjects(context);
2609
4012
  let runtime;
2610
4013
  try {
2611
4014
  runtime = await createBrowserRuntime({
@@ -2619,11 +4022,13 @@ const listBrowserTests = async (context, options)=>{
2619
4022
  forceHeadless: true
2620
4023
  });
2621
4024
  } catch (error) {
2622
- logger.error(color.red('Failed to load Playwright. Please install "playwright" to use browser mode.'), error);
4025
+ const providers = [
4026
+ ...new Set(browserProjects.map((p)=>p.normalizedConfig.browser.provider))
4027
+ ];
4028
+ logger.error(color.red(`Failed to initialize browser provider runtime (${providers.join(', ')}).`), error);
2623
4029
  throw error;
2624
4030
  }
2625
4031
  const { browser, port } = runtime;
2626
- const browserProjects = getBrowserProjects(context);
2627
4032
  const projectRuntimeConfigs = browserProjects.map((project)=>({
2628
4033
  name: project.name,
2629
4034
  environmentName: project.environmentName,
@@ -2654,7 +4059,7 @@ const listBrowserTests = async (context, options)=>{
2654
4059
  viewport: null
2655
4060
  });
2656
4061
  const page = await browserContext.newPage();
2657
- await page.exposeFunction('__rstest_dispatch__', (message)=>{
4062
+ await page.exposeFunction(DISPATCH_MESSAGE_TYPE, (message)=>{
2658
4063
  switch(message.type){
2659
4064
  case 'collect-result':
2660
4065
  {
@@ -2736,99 +4141,6 @@ const listBrowserTests = async (context, options)=>{
2736
4141
  close: cleanup
2737
4142
  };
2738
4143
  };
2739
- const BROWSER_VIEWPORT_PRESET_IDS = [
2740
- 'iPhoneSE',
2741
- 'iPhoneXR',
2742
- 'iPhone12Pro',
2743
- 'iPhone14ProMax',
2744
- 'Pixel7',
2745
- 'SamsungGalaxyS8Plus',
2746
- 'SamsungGalaxyS20Ultra',
2747
- 'iPadMini',
2748
- 'iPadAir',
2749
- 'iPadPro',
2750
- 'SurfacePro7',
2751
- 'SurfaceDuo',
2752
- 'GalaxyZFold5',
2753
- 'AsusZenbookFold',
2754
- 'SamsungGalaxyA51A71',
2755
- 'NestHub',
2756
- 'NestHubMax'
2757
- ];
2758
- const BROWSER_VIEWPORT_PRESET_DIMENSIONS = {
2759
- iPhoneSE: {
2760
- width: 375,
2761
- height: 667
2762
- },
2763
- iPhoneXR: {
2764
- width: 414,
2765
- height: 896
2766
- },
2767
- iPhone12Pro: {
2768
- width: 390,
2769
- height: 844
2770
- },
2771
- iPhone14ProMax: {
2772
- width: 430,
2773
- height: 932
2774
- },
2775
- Pixel7: {
2776
- width: 412,
2777
- height: 915
2778
- },
2779
- SamsungGalaxyS8Plus: {
2780
- width: 360,
2781
- height: 740
2782
- },
2783
- SamsungGalaxyS20Ultra: {
2784
- width: 412,
2785
- height: 915
2786
- },
2787
- iPadMini: {
2788
- width: 768,
2789
- height: 1024
2790
- },
2791
- iPadAir: {
2792
- width: 820,
2793
- height: 1180
2794
- },
2795
- iPadPro: {
2796
- width: 1024,
2797
- height: 1366
2798
- },
2799
- SurfacePro7: {
2800
- width: 912,
2801
- height: 1368
2802
- },
2803
- SurfaceDuo: {
2804
- width: 540,
2805
- height: 720
2806
- },
2807
- GalaxyZFold5: {
2808
- width: 344,
2809
- height: 882
2810
- },
2811
- AsusZenbookFold: {
2812
- width: 853,
2813
- height: 1280
2814
- },
2815
- SamsungGalaxyA51A71: {
2816
- width: 412,
2817
- height: 914
2818
- },
2819
- NestHub: {
2820
- width: 1024,
2821
- height: 600
2822
- },
2823
- NestHubMax: {
2824
- width: 1280,
2825
- height: 800
2826
- }
2827
- };
2828
- const resolveBrowserViewportPreset = (presetId)=>{
2829
- const size = BROWSER_VIEWPORT_PRESET_DIMENSIONS[presetId];
2830
- return size ?? null;
2831
- };
2832
4144
  const SUPPORTED_PROVIDERS = [
2833
4145
  'playwright'
2834
4146
  ];