@rstest/browser 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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/{565.226c9ef5.js → 101.36a8ccdf84.js} +4024 -3856
  7. package/dist/browser-container/container-static/js/101.36a8ccdf84.js.LICENSE.txt +1 -0
  8. package/dist/browser-container/container-static/js/{index.c1d17467.js → index.28d833de0b.js} +732 -675
  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/headedSerialTaskQueue.d.ts +8 -0
  25. package/dist/headlessLatestRerunScheduler.d.ts +19 -0
  26. package/dist/headlessTransport.d.ts +12 -0
  27. package/dist/hostController.d.ts +16 -0
  28. package/dist/index.js +1790 -296
  29. package/dist/protocol.d.ts +44 -33
  30. package/dist/providers/index.d.ts +79 -0
  31. package/dist/providers/playwright/compileLocator.d.ts +3 -0
  32. package/dist/providers/playwright/dispatchBrowserRpc.d.ts +13 -0
  33. package/dist/providers/playwright/expectUtils.d.ts +24 -0
  34. package/dist/providers/playwright/implementation.d.ts +2 -0
  35. package/dist/providers/playwright/index.d.ts +1 -0
  36. package/dist/providers/playwright/runtime.d.ts +5 -0
  37. package/dist/providers/playwright/textMatcher.d.ts +8 -0
  38. package/dist/rpcProtocol.d.ts +145 -0
  39. package/dist/runSession.d.ts +33 -0
  40. package/dist/sessionRegistry.d.ts +34 -0
  41. package/dist/sourceMap/sourceMapLoader.d.ts +14 -0
  42. package/dist/watchCliShortcuts.d.ts +6 -0
  43. package/dist/watchRerunPlanner.d.ts +21 -0
  44. package/package.json +17 -12
  45. package/src/AGENTS.md +128 -0
  46. package/src/augmentExpect.ts +62 -0
  47. package/src/browser.ts +3 -0
  48. package/src/browserRpcRegistry.ts +57 -0
  49. package/src/client/AGENTS.md +82 -0
  50. package/src/client/api.ts +213 -0
  51. package/src/client/browserRpc.ts +86 -0
  52. package/src/client/dispatchTransport.ts +178 -0
  53. package/src/client/entry.ts +96 -33
  54. package/src/client/locator.ts +452 -0
  55. package/src/client/snapshot.ts +32 -97
  56. package/src/client/sourceMapSupport.ts +26 -37
  57. package/src/concurrency.ts +62 -0
  58. package/src/dispatchCapabilities.ts +162 -0
  59. package/src/dispatchRouter.ts +82 -0
  60. package/src/env.d.ts +8 -1
  61. package/src/headedSerialTaskQueue.ts +19 -0
  62. package/src/headlessLatestRerunScheduler.ts +76 -0
  63. package/src/headlessTransport.ts +28 -0
  64. package/src/hostController.ts +1538 -384
  65. package/src/protocol.ts +66 -31
  66. package/src/providers/index.ts +103 -0
  67. package/src/providers/playwright/compileLocator.ts +130 -0
  68. package/src/providers/playwright/dispatchBrowserRpc.ts +372 -0
  69. package/src/providers/playwright/expectUtils.ts +57 -0
  70. package/src/providers/playwright/implementation.ts +33 -0
  71. package/src/providers/playwright/index.ts +1 -0
  72. package/src/providers/playwright/runtime.ts +32 -0
  73. package/src/providers/playwright/textMatcher.ts +10 -0
  74. package/src/rpcProtocol.ts +220 -0
  75. package/src/runSession.ts +110 -0
  76. package/src/sessionRegistry.ts +89 -0
  77. package/src/sourceMap/sourceMapLoader.ts +96 -0
  78. package/src/watchCliShortcuts.ts +77 -0
  79. package/src/watchRerunPlanner.ts +77 -0
  80. package/dist/browser-container/container-static/css/index.5a71c757.css +0 -1
  81. package/dist/browser-container/container-static/js/565.226c9ef5.js.LICENSE.txt +0 -1
  82. package/dist/browser-container/container-static/js/lib-react.97ee79b0.js.LICENSE.txt +0 -1
  83. package/dist/browser-container/container-static/js/scheduler.5accca0c.js +0 -407
  84. 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");
@@ -1610,6 +1613,959 @@ function createBirpc($functions, options) {
1610
1613
  _promiseInit = on(onMessage);
1611
1614
  return rpc;
1612
1615
  }
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);
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 createHeadedSerialTaskQueue = ()=>{
1764
+ let queue = Promise.resolve();
1765
+ const enqueue = (task)=>{
1766
+ const next = queue.then(task);
1767
+ queue = next.catch(()=>{});
1768
+ return next;
1769
+ };
1770
+ return {
1771
+ enqueue
1772
+ };
1773
+ };
1774
+ const createHeadlessLatestRerunScheduler = (options)=>{
1775
+ let pendingFiles = null;
1776
+ let draining = null;
1777
+ let latestEnqueueVersion = 0;
1778
+ const runDrainLoop = async ()=>{
1779
+ while(pendingFiles){
1780
+ const nextFiles = pendingFiles;
1781
+ pendingFiles = null;
1782
+ try {
1783
+ await options.runFiles(nextFiles);
1784
+ } catch (error) {
1785
+ try {
1786
+ await options.onError?.(error);
1787
+ } catch {}
1788
+ }
1789
+ }
1790
+ };
1791
+ const ensureDrainLoop = ()=>{
1792
+ if (draining) return;
1793
+ draining = runDrainLoop().finally(()=>{
1794
+ draining = null;
1795
+ });
1796
+ };
1797
+ return {
1798
+ async enqueueLatest (files) {
1799
+ const enqueueVersion = ++latestEnqueueVersion;
1800
+ const activeRun = options.getActiveRun();
1801
+ if (activeRun && !options.isRunCancelled(activeRun)) {
1802
+ options.onInterrupt?.(activeRun);
1803
+ options.invalidateActiveRun();
1804
+ await options.interruptActiveRun(activeRun);
1805
+ }
1806
+ if (enqueueVersion !== latestEnqueueVersion) return;
1807
+ pendingFiles = files;
1808
+ ensureDrainLoop();
1809
+ },
1810
+ async whenIdle () {
1811
+ await draining;
1812
+ }
1813
+ };
1814
+ };
1815
+ const attachHeadlessRunnerTransport = async (page, handlers)=>{
1816
+ await page.exposeFunction(DISPATCH_MESSAGE_TYPE, handlers.onDispatchMessage);
1817
+ await page.exposeFunction(DISPATCH_RPC_BRIDGE_NAME, handlers.onDispatchRpc);
1818
+ };
1819
+ const isRecord = (value)=>'object' == typeof value && null !== value;
1820
+ const readString = (value, key, label)=>{
1821
+ const result = value[key];
1822
+ if ('string' != typeof result) throw new Error(`Invalid browser RPC request: ${label} must be a string`);
1823
+ return result;
1824
+ };
1825
+ const readUnknownArray = (value, key, label)=>{
1826
+ const result = value[key];
1827
+ if (!Array.isArray(result)) throw new Error(`Invalid browser RPC request: ${label} must be an array`);
1828
+ return result;
1829
+ };
1830
+ const parseBrowserLocatorIR = (value, label)=>{
1831
+ if (!isRecord(value)) throw new Error(`Invalid browser RPC request: ${label} must be an object`);
1832
+ const steps = value.steps;
1833
+ if (!Array.isArray(steps)) throw new Error(`Invalid browser RPC request: ${label}.steps must be an array`);
1834
+ return value;
1835
+ };
1836
+ const validateBrowserRpcRequest = (payload)=>{
1837
+ if (!isRecord(payload)) throw new Error('Invalid browser RPC request: payload must be an object');
1838
+ const kind = readString(payload, 'kind', 'kind');
1839
+ if ('locator' !== kind && 'expect' !== kind && 'config' !== kind) throw new Error(`Invalid browser RPC request: unsupported kind ${JSON.stringify(kind)}`);
1840
+ const request = {
1841
+ id: readString(payload, 'id', 'id'),
1842
+ testPath: readString(payload, 'testPath', 'testPath'),
1843
+ runId: readString(payload, 'runId', 'runId'),
1844
+ kind,
1845
+ locator: parseBrowserLocatorIR(payload.locator, 'locator'),
1846
+ method: readString(payload, 'method', 'method'),
1847
+ args: readUnknownArray(payload, 'args', 'args')
1848
+ };
1849
+ const isNot = payload.isNot;
1850
+ if (void 0 !== isNot) {
1851
+ if ('boolean' != typeof isNot) throw new Error('Invalid browser RPC request: isNot must be a boolean');
1852
+ request.isNot = isNot;
1853
+ }
1854
+ const timeout = payload.timeout;
1855
+ if (void 0 !== timeout) {
1856
+ if ('number' != typeof timeout) throw new Error('Invalid browser RPC request: timeout must be a number');
1857
+ request.timeout = timeout;
1858
+ }
1859
+ return request;
1860
+ };
1861
+ const supportedLocatorActions = new Set([
1862
+ 'click',
1863
+ 'dblclick',
1864
+ 'fill',
1865
+ 'hover',
1866
+ 'press',
1867
+ 'clear',
1868
+ 'check',
1869
+ 'uncheck',
1870
+ 'focus',
1871
+ 'blur',
1872
+ 'scrollIntoViewIfNeeded',
1873
+ 'waitFor',
1874
+ 'dispatchEvent',
1875
+ 'selectOption',
1876
+ 'setInputFiles'
1877
+ ]);
1878
+ const supportedExpectElementMatchers = new Set([
1879
+ 'toBeVisible',
1880
+ 'toBeHidden',
1881
+ 'toBeEnabled',
1882
+ 'toBeDisabled',
1883
+ 'toBeAttached',
1884
+ 'toBeDetached',
1885
+ 'toBeEditable',
1886
+ 'toBeFocused',
1887
+ 'toBeEmpty',
1888
+ 'toBeInViewport',
1889
+ 'toHaveText',
1890
+ 'toContainText',
1891
+ 'toHaveValue',
1892
+ 'toHaveAttribute',
1893
+ 'toHaveClass',
1894
+ 'toHaveCount',
1895
+ 'toBeChecked',
1896
+ 'toBeUnchecked',
1897
+ 'toHaveId',
1898
+ 'toHaveCSS',
1899
+ 'toHaveJSProperty'
1900
+ ]);
1901
+ const reviveBrowserLocatorText = (text)=>{
1902
+ if ('string' === text.type) return text.value;
1903
+ return new RegExp(text.source, text.flags);
1904
+ };
1905
+ const compilePlaywrightLocator = (frame, locatorIR)=>{
1906
+ const compileFromFrame = (ir)=>{
1907
+ let current = frame;
1908
+ const ensureLocator = ()=>{
1909
+ if (current.filter) return current;
1910
+ current = current.locator(':root');
1911
+ return current;
1912
+ };
1913
+ for (const step of ir.steps)switch(step.type){
1914
+ case 'getByRole':
1915
+ {
1916
+ const name = step.options?.name ? reviveBrowserLocatorText(step.options.name) : void 0;
1917
+ const options = step.options ? {
1918
+ ...step.options,
1919
+ name
1920
+ } : void 0;
1921
+ current = current.getByRole(step.role, options);
1922
+ break;
1923
+ }
1924
+ case 'locator':
1925
+ current = current.locator(step.selector);
1926
+ break;
1927
+ case 'getByText':
1928
+ current = current.getByText(reviveBrowserLocatorText(step.text), step.options);
1929
+ break;
1930
+ case 'getByLabel':
1931
+ current = current.getByLabel(reviveBrowserLocatorText(step.text), step.options);
1932
+ break;
1933
+ case 'getByPlaceholder':
1934
+ current = current.getByPlaceholder(reviveBrowserLocatorText(step.text), step.options);
1935
+ break;
1936
+ case 'getByAltText':
1937
+ current = current.getByAltText(reviveBrowserLocatorText(step.text), step.options);
1938
+ break;
1939
+ case 'getByTitle':
1940
+ current = current.getByTitle(reviveBrowserLocatorText(step.text), step.options);
1941
+ break;
1942
+ case 'getByTestId':
1943
+ current = current.getByTestId(reviveBrowserLocatorText(step.text));
1944
+ break;
1945
+ case 'filter':
1946
+ {
1947
+ const locator = ensureLocator();
1948
+ const options = {};
1949
+ if (step.options?.hasText) options.hasText = reviveBrowserLocatorText(step.options.hasText);
1950
+ if (step.options?.hasNotText) options.hasNotText = reviveBrowserLocatorText(step.options.hasNotText);
1951
+ if (step.options?.has) options.has = compileFromFrame(step.options.has);
1952
+ if (step.options?.hasNot) options.hasNot = compileFromFrame(step.options.hasNot);
1953
+ current = locator.filter(options);
1954
+ break;
1955
+ }
1956
+ case 'and':
1957
+ {
1958
+ const locator = ensureLocator();
1959
+ const other = compileFromFrame(step.locator);
1960
+ current = locator.and(other);
1961
+ break;
1962
+ }
1963
+ case 'or':
1964
+ {
1965
+ const locator = ensureLocator();
1966
+ const other = compileFromFrame(step.locator);
1967
+ current = locator.or(other);
1968
+ break;
1969
+ }
1970
+ case 'nth':
1971
+ {
1972
+ const locator = ensureLocator();
1973
+ current = locator.nth(step.index);
1974
+ break;
1975
+ }
1976
+ case 'first':
1977
+ {
1978
+ const locator = ensureLocator();
1979
+ current = locator.first();
1980
+ break;
1981
+ }
1982
+ case 'last':
1983
+ {
1984
+ const locator = ensureLocator();
1985
+ current = locator.last();
1986
+ break;
1987
+ }
1988
+ default:
1989
+ throw new Error(`Unknown locator step: ${String(step?.type)}`);
1990
+ }
1991
+ return ensureLocator();
1992
+ };
1993
+ return compileFromFrame(locatorIR);
1994
+ };
1995
+ const serializeExpectedText = (text, options)=>{
1996
+ const base = {
1997
+ matchSubstring: options?.matchSubstring,
1998
+ ignoreCase: options?.ignoreCase,
1999
+ normalizeWhiteSpace: options?.normalizeWhiteSpace
2000
+ };
2001
+ if ('string' === text.type) return [
2002
+ {
2003
+ ...base,
2004
+ string: text.value
2005
+ }
2006
+ ];
2007
+ return [
2008
+ {
2009
+ ...base,
2010
+ regexSource: text.source,
2011
+ regexFlags: text.flags
2012
+ }
2013
+ ];
2014
+ };
2015
+ const formatExpectError = (result)=>{
2016
+ const parts = [];
2017
+ if (result.errorMessage) parts.push(result.errorMessage);
2018
+ if (result.log?.length) {
2019
+ parts.push('Call log:');
2020
+ parts.push(...result.log.map((l)=>`- ${l}`));
2021
+ }
2022
+ return parts.join('\n');
2023
+ };
2024
+ const escapeCssAttrValue = (value)=>value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
2025
+ const getRunnerFrame = async (containerPage, testPath, timeoutMs)=>{
2026
+ const selector = `iframe[data-test-file='${escapeCssAttrValue(testPath)}']`;
2027
+ const iframe = containerPage.locator(selector);
2028
+ const count = await iframe.count();
2029
+ if (0 === count) {
2030
+ const known = await containerPage.locator('iframe[data-test-file]').evaluateAll((nodes)=>nodes.map((n)=>n.dataset.testFile));
2031
+ throw new Error(`Runner iframe not found for testPath: ${JSON.stringify(testPath)}. Known iframes: ${JSON.stringify(known)}. Timeout: ${timeoutMs}ms`);
2032
+ }
2033
+ return containerPage.frameLocator(selector);
2034
+ };
2035
+ const callExpect = async (locator, expectMethod, options, fallbackMessage)=>{
2036
+ const result = await locator._expect(expectMethod, options);
2037
+ if (!options.isNot !== result.matches) throw new Error(formatExpectError(result) || fallbackMessage);
2038
+ return null;
2039
+ };
2040
+ const assertSerializedText = (value, matcherName)=>{
2041
+ const t = value;
2042
+ if (!t || 'string' !== t.type && 'regexp' !== t.type) throw new Error(`${matcherName} expects a serialized text matcher`);
2043
+ return t;
2044
+ };
2045
+ const assertStringArg = (value, matcherName, label)=>{
2046
+ if ('string' != typeof value || !value) throw new Error(`${matcherName} expects ${label}`);
2047
+ return value;
2048
+ };
2049
+ const simpleMatchers = {
2050
+ toBeVisible: 'to.be.visible',
2051
+ toBeHidden: 'to.be.hidden',
2052
+ toBeEnabled: 'to.be.enabled',
2053
+ toBeDisabled: 'to.be.disabled',
2054
+ toBeAttached: 'to.be.attached',
2055
+ toBeDetached: 'to.be.detached',
2056
+ toBeEditable: 'to.be.editable',
2057
+ toBeFocused: 'to.be.focused',
2058
+ toBeEmpty: 'to.be.empty'
2059
+ };
2060
+ const textMatchers = {
2061
+ toHaveId: {
2062
+ expectMethod: 'to.have.id'
2063
+ },
2064
+ toHaveText: {
2065
+ expectMethod: 'to.have.text',
2066
+ textOptions: {
2067
+ normalizeWhiteSpace: true
2068
+ }
2069
+ },
2070
+ toContainText: {
2071
+ expectMethod: 'to.have.text',
2072
+ textOptions: {
2073
+ matchSubstring: true,
2074
+ normalizeWhiteSpace: true
2075
+ }
2076
+ },
2077
+ toHaveValue: {
2078
+ expectMethod: 'to.have.value'
2079
+ },
2080
+ toHaveClass: {
2081
+ expectMethod: 'to.have.class'
2082
+ }
2083
+ };
2084
+ const dispatchExpectMatcher = (locator, request, isNot, timeout)=>{
2085
+ const { method, args } = request;
2086
+ const simpleExpect = simpleMatchers[method];
2087
+ if (simpleExpect) return callExpect(locator, simpleExpect, {
2088
+ isNot,
2089
+ timeout
2090
+ }, `Expected element ${method.replace('toBe', 'to be ').replace(/([A-Z])/g, ' $1').trim().toLowerCase()}`);
2091
+ const textDef = textMatchers[method];
2092
+ if (textDef) {
2093
+ const expected = assertSerializedText(args[0], method);
2094
+ return callExpect(locator, textDef.expectMethod, {
2095
+ isNot,
2096
+ timeout,
2097
+ expectedText: serializeExpectedText(expected, textDef.textOptions)
2098
+ }, `Expected element ${method}`);
2099
+ }
2100
+ switch(method){
2101
+ case 'toBeInViewport':
2102
+ {
2103
+ const ratio = args[0];
2104
+ if (void 0 !== ratio && 'number' != typeof ratio) throw new Error(`toBeInViewport expects ratio to be a number, got ${typeof ratio}`);
2105
+ return callExpect(locator, 'to.be.in.viewport', {
2106
+ isNot,
2107
+ timeout,
2108
+ expectedNumber: ratio
2109
+ }, 'Expected element to be in viewport');
2110
+ }
2111
+ case 'toBeChecked':
2112
+ return callExpect(locator, 'to.be.checked', {
2113
+ isNot,
2114
+ timeout,
2115
+ expectedValue: {
2116
+ checked: true
2117
+ }
2118
+ }, 'Expected element to be checked');
2119
+ case 'toBeUnchecked':
2120
+ return callExpect(locator, 'to.be.checked', {
2121
+ isNot,
2122
+ timeout,
2123
+ expectedValue: {
2124
+ checked: false
2125
+ }
2126
+ }, 'Expected element to be unchecked');
2127
+ case 'toHaveCount':
2128
+ {
2129
+ const expected = args[0];
2130
+ if ('number' != typeof expected) throw new Error(`toHaveCount expects a number, got ${typeof expected}`);
2131
+ return callExpect(locator, 'to.have.count', {
2132
+ isNot,
2133
+ timeout,
2134
+ expectedNumber: expected
2135
+ }, `Expected count ${expected}`);
2136
+ }
2137
+ case 'toHaveAttribute':
2138
+ {
2139
+ const name = assertStringArg(args[0], 'toHaveAttribute', 'an attribute name');
2140
+ if (args.length < 2) return callExpect(locator, 'to.have.attribute', {
2141
+ isNot,
2142
+ timeout,
2143
+ expressionArg: name
2144
+ }, `Expected attribute ${name} to be present`);
2145
+ const expected = assertSerializedText(args[1], 'toHaveAttribute');
2146
+ return callExpect(locator, 'to.have.attribute.value', {
2147
+ isNot,
2148
+ timeout,
2149
+ expressionArg: name,
2150
+ expectedText: serializeExpectedText(expected)
2151
+ }, `Expected attribute ${name} to match`);
2152
+ }
2153
+ case 'toHaveCSS':
2154
+ {
2155
+ const name = assertStringArg(args[0], 'toHaveCSS', 'a CSS property name');
2156
+ const expected = assertSerializedText(args[1], 'toHaveCSS');
2157
+ return callExpect(locator, 'to.have.css', {
2158
+ isNot,
2159
+ timeout,
2160
+ expressionArg: name,
2161
+ expectedText: serializeExpectedText(expected)
2162
+ }, `Expected CSS ${name} to match`);
2163
+ }
2164
+ case 'toHaveJSProperty':
2165
+ {
2166
+ const name = assertStringArg(args[0], 'toHaveJSProperty', 'a property name');
2167
+ const expectedValue = args[1];
2168
+ try {
2169
+ JSON.stringify(expectedValue);
2170
+ } catch {
2171
+ throw new Error('toHaveJSProperty expects a JSON-serializable expected value');
2172
+ }
2173
+ return callExpect(locator, 'to.have.property', {
2174
+ isNot,
2175
+ timeout,
2176
+ expressionArg: name,
2177
+ expectedValue
2178
+ }, `Expected JS property ${name} to match`);
2179
+ }
2180
+ }
2181
+ throw new Error(`Unhandled expect matcher: ${method}`);
2182
+ };
2183
+ const dispatchConfigMethod = async (request)=>{
2184
+ switch(request.method){
2185
+ case 'setTestIdAttribute':
2186
+ {
2187
+ const attr = request.args[0];
2188
+ if ('string' != typeof attr || !attr) throw new Error('setTestIdAttribute expects a non-empty string argument');
2189
+ const playwright = await import("playwright");
2190
+ playwright.selectors.setTestIdAttribute(attr);
2191
+ return null;
2192
+ }
2193
+ default:
2194
+ throw new Error(`Unknown config method: ${request.method}`);
2195
+ }
2196
+ };
2197
+ async function dispatchPlaywrightBrowserRpc({ containerPage, runnerPage, request, timeoutFallbackMs }) {
2198
+ if ('config' === request.kind) return dispatchConfigMethod(request);
2199
+ const testPath = request.testPath;
2200
+ if (!testPath) throw new Error('Browser RPC request is missing testPath');
2201
+ const timeout = 'number' == typeof request.timeout ? request.timeout : timeoutFallbackMs;
2202
+ const locatorRoot = runnerPage ? runnerPage : await getRunnerFrame(containerPage ?? (()=>{
2203
+ throw new Error('Browser container page is not initialized');
2204
+ })(), testPath, timeout);
2205
+ const locator = compilePlaywrightLocator(locatorRoot, request.locator);
2206
+ if ('locator' === request.kind) {
2207
+ if (!supportedLocatorActions.has(request.method)) throw new Error(`Locator method not supported: ${request.method}`);
2208
+ const target = locator;
2209
+ return await target[request.method](...request.args);
2210
+ }
2211
+ if ('expect' === request.kind) {
2212
+ if (!supportedExpectElementMatchers.has(request.method)) throw new Error(`Expect matcher not supported: ${request.method}`);
2213
+ return dispatchExpectMatcher(locator, request, !!request.isNot, timeout);
2214
+ }
2215
+ throw new Error(`Unknown browser rpc kind: ${request.kind}`);
2216
+ }
2217
+ async function launchPlaywrightBrowser({ browserName, headless }) {
2218
+ const playwright = await import("playwright");
2219
+ const browserType = playwright[browserName];
2220
+ const browser = await browserType.launch({
2221
+ headless,
2222
+ args: 'chromium' === browserName ? [
2223
+ '--disable-popup-blocking',
2224
+ '--no-first-run',
2225
+ '--no-default-browser-check'
2226
+ ] : void 0
2227
+ });
2228
+ return {
2229
+ browser: browser
2230
+ };
2231
+ }
2232
+ const playwrightProviderImplementation = {
2233
+ name: 'playwright',
2234
+ async launchRuntime ({ browserName, headless }) {
2235
+ return launchPlaywrightBrowser({
2236
+ browserName,
2237
+ headless
2238
+ });
2239
+ },
2240
+ async dispatchRpc ({ containerPage, runnerPage, request, timeoutFallbackMs }) {
2241
+ return dispatchPlaywrightBrowserRpc({
2242
+ containerPage: containerPage,
2243
+ runnerPage: runnerPage,
2244
+ request,
2245
+ timeoutFallbackMs
2246
+ });
2247
+ }
2248
+ };
2249
+ const providerImplementations = {
2250
+ playwright: playwrightProviderImplementation
2251
+ };
2252
+ function getBrowserProviderImplementation(provider) {
2253
+ const implementation = providerImplementations[provider];
2254
+ if (!implementation) throw new Error(`Unsupported browser provider: ${String(provider)}`);
2255
+ return implementation;
2256
+ }
2257
+ const createCancelSignal = ()=>{
2258
+ let settled = false;
2259
+ let resolveSignal = ()=>{};
2260
+ const signal = new Promise((resolve)=>{
2261
+ resolveSignal = ()=>{
2262
+ if (!settled) {
2263
+ settled = true;
2264
+ resolve();
2265
+ }
2266
+ };
2267
+ });
2268
+ return {
2269
+ signal,
2270
+ resolve: resolveSignal
2271
+ };
2272
+ };
2273
+ const createRunSession = (token)=>{
2274
+ const { signal, resolve } = createCancelSignal();
2275
+ return {
2276
+ token,
2277
+ cancelled: false,
2278
+ cancelSignal: signal,
2279
+ signalCancel: resolve
2280
+ };
2281
+ };
2282
+ class RunSessionLifecycle {
2283
+ currentToken = 0;
2284
+ active = null;
2285
+ get activeSession() {
2286
+ return this.active;
2287
+ }
2288
+ get activeToken() {
2289
+ return this.currentToken;
2290
+ }
2291
+ createSession(factory) {
2292
+ const session = factory(++this.currentToken);
2293
+ this.active = session;
2294
+ return session;
2295
+ }
2296
+ isTokenActive(token) {
2297
+ return token === this.currentToken;
2298
+ }
2299
+ isTokenStale(token) {
2300
+ return !this.isTokenActive(token);
2301
+ }
2302
+ invalidateActiveToken() {
2303
+ this.currentToken += 1;
2304
+ return this.currentToken;
2305
+ }
2306
+ clearIfActive(session) {
2307
+ if (this.active === session) this.active = null;
2308
+ }
2309
+ async cancel(session, options) {
2310
+ const waitForDone = options?.waitForDone ?? true;
2311
+ if (!session.cancelled) {
2312
+ session.cancelled = true;
2313
+ session.signalCancel();
2314
+ await options?.onCancel?.(session);
2315
+ }
2316
+ if (waitForDone) await session.done?.catch(()=>{});
2317
+ }
2318
+ async cancelActive(options) {
2319
+ if (!this.active) return;
2320
+ await this.cancel(this.active, options);
2321
+ }
2322
+ }
2323
+ class RunnerSessionRegistry {
2324
+ nextId = 0;
2325
+ sessionsById = new Map();
2326
+ sessionIdByTestFile = new Map();
2327
+ register(input) {
2328
+ const id = input.id ?? `runner-session-${++this.nextId}`;
2329
+ const createdAt = input.createdAt ?? Date.now();
2330
+ const record = {
2331
+ ...input,
2332
+ id,
2333
+ createdAt
2334
+ };
2335
+ this.sessionsById.set(id, record);
2336
+ this.sessionIdByTestFile.set(record.testFile, id);
2337
+ return record;
2338
+ }
2339
+ getById(id) {
2340
+ return this.sessionsById.get(id);
2341
+ }
2342
+ getByTestFile(testFile) {
2343
+ const id = this.sessionIdByTestFile.get(testFile);
2344
+ if (!id) return;
2345
+ return this.sessionsById.get(id);
2346
+ }
2347
+ list() {
2348
+ return Array.from(this.sessionsById.values());
2349
+ }
2350
+ listByRunToken(runToken) {
2351
+ return this.list().filter((session)=>session.runToken === runToken);
2352
+ }
2353
+ deleteById(id) {
2354
+ const record = this.sessionsById.get(id);
2355
+ if (!record) return false;
2356
+ this.sessionsById.delete(id);
2357
+ if (this.sessionIdByTestFile.get(record.testFile) === id) this.sessionIdByTestFile.delete(record.testFile);
2358
+ return true;
2359
+ }
2360
+ deleteByTestFile(testFile) {
2361
+ const id = this.sessionIdByTestFile.get(testFile);
2362
+ if (!id) return false;
2363
+ return this.deleteById(id);
2364
+ }
2365
+ clear() {
2366
+ this.sessionsById.clear();
2367
+ this.sessionIdByTestFile.clear();
2368
+ }
2369
+ }
2370
+ const normalizeJavaScriptUrl = (value, options)=>{
2371
+ try {
2372
+ const url = options?.origin ? new URL(value, options.origin) : new URL(value);
2373
+ if ('http:' !== url.protocol && 'https:' !== url.protocol) return null;
2374
+ url.search = '';
2375
+ url.hash = '';
2376
+ return url.toString();
2377
+ } catch {
2378
+ return null;
2379
+ }
2380
+ };
2381
+ const resolveInlineSourceMap = (code)=>{
2382
+ const converter = convert_source_map.fromSource(code);
2383
+ if (!converter) return null;
2384
+ return converter.toObject();
2385
+ };
2386
+ const fetchSourceMap = async (jsUrl, fetcher)=>{
2387
+ const jsResponse = await fetcher(jsUrl);
2388
+ if (!jsResponse.ok) return null;
2389
+ const code = await jsResponse.text();
2390
+ const inlineMap = resolveInlineSourceMap(code);
2391
+ if (inlineMap) return inlineMap;
2392
+ const mapResponse = await fetcher(`${jsUrl}.map`);
2393
+ if (!mapResponse.ok) return null;
2394
+ return await mapResponse.json();
2395
+ };
2396
+ const loadSourceMapWithCache = async ({ jsUrl, cache, force = false, origin, fetcher = fetch })=>{
2397
+ const normalizedUrl = normalizeJavaScriptUrl(jsUrl, {
2398
+ origin
2399
+ });
2400
+ if (!normalizedUrl) return null;
2401
+ if (!force && cache.has(normalizedUrl)) return cache.get(normalizedUrl) ?? null;
2402
+ try {
2403
+ const sourceMap = await fetchSourceMap(normalizedUrl, fetcher);
2404
+ cache.set(normalizedUrl, sourceMap);
2405
+ return sourceMap;
2406
+ } catch {
2407
+ cache.set(normalizedUrl, null);
2408
+ return null;
2409
+ }
2410
+ };
2411
+ const BROWSER_VIEWPORT_PRESET_IDS = [
2412
+ 'iPhoneSE',
2413
+ 'iPhoneXR',
2414
+ 'iPhone12Pro',
2415
+ 'iPhone14ProMax',
2416
+ 'Pixel7',
2417
+ 'SamsungGalaxyS8Plus',
2418
+ 'SamsungGalaxyS20Ultra',
2419
+ 'iPadMini',
2420
+ 'iPadAir',
2421
+ 'iPadPro',
2422
+ 'SurfacePro7',
2423
+ 'SurfaceDuo',
2424
+ 'GalaxyZFold5',
2425
+ 'AsusZenbookFold',
2426
+ 'SamsungGalaxyA51A71',
2427
+ 'NestHub',
2428
+ 'NestHubMax'
2429
+ ];
2430
+ const BROWSER_VIEWPORT_PRESET_DIMENSIONS = {
2431
+ iPhoneSE: {
2432
+ width: 375,
2433
+ height: 667
2434
+ },
2435
+ iPhoneXR: {
2436
+ width: 414,
2437
+ height: 896
2438
+ },
2439
+ iPhone12Pro: {
2440
+ width: 390,
2441
+ height: 844
2442
+ },
2443
+ iPhone14ProMax: {
2444
+ width: 430,
2445
+ height: 932
2446
+ },
2447
+ Pixel7: {
2448
+ width: 412,
2449
+ height: 915
2450
+ },
2451
+ SamsungGalaxyS8Plus: {
2452
+ width: 360,
2453
+ height: 740
2454
+ },
2455
+ SamsungGalaxyS20Ultra: {
2456
+ width: 412,
2457
+ height: 915
2458
+ },
2459
+ iPadMini: {
2460
+ width: 768,
2461
+ height: 1024
2462
+ },
2463
+ iPadAir: {
2464
+ width: 820,
2465
+ height: 1180
2466
+ },
2467
+ iPadPro: {
2468
+ width: 1024,
2469
+ height: 1366
2470
+ },
2471
+ SurfacePro7: {
2472
+ width: 912,
2473
+ height: 1368
2474
+ },
2475
+ SurfaceDuo: {
2476
+ width: 540,
2477
+ height: 720
2478
+ },
2479
+ GalaxyZFold5: {
2480
+ width: 344,
2481
+ height: 882
2482
+ },
2483
+ AsusZenbookFold: {
2484
+ width: 853,
2485
+ height: 1280
2486
+ },
2487
+ SamsungGalaxyA51A71: {
2488
+ width: 412,
2489
+ height: 914
2490
+ },
2491
+ NestHub: {
2492
+ width: 1024,
2493
+ height: 600
2494
+ },
2495
+ NestHubMax: {
2496
+ width: 1280,
2497
+ height: 800
2498
+ }
2499
+ };
2500
+ const resolveBrowserViewportPreset = (presetId)=>{
2501
+ const size = BROWSER_VIEWPORT_PRESET_DIMENSIONS[presetId];
2502
+ return size ?? null;
2503
+ };
2504
+ const isTTY = ()=>Boolean(process.stdin.isTTY && !process.env.CI);
2505
+ const isBrowserWatchCliShortcutsEnabled = ()=>isTTY();
2506
+ const getBrowserWatchCliShortcutsHintMessage = ()=>` ${color.dim('press')} ${color.bold('q')} ${color.dim('to quit')}\n`;
2507
+ const logBrowserWatchReadyMessage = (enableCliShortcuts)=>{
2508
+ logger.log(color.green(' Waiting for file changes...'));
2509
+ if (enableCliShortcuts) logger.log(getBrowserWatchCliShortcutsHintMessage());
2510
+ };
2511
+ async function setupBrowserWatchCliShortcuts({ close }) {
2512
+ const { emitKeypressEvents } = await import("node:readline");
2513
+ emitKeypressEvents(process.stdin);
2514
+ process.stdin.setRawMode(true);
2515
+ process.stdin.resume();
2516
+ process.stdin.setEncoding('utf8');
2517
+ let isClosing = false;
2518
+ const handleKeypress = (str, key)=>{
2519
+ if (key.ctrl && 'c' === key.name) return void process.kill(process.pid, 'SIGINT');
2520
+ if (key.ctrl && 'z' === key.name) {
2521
+ if ('win32' !== process.platform) process.kill(process.pid, 'SIGTSTP');
2522
+ return;
2523
+ }
2524
+ if ('q' !== str || isClosing) return;
2525
+ isClosing = true;
2526
+ (async ()=>{
2527
+ try {
2528
+ await close();
2529
+ } finally{
2530
+ process.exit(0);
2531
+ }
2532
+ })();
2533
+ };
2534
+ process.stdin.on('keypress', handleKeypress);
2535
+ return ()=>{
2536
+ try {
2537
+ process.stdin.setRawMode(false);
2538
+ process.stdin.pause();
2539
+ } catch {}
2540
+ process.stdin.off('keypress', handleKeypress);
2541
+ };
2542
+ }
2543
+ const serializeTestFiles = (files)=>JSON.stringify(files.map((f)=>`${f.projectName}:${f.testPath}`).sort());
2544
+ const normalizeTestFiles = (files)=>files.map((file)=>({
2545
+ ...file,
2546
+ testPath: normalize(file.testPath)
2547
+ }));
2548
+ const collectWatchTestFiles = (projectEntries)=>projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
2549
+ testPath: normalize(testPath),
2550
+ projectName: entry.project.name
2551
+ })));
2552
+ const planWatchRerun = ({ projectEntries, previousTestFiles, affectedTestFiles })=>{
2553
+ const currentTestFiles = collectWatchTestFiles(projectEntries);
2554
+ const normalizedPrevious = normalizeTestFiles(previousTestFiles);
2555
+ const filesChanged = serializeTestFiles(currentTestFiles) !== serializeTestFiles(normalizedPrevious);
2556
+ const normalizedAffectedTestFiles = affectedTestFiles.map((testFile)=>normalize(testFile));
2557
+ const currentFileMap = new Map(currentTestFiles.map((file)=>[
2558
+ file.testPath,
2559
+ file
2560
+ ]));
2561
+ const matchedAffectedFiles = normalizedAffectedTestFiles.map((testFile)=>currentFileMap.get(testFile)).filter((file)=>Boolean(file));
2562
+ return {
2563
+ currentTestFiles,
2564
+ filesChanged,
2565
+ normalizedAffectedTestFiles,
2566
+ affectedTestFiles: matchedAffectedFiles
2567
+ };
2568
+ };
1613
2569
  const picomatch = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.3/node_modules/picomatch/index.js");
1614
2570
  const { createRsbuild: createRsbuild, rspack: rspack } = rsbuild;
1615
2571
  const hostController_dirname = dirname(fileURLToPath(import.meta.url));
@@ -1682,12 +2638,68 @@ const watchContext = {
1682
2638
  lastTestFiles: [],
1683
2639
  hooksEnabled: false,
1684
2640
  cleanupRegistered: false,
2641
+ cleanupPromise: null,
2642
+ closeCliShortcuts: null,
1685
2643
  chunkHashes: new Map(),
1686
2644
  affectedTestFiles: []
1687
2645
  };
2646
+ const resolveViewport = (viewport)=>{
2647
+ if (!viewport) return null;
2648
+ if ('string' == typeof viewport) return resolveBrowserViewportPreset(viewport);
2649
+ if ('number' == typeof viewport.width && Number.isFinite(viewport.width) && viewport.width > 0 && 'number' == typeof viewport.height && Number.isFinite(viewport.height) && viewport.height > 0) return {
2650
+ width: viewport.width,
2651
+ height: viewport.height
2652
+ };
2653
+ return null;
2654
+ };
2655
+ const mapViewportByProject = (projects)=>{
2656
+ const map = new Map();
2657
+ for (const project of projects){
2658
+ const viewport = resolveViewport(project.viewport);
2659
+ if (viewport) map.set(project.name, viewport);
2660
+ }
2661
+ return map;
2662
+ };
1688
2663
  const ensureProcessExitCode = (code)=>{
1689
2664
  if (void 0 === process.exitCode || 0 === process.exitCode) process.exitCode = code;
1690
2665
  };
2666
+ const castArray = (arr)=>{
2667
+ if (void 0 === arr) return [];
2668
+ return Array.isArray(arr) ? arr : [
2669
+ arr
2670
+ ];
2671
+ };
2672
+ const applyDefaultWatchOptions = (rspackConfig, isWatchMode)=>{
2673
+ rspackConfig.watchOptions ??= {};
2674
+ if (!isWatchMode) {
2675
+ rspackConfig.watchOptions.ignored = '**/**';
2676
+ return;
2677
+ }
2678
+ rspackConfig.watchOptions.ignored = castArray(rspackConfig.watchOptions.ignored || []);
2679
+ if (0 === rspackConfig.watchOptions.ignored.length) rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
2680
+ rspackConfig.output?.path && rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
2681
+ };
2682
+ const createBrowserLazyCompilationConfig = (setupFiles)=>{
2683
+ const eagerSetupFiles = new Set(setupFiles.map((filePath)=>normalize(filePath)));
2684
+ if (0 === eagerSetupFiles.size) return {
2685
+ imports: true,
2686
+ entries: false
2687
+ };
2688
+ return {
2689
+ imports: true,
2690
+ entries: false,
2691
+ test (module) {
2692
+ const filePath = module.nameForCondition?.();
2693
+ return !filePath || !eagerSetupFiles.has(normalize(filePath));
2694
+ }
2695
+ };
2696
+ };
2697
+ const createBrowserRsbuildDevConfig = (isWatchMode)=>({
2698
+ hmr: isWatchMode,
2699
+ client: {
2700
+ logLevel: 'error'
2701
+ }
2702
+ });
1691
2703
  const globToRegexp = (glob)=>{
1692
2704
  const regex = picomatch.makeRe(glob, {
1693
2705
  fastpaths: false,
@@ -1796,6 +2808,31 @@ const getRuntimeConfigFromProject = (project)=>{
1796
2808
  };
1797
2809
  };
1798
2810
  const getBrowserProjects = (context)=>context.projects.filter((project)=>project.normalizedConfig.browser.enabled);
2811
+ const getBrowserLaunchOptions = (project)=>({
2812
+ provider: project.normalizedConfig.browser.provider,
2813
+ browser: project.normalizedConfig.browser.browser,
2814
+ headless: project.normalizedConfig.browser.headless,
2815
+ port: project.normalizedConfig.browser.port,
2816
+ strictPort: project.normalizedConfig.browser.strictPort
2817
+ });
2818
+ const ensureConsistentBrowserLaunchOptions = (projects)=>{
2819
+ if (0 === projects.length) throw new Error('No browser-enabled projects found.');
2820
+ const firstProject = projects[0];
2821
+ const firstOptions = getBrowserLaunchOptions(firstProject);
2822
+ for (const project of projects.slice(1)){
2823
+ const options = getBrowserLaunchOptions(project);
2824
+ 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.`);
2825
+ }
2826
+ return firstOptions;
2827
+ };
2828
+ const resolveProviderForTestPath = ({ testPath, browserProjects })=>{
2829
+ const normalizedTestPath = normalize(testPath);
2830
+ const sortedProjects = [
2831
+ ...browserProjects
2832
+ ].sort((a, b)=>b.rootPath.length - a.rootPath.length);
2833
+ for (const project of sortedProjects)if (normalizedTestPath.startsWith(project.rootPath)) return project.provider;
2834
+ throw new Error(`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. Known project roots: ${JSON.stringify(sortedProjects.map((p)=>p.rootPath))}`);
2835
+ };
1799
2836
  const collectProjectEntries = async (context)=>{
1800
2837
  const projectEntries = [];
1801
2838
  const browserProjects = getBrowserProjects(context);
@@ -1909,20 +2946,6 @@ const htmlTemplate = `<!DOCTYPE html>
1909
2946
  </body>
1910
2947
  </html>
1911
2948
  `;
1912
- const fallbackSchedulerHtmlTemplate = `<!DOCTYPE html>
1913
- <html lang="en">
1914
- <head>
1915
- <meta charset="UTF-8" />
1916
- <title>Rstest Browser Scheduler</title>
1917
- <script>
1918
- window.__RSTEST_BROWSER_OPTIONS__ = ${OPTIONS_PLACEHOLDER};
1919
- </script>
1920
- </head>
1921
- <body>
1922
- <script type="module" src="/container-static/js/scheduler.js"></script>
1923
- </body>
1924
- </html>
1925
- `;
1926
2949
  const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1927
2950
  const destroyBrowserRuntime = async (runtime)=>{
1928
2951
  try {
@@ -1939,21 +2962,28 @@ const destroyBrowserRuntime = async (runtime)=>{
1939
2962
  force: true
1940
2963
  }).catch(()=>{});
1941
2964
  };
1942
- const registerWatchCleanup = ()=>{
1943
- if (watchContext.cleanupRegistered) return;
1944
- const cleanup = async ()=>{
2965
+ const cleanupWatchRuntime = ()=>{
2966
+ if (watchContext.cleanupPromise) return watchContext.cleanupPromise;
2967
+ watchContext.cleanupPromise = (async ()=>{
2968
+ watchContext.closeCliShortcuts?.();
2969
+ watchContext.closeCliShortcuts = null;
1945
2970
  if (!watchContext.runtime) return;
1946
2971
  await destroyBrowserRuntime(watchContext.runtime);
1947
2972
  watchContext.runtime = null;
1948
- };
2973
+ })();
2974
+ return watchContext.cleanupPromise;
2975
+ };
2976
+ const registerWatchCleanup = ()=>{
2977
+ if (watchContext.cleanupRegistered) return;
1949
2978
  for (const signal of [
1950
2979
  'SIGINT',
1951
- 'SIGTERM'
2980
+ 'SIGTERM',
2981
+ 'SIGTSTP'
1952
2982
  ])process.once(signal, ()=>{
1953
- cleanup();
2983
+ cleanupWatchRuntime();
1954
2984
  });
1955
2985
  process.once('exit', ()=>{
1956
- cleanup();
2986
+ cleanupWatchRuntime();
1957
2987
  });
1958
2988
  watchContext.cleanupRegistered = true;
1959
2989
  };
@@ -1962,24 +2992,25 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
1962
2992
  [manifestPath]: manifestSource
1963
2993
  });
1964
2994
  const containerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'index.html'), 'utf-8') : null;
1965
- const schedulerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'scheduler.html'), 'utf-8').catch(()=>null) : null;
1966
2995
  let injectedContainerHtml = null;
1967
- let injectedSchedulerHtml = null;
1968
2996
  let serializedOptions = 'null';
2997
+ const dispatchHandlers = new Map();
1969
2998
  const setContainerOptions = (options)=>{
1970
2999
  serializedOptions = serializeForInlineScript(options);
1971
3000
  if (containerHtmlTemplate) injectedContainerHtml = containerHtmlTemplate.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1972
- injectedSchedulerHtml = (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(OPTIONS_PLACEHOLDER, serializedOptions);
1973
3001
  };
1974
3002
  const browserProjects = getBrowserProjects(context);
1975
- const firstProject = browserProjects[0];
1976
- const userPlugins = firstProject?.normalizedConfig.plugins || [];
1977
- const userRsbuildConfig = firstProject?.normalizedConfig ?? {};
1978
- const browserConfig = firstProject?.normalizedConfig.browser ?? context.normalizedConfig.browser;
3003
+ const projectByEnvironmentName = new Map(browserProjects.map((project)=>[
3004
+ project.environmentName,
3005
+ project
3006
+ ]));
3007
+ const userPlugins = browserProjects.flatMap((project)=>project.normalizedConfig.plugins || []);
3008
+ const browserLaunchOptions = ensureConsistentBrowserLaunchOptions(browserProjects);
1979
3009
  const browserRuntimePath = fileURLToPath(import.meta.resolve('@rstest/core/browser-runtime'));
1980
3010
  const rstestInternalAliases = {
1981
3011
  '@rstest/browser-manifest': manifestPath,
1982
3012
  '@rstest/core': resolveBrowserFile('client/public.ts'),
3013
+ '@rstest/browser': resolveBrowserFile('browser.ts'),
1983
3014
  '@rstest/core/browser-runtime': browserRuntimePath,
1984
3015
  '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts')
1985
3016
  };
@@ -1991,16 +3022,15 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
1991
3022
  plugins: userPlugins,
1992
3023
  server: {
1993
3024
  printUrls: false,
1994
- port: browserConfig.port ?? 4000,
1995
- strictPort: browserConfig.strictPort
1996
- },
1997
- dev: {
1998
- client: {
1999
- logLevel: 'error'
2000
- }
3025
+ port: browserLaunchOptions.port ?? 4000,
3026
+ strictPort: browserLaunchOptions.strictPort
2001
3027
  },
3028
+ dev: createBrowserRsbuildDevConfig(isWatchMode),
2002
3029
  environments: {
2003
- [firstProject?.environmentName || 'web']: {}
3030
+ ...Object.fromEntries(browserProjects.map((project)=>[
3031
+ project.environmentName,
3032
+ {}
3033
+ ]))
2004
3034
  }
2005
3035
  }
2006
3036
  });
@@ -2008,12 +3038,27 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2008
3038
  {
2009
3039
  name: 'rstest:browser-user-config',
2010
3040
  setup (api) {
3041
+ api.expose?.('rstest:browser', {
3042
+ registerDispatchHandler: (namespace, handler)=>{
3043
+ dispatchHandlers.set(namespace, handler);
3044
+ }
3045
+ });
2011
3046
  api.modifyEnvironmentConfig({
2012
- handler: (config, { mergeEnvironmentConfig })=>{
3047
+ handler: (config, { mergeEnvironmentConfig, name })=>{
3048
+ const project = projectByEnvironmentName.get(name);
3049
+ if (!project) return config;
3050
+ const userRsbuildConfig = project.normalizedConfig;
3051
+ const setupFiles = Object.values(getSetupFiles(project.normalizedConfig.setupFiles, project.rootPath));
2013
3052
  const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
2014
3053
  resolve: {
2015
3054
  alias: rstestInternalAliases
2016
3055
  },
3056
+ source: {
3057
+ define: {
3058
+ 'process.env': 'globalThis[Symbol.for("rstest.env")]',
3059
+ 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]'
3060
+ }
3061
+ },
2017
3062
  output: {
2018
3063
  target: 'web',
2019
3064
  sourceMap: {
@@ -2023,12 +3068,10 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2023
3068
  tools: {
2024
3069
  rspack: (rspackConfig)=>{
2025
3070
  rspackConfig.mode = 'development';
2026
- rspackConfig.lazyCompilation = {
2027
- imports: true,
2028
- entries: false
2029
- };
3071
+ rspackConfig.lazyCompilation = createBrowserLazyCompilationConfig(setupFiles);
2030
3072
  rspackConfig.plugins = rspackConfig.plugins || [];
2031
3073
  rspackConfig.plugins.push(virtualManifestPlugin);
3074
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
2032
3075
  const browserRuntimeDir = dirname(browserRuntimePath);
2033
3076
  rspackConfig.module = rspackConfig.module || {};
2034
3077
  rspackConfig.module.rules = rspackConfig.module.rules || [];
@@ -2063,7 +3106,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2063
3106
  api.onAfterDevCompile(async ({ stats })=>{
2064
3107
  if (stats) {
2065
3108
  const projectEntries = await collectProjectEntries(context);
2066
- const entryTestFiles = new Set(projectEntries.flatMap((entry)=>entry.testFiles.map((f)=>normalize(f))));
3109
+ const entryTestFiles = new Set(collectWatchTestFiles(projectEntries).map((file)=>file.testPath));
2067
3110
  const statsJson = stats.toJson({
2068
3111
  all: true
2069
3112
  });
@@ -2077,7 +3120,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2077
3120
  }
2078
3121
  }
2079
3122
  ]);
2080
- const coverage = firstProject?.normalizedConfig.coverage;
3123
+ const coverage = browserProjects.find((project)=>project.normalizedConfig.coverage?.enabled)?.normalizedConfig.coverage;
2081
3124
  if (coverage?.enabled && 'list' !== context.command) {
2082
3125
  const { pluginCoverage } = await loadCoverageProvider(coverage, context.rootPath);
2083
3126
  rsbuildInstance.addPlugins([
@@ -2157,13 +3200,8 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2157
3200
  }
2158
3201
  return;
2159
3202
  }
2160
- if ('/' === url.pathname || '/scheduler.html' === url.pathname) {
3203
+ if ('/' === url.pathname) {
2161
3204
  if (await respondWithDevServerHtml(url, res)) return;
2162
- if ('/scheduler.html' === url.pathname) {
2163
- res.setHeader('Content-Type', 'text/html');
2164
- res.end(injectedSchedulerHtml || (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(OPTIONS_PLACEHOLDER, 'null'));
2165
- return;
2166
- }
2167
3205
  const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
2168
3206
  if (html) {
2169
3207
  res.setHeader('Content-Type', 'text/html');
@@ -2198,43 +3236,31 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2198
3236
  });
2199
3237
  const wsPort = wss.address().port;
2200
3238
  logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
2201
- let browserLauncher;
2202
- const browserName = browserConfig.browser;
3239
+ const browserName = browserLaunchOptions.browser ?? 'chromium';
2203
3240
  try {
2204
- const playwright = await import("playwright");
2205
- browserLauncher = playwright[browserName];
2206
- } catch (_error) {
2207
- wss.close();
2208
- await devServer.close();
2209
- throw _error;
2210
- }
2211
- let browser;
2212
- try {
2213
- browser = await browserLauncher.launch({
2214
- headless: forceHeadless ?? browserConfig.headless,
2215
- args: 'chromium' === browserName ? [
2216
- '--disable-popup-blocking',
2217
- '--no-first-run',
2218
- '--no-default-browser-check'
2219
- ] : void 0
3241
+ const providerImplementation = getBrowserProviderImplementation(browserLaunchOptions.provider);
3242
+ const runtime = await providerImplementation.launchRuntime({
3243
+ browserName,
3244
+ headless: forceHeadless ?? browserLaunchOptions.headless
2220
3245
  });
3246
+ return {
3247
+ rsbuildInstance,
3248
+ devServer,
3249
+ browser: runtime.browser,
3250
+ port,
3251
+ wsPort,
3252
+ manifestPath,
3253
+ tempDir,
3254
+ manifestPlugin: virtualManifestPlugin,
3255
+ setContainerOptions,
3256
+ dispatchHandlers,
3257
+ wss
3258
+ };
2221
3259
  } catch (_error) {
2222
3260
  wss.close();
2223
3261
  await devServer.close();
2224
3262
  throw _error;
2225
3263
  }
2226
- return {
2227
- rsbuildInstance,
2228
- devServer,
2229
- browser,
2230
- port,
2231
- wsPort,
2232
- manifestPath,
2233
- tempDir,
2234
- manifestPlugin: virtualManifestPlugin,
2235
- setContainerOptions,
2236
- wss
2237
- };
2238
3264
  };
2239
3265
  async function resolveProjectEntries(context, shardedEntries) {
2240
3266
  if (shardedEntries) {
@@ -2259,8 +3285,36 @@ const runBrowserController = async (context, options)=>{
2259
3285
  const { skipOnTestRunEnd = false } = options ?? {};
2260
3286
  const buildStart = Date.now();
2261
3287
  const browserProjects = getBrowserProjects(context);
2262
- const useSchedulerPage = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
2263
- const buildErrorResult = async (error)=>{
3288
+ const useHeadlessDirect = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
3289
+ const browserSourceMapCache = new Map();
3290
+ const isHttpLikeFile = (file)=>/^https?:\/\//.test(file);
3291
+ const resolveBrowserSourcemap = async (sourcePath)=>{
3292
+ if (!isHttpLikeFile(sourcePath)) return {
3293
+ handled: false,
3294
+ sourcemap: null
3295
+ };
3296
+ const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
3297
+ if (!normalizedUrl) return {
3298
+ handled: true,
3299
+ sourcemap: null
3300
+ };
3301
+ if (browserSourceMapCache.has(normalizedUrl)) return {
3302
+ handled: true,
3303
+ sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null
3304
+ };
3305
+ return {
3306
+ handled: true,
3307
+ sourcemap: await loadSourceMapWithCache({
3308
+ jsUrl: normalizedUrl,
3309
+ cache: browserSourceMapCache
3310
+ })
3311
+ };
3312
+ };
3313
+ const getBrowserSourcemap = async (sourcePath)=>{
3314
+ const result = await resolveBrowserSourcemap(sourcePath);
3315
+ return result.handled ? result.sourcemap : null;
3316
+ };
3317
+ const buildErrorResult = async (error, close)=>{
2264
3318
  const elapsed = Math.max(0, Date.now() - buildStart);
2265
3319
  const errorResult = {
2266
3320
  results: [],
@@ -2273,14 +3327,17 @@ const runBrowserController = async (context, options)=>{
2273
3327
  hasFailure: true,
2274
3328
  unhandledErrors: [
2275
3329
  error
2276
- ]
3330
+ ],
3331
+ getSourcemap: getBrowserSourcemap,
3332
+ resolveSourcemap: resolveBrowserSourcemap,
3333
+ close
2277
3334
  };
2278
3335
  if (!skipOnTestRunEnd) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
2279
3336
  results: [],
2280
3337
  testResults: [],
2281
3338
  duration: errorResult.duration,
2282
3339
  snapshotSummary: context.snapshotManager.summary,
2283
- getSourcemap: async ()=>null,
3340
+ getSourcemap: getBrowserSourcemap,
2284
3341
  unhandledErrors: errorResult.unhandledErrors
2285
3342
  });
2286
3343
  return errorResult;
@@ -2288,24 +3345,51 @@ const runBrowserController = async (context, options)=>{
2288
3345
  const toError = (error)=>error instanceof Error ? error : new Error(String(error));
2289
3346
  const failWithError = async (error, cleanup)=>{
2290
3347
  ensureProcessExitCode(1);
2291
- await cleanup?.();
2292
- return buildErrorResult(toError(error));
3348
+ const normalizedError = toError(error);
3349
+ if (cleanup && skipOnTestRunEnd) return buildErrorResult(normalizedError, cleanup);
3350
+ try {
3351
+ return await buildErrorResult(normalizedError);
3352
+ } finally{
3353
+ await cleanup?.();
3354
+ }
3355
+ };
3356
+ const collectDeletedTestPaths = (previous, current)=>{
3357
+ const currentPathSet = new Set(current.map((file)=>file.testPath));
3358
+ return previous.map((file)=>file.testPath).filter((testPath)=>!currentPathSet.has(testPath));
3359
+ };
3360
+ const notifyTestRunStart = async ()=>{
3361
+ if (skipOnTestRunEnd) return;
3362
+ for (const reporter of context.reporters)await reporter.onTestRunStart?.();
3363
+ };
3364
+ const notifyTestRunEnd = async ({ duration, unhandledErrors, filterRerunTestPaths })=>{
3365
+ if (skipOnTestRunEnd) return;
3366
+ for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
3367
+ results: context.reporterResults.results,
3368
+ testResults: context.reporterResults.testResults,
3369
+ duration,
3370
+ snapshotSummary: context.snapshotManager.summary,
3371
+ getSourcemap: getBrowserSourcemap,
3372
+ unhandledErrors,
3373
+ filterRerunTestPaths
3374
+ });
2293
3375
  };
2294
3376
  const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
2295
3377
  let containerDevServer;
2296
3378
  let containerDistPath;
2297
- if (containerDevServerEnv) try {
2298
- containerDevServer = new URL(containerDevServerEnv).toString();
2299
- logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
2300
- } catch (error) {
2301
- const originalError = toError(error);
2302
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
2303
- return failWithError(originalError);
2304
- }
2305
- if (!containerDevServer) try {
2306
- containerDistPath = resolveContainerDist();
2307
- } catch (error) {
2308
- return failWithError(error);
3379
+ if (!useHeadlessDirect) {
3380
+ if (containerDevServerEnv) try {
3381
+ containerDevServer = new URL(containerDevServerEnv).toString();
3382
+ logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
3383
+ } catch (error) {
3384
+ const originalError = toError(error);
3385
+ originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
3386
+ return failWithError(originalError);
3387
+ }
3388
+ if (!containerDevServer) try {
3389
+ containerDistPath = resolveContainerDist();
3390
+ } catch (error) {
3391
+ return failWithError(error);
3392
+ }
2309
3393
  }
2310
3394
  const projectEntries = await resolveProjectEntries(context, options?.shardedEntries);
2311
3395
  const totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
@@ -2319,17 +3403,16 @@ const runBrowserController = async (context, options)=>{
2319
3403
  if (0 !== code) ensureProcessExitCode(code);
2320
3404
  return;
2321
3405
  }
3406
+ await notifyTestRunStart();
2322
3407
  const isWatchMode = 'watch' === context.command;
3408
+ const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
2323
3409
  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());
2324
3410
  const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
2325
3411
  const manifestSource = generateManifestModule({
2326
3412
  manifestPath,
2327
3413
  entries: projectEntries
2328
3414
  });
2329
- if (isWatchMode) watchContext.lastTestFiles = projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
2330
- testPath,
2331
- projectName: entry.project.name
2332
- })));
3415
+ if (isWatchMode) watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
2333
3416
  let runtime = isWatchMode ? watchContext.runtime : null;
2334
3417
  let triggerRerun;
2335
3418
  if (!runtime) {
@@ -2357,6 +3440,9 @@ const runBrowserController = async (context, options)=>{
2357
3440
  if (isWatchMode) {
2358
3441
  watchContext.runtime = runtime;
2359
3442
  registerWatchCleanup();
3443
+ if (enableCliShortcuts && !watchContext.closeCliShortcuts) watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
3444
+ close: cleanupWatchRuntime
3445
+ });
2360
3446
  }
2361
3447
  }
2362
3448
  const { browser, port, wsPort, wss } = runtime;
@@ -2384,15 +3470,496 @@ const runBrowserController = async (context, options)=>{
2384
3470
  debug: isDebug(),
2385
3471
  rpcTimeout: maxTestTimeoutForRpc
2386
3472
  };
3473
+ const browserProviderProjects = browserProjects.map((project)=>({
3474
+ rootPath: normalize(project.rootPath),
3475
+ provider: project.normalizedConfig.browser.provider
3476
+ }));
3477
+ const implementationByProvider = new Map();
3478
+ for (const browserProject of browserProviderProjects)if (!implementationByProvider.has(browserProject.provider)) implementationByProvider.set(browserProject.provider, getBrowserProviderImplementation(browserProject.provider));
3479
+ let activeContainerPage = null;
3480
+ let getHeadlessRunnerPageBySessionId;
3481
+ const dispatchBrowserRpcRequest = async ({ request, target })=>{
3482
+ const timeoutFallbackMs = maxTestTimeoutForRpc;
3483
+ const provider = resolveProviderForTestPath({
3484
+ testPath: request.testPath,
3485
+ browserProjects: browserProviderProjects
3486
+ });
3487
+ const implementation = implementationByProvider.get(provider);
3488
+ if (!implementation) throw new Error(`Browser provider implementation not found: ${provider}`);
3489
+ const runnerPage = target?.sessionId ? getHeadlessRunnerPageBySessionId?.(target.sessionId) : void 0;
3490
+ if (target?.sessionId && !runnerPage) throw new Error(`Runner page session not found for browser dispatch: ${target.sessionId}`);
3491
+ if (!runnerPage && !activeContainerPage) throw new Error('Browser container page is not initialized');
3492
+ try {
3493
+ return await implementation.dispatchRpc({
3494
+ containerPage: runnerPage ? void 0 : activeContainerPage ?? void 0,
3495
+ runnerPage,
3496
+ request,
3497
+ timeoutFallbackMs
3498
+ });
3499
+ } catch (error) {
3500
+ if (error instanceof Error) throw error.message;
3501
+ throw String(error);
3502
+ }
3503
+ };
3504
+ runtime.dispatchHandlers.set('browser', async (dispatchRequest)=>{
3505
+ const request = validateBrowserRpcRequest(dispatchRequest.args);
3506
+ return dispatchBrowserRpcRequest({
3507
+ request,
3508
+ target: dispatchRequest.target
3509
+ });
3510
+ });
2387
3511
  runtime.setContainerOptions(hostOptions);
2388
3512
  const reporterResults = [];
2389
3513
  const caseResults = [];
2390
- let completedTests = 0;
2391
3514
  let fatalError = null;
2392
- let resolveAllTests;
2393
- const allTestsPromise = new Promise((resolve)=>{
2394
- resolveAllTests = resolve;
2395
- });
3515
+ const snapshotRpcMethods = {
3516
+ async resolveSnapshotPath (testPath) {
3517
+ const snapExtension = '.snap';
3518
+ const resolver = context.normalizedConfig.resolveSnapshotPath || (()=>join(dirname(testPath), '__snapshots__', `${basename(testPath)}${snapExtension}`));
3519
+ return resolver(testPath, snapExtension);
3520
+ },
3521
+ async readSnapshotFile (filepath) {
3522
+ try {
3523
+ return await promises.readFile(filepath, 'utf-8');
3524
+ } catch {
3525
+ return null;
3526
+ }
3527
+ },
3528
+ async saveSnapshotFile (filepath, content) {
3529
+ const dir = dirname(filepath);
3530
+ await promises.mkdir(dir, {
3531
+ recursive: true
3532
+ });
3533
+ await promises.writeFile(filepath, content, 'utf-8');
3534
+ },
3535
+ async removeSnapshotFile (filepath) {
3536
+ try {
3537
+ await promises.unlink(filepath);
3538
+ } catch {}
3539
+ }
3540
+ };
3541
+ const handleTestFileStart = async (payload)=>{
3542
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileStart?.({
3543
+ testPath: payload.testPath,
3544
+ tests: []
3545
+ })));
3546
+ };
3547
+ const handleTestFileReady = async (payload)=>{
3548
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileReady?.(payload)));
3549
+ };
3550
+ const handleTestSuiteStart = async (payload)=>{
3551
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestSuiteStart?.(payload)));
3552
+ };
3553
+ const handleTestSuiteResult = async (payload)=>{
3554
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestSuiteResult?.(payload)));
3555
+ };
3556
+ const handleTestCaseStart = async (payload)=>{
3557
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseStart?.(payload)));
3558
+ };
3559
+ const handleTestCaseResult = async (payload)=>{
3560
+ caseResults.push(payload);
3561
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseResult?.(payload)));
3562
+ };
3563
+ const handleTestFileComplete = async (payload)=>{
3564
+ reporterResults.push(payload);
3565
+ context.updateReporterResultState([
3566
+ payload
3567
+ ], payload.results);
3568
+ if (payload.snapshotResult) context.snapshotManager.add(payload.snapshotResult);
3569
+ await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileResult?.(payload)));
3570
+ if ('fail' === payload.status) ensureProcessExitCode(1);
3571
+ };
3572
+ const handleLog = async (payload)=>{
3573
+ const log = {
3574
+ content: payload.content,
3575
+ name: payload.level,
3576
+ testPath: payload.testPath,
3577
+ type: payload.type,
3578
+ trace: payload.trace
3579
+ };
3580
+ const shouldLog = context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
3581
+ if (shouldLog) await Promise.all(context.reporters.map((reporter)=>reporter.onUserConsoleLog?.(log)));
3582
+ };
3583
+ const handleFatal = async (payload)=>{
3584
+ const error = new Error(payload.message);
3585
+ error.stack = payload.stack;
3586
+ fatalError = error;
3587
+ ensureProcessExitCode(1);
3588
+ };
3589
+ const runSnapshotRpc = async (request)=>{
3590
+ switch(request.method){
3591
+ case 'resolveSnapshotPath':
3592
+ return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
3593
+ case 'readSnapshotFile':
3594
+ return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
3595
+ case 'saveSnapshotFile':
3596
+ return snapshotRpcMethods.saveSnapshotFile(request.args.filepath, request.args.content);
3597
+ case 'removeSnapshotFile':
3598
+ return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
3599
+ default:
3600
+ return;
3601
+ }
3602
+ };
3603
+ const createDispatchRouter = (options)=>createHostDispatchRouter({
3604
+ routerOptions: options,
3605
+ runnerCallbacks: {
3606
+ onTestFileStart: handleTestFileStart,
3607
+ onTestFileReady: handleTestFileReady,
3608
+ onTestSuiteStart: handleTestSuiteStart,
3609
+ onTestSuiteResult: handleTestSuiteResult,
3610
+ onTestCaseStart: handleTestCaseStart,
3611
+ onTestCaseResult: handleTestCaseResult,
3612
+ onTestFileComplete: handleTestFileComplete,
3613
+ onLog: handleLog,
3614
+ onFatal: handleFatal
3615
+ },
3616
+ runSnapshotRpc,
3617
+ extensionHandlers: runtime.dispatchHandlers,
3618
+ onDuplicateNamespace: (namespace)=>{
3619
+ logger.debug(`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`);
3620
+ }
3621
+ });
3622
+ if (useHeadlessDirect) {
3623
+ const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
3624
+ const runLifecycle = new RunSessionLifecycle();
3625
+ const sessionRegistry = new RunnerSessionRegistry();
3626
+ getHeadlessRunnerPageBySessionId = (sessionId)=>sessionRegistry.getById(sessionId)?.page;
3627
+ let dispatchRequestCounter = 0;
3628
+ const nextDispatchRequestId = (namespace)=>`${namespace}-${++dispatchRequestCounter}`;
3629
+ const closeContextSafely = async (browserContext)=>{
3630
+ try {
3631
+ await browserContext.close();
3632
+ } catch {}
3633
+ };
3634
+ const cancelRun = async (run, waitForDone = true)=>{
3635
+ await runLifecycle.cancel(run, {
3636
+ waitForDone,
3637
+ onCancel: async (session)=>{
3638
+ await Promise.all(Array.from(session.contexts).map((browserContext)=>closeContextSafely(browserContext)));
3639
+ }
3640
+ });
3641
+ };
3642
+ const dispatchRouter = createDispatchRouter({
3643
+ isRunTokenStale: (runToken)=>runLifecycle.isTokenStale(runToken),
3644
+ onStale: (request)=>{
3645
+ if (request.namespace === DISPATCH_NAMESPACE_RUNNER) logger.debug(`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`);
3646
+ }
3647
+ });
3648
+ const dispatchRunnerMessage = async (run, file, sessionId, message)=>{
3649
+ const response = await dispatchRouter.dispatch({
3650
+ requestId: nextDispatchRequestId('runner'),
3651
+ runToken: run.token,
3652
+ namespace: DISPATCH_NAMESPACE_RUNNER,
3653
+ method: message.type,
3654
+ args: 'payload' in message ? message.payload : void 0,
3655
+ target: {
3656
+ sessionId,
3657
+ testFile: file.testPath,
3658
+ projectName: file.projectName
3659
+ }
3660
+ });
3661
+ if (response.stale) return;
3662
+ if (response.error) throw new Error(response.error);
3663
+ };
3664
+ const runSingleFile = async (run, file)=>{
3665
+ if (run.cancelled || runLifecycle.isTokenStale(run.token)) return;
3666
+ const viewport = viewportByProject.get(file.projectName);
3667
+ const browserContext = await browser.newContext({
3668
+ viewport: viewport ?? null
3669
+ });
3670
+ run.contexts.add(browserContext);
3671
+ let page = null;
3672
+ let sessionId = null;
3673
+ let settled = false;
3674
+ let resolveDone = null;
3675
+ const markDone = ()=>{
3676
+ if (!settled) {
3677
+ settled = true;
3678
+ resolveDone?.();
3679
+ }
3680
+ };
3681
+ const donePromise = new Promise((resolve)=>{
3682
+ resolveDone = resolve;
3683
+ });
3684
+ const projectRuntime = projectRuntimeConfigs.find((project)=>project.name === file.projectName);
3685
+ const perFileTimeoutMs = (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) + 30000;
3686
+ let timeoutId;
3687
+ try {
3688
+ page = await browserContext.newPage();
3689
+ const session = sessionRegistry.register({
3690
+ testFile: file.testPath,
3691
+ projectName: file.projectName,
3692
+ runToken: run.token,
3693
+ mode: 'headless-page',
3694
+ context: browserContext,
3695
+ page
3696
+ });
3697
+ sessionId = session.id;
3698
+ await attachHeadlessRunnerTransport(page, {
3699
+ onDispatchMessage: async (message)=>{
3700
+ try {
3701
+ await dispatchRunnerMessage(run, file, session.id, message);
3702
+ if ('file-complete' === message.type || 'complete' === message.type) markDone();
3703
+ else if ('fatal' === message.type) {
3704
+ markDone();
3705
+ await cancelRun(run, false);
3706
+ }
3707
+ } catch (error) {
3708
+ const formatted = toError(error);
3709
+ await handleFatal({
3710
+ message: formatted.message,
3711
+ stack: formatted.stack
3712
+ });
3713
+ markDone();
3714
+ await cancelRun(run, false);
3715
+ }
3716
+ },
3717
+ onDispatchRpc: async (request)=>dispatchRouter.dispatch({
3718
+ ...request,
3719
+ runToken: run.token,
3720
+ target: {
3721
+ sessionId: session.id,
3722
+ testFile: file.testPath,
3723
+ projectName: file.projectName,
3724
+ ...request.target
3725
+ }
3726
+ })
3727
+ });
3728
+ const inlineOptions = {
3729
+ ...hostOptions,
3730
+ testFile: file.testPath,
3731
+ runId: `${run.token}:${session.id}`
3732
+ };
3733
+ const serializedOptions = serializeForInlineScript(inlineOptions);
3734
+ await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
3735
+ await page.goto(`http://localhost:${port}/runner.html`, {
3736
+ waitUntil: 'load'
3737
+ });
3738
+ const timeoutPromise = new Promise((resolve)=>{
3739
+ timeoutId = setTimeout(()=>resolve('timeout'), perFileTimeoutMs);
3740
+ });
3741
+ const state = await Promise.race([
3742
+ donePromise.then(()=>'done'),
3743
+ timeoutPromise,
3744
+ run.cancelSignal.then(()=>'cancelled')
3745
+ ]);
3746
+ if ('cancelled' === state) return;
3747
+ if ('timeout' === state && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
3748
+ await handleFatal({
3749
+ message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`
3750
+ });
3751
+ await cancelRun(run, false);
3752
+ }
3753
+ } catch (error) {
3754
+ if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
3755
+ const formatted = toError(error);
3756
+ await handleFatal({
3757
+ message: formatted.message,
3758
+ stack: formatted.stack
3759
+ });
3760
+ await cancelRun(run, false);
3761
+ }
3762
+ } finally{
3763
+ if (timeoutId) clearTimeout(timeoutId);
3764
+ if (page) try {
3765
+ await page.close();
3766
+ } catch {}
3767
+ if (sessionId) sessionRegistry.deleteById(sessionId);
3768
+ run.contexts.delete(browserContext);
3769
+ await closeContextSafely(browserContext);
3770
+ }
3771
+ };
3772
+ const runFilesWithPool = async (files)=>{
3773
+ if (0 === files.length) return;
3774
+ const previous = runLifecycle.activeSession;
3775
+ if (previous) await cancelRun(previous);
3776
+ const run = runLifecycle.createSession((token)=>({
3777
+ ...createRunSession(token),
3778
+ contexts: new Set()
3779
+ }));
3780
+ const queue = [
3781
+ ...files
3782
+ ];
3783
+ const concurrency = getHeadlessConcurrency(context, queue.length);
3784
+ const worker = async ()=>{
3785
+ while(queue.length > 0 && !run.cancelled && runLifecycle.isTokenActive(run.token)){
3786
+ const next = queue.shift();
3787
+ if (!next) return;
3788
+ await runSingleFile(run, next);
3789
+ }
3790
+ };
3791
+ run.done = Promise.all(Array.from({
3792
+ length: Math.min(queue.length, Math.max(concurrency, 1))
3793
+ }, ()=>worker())).then(()=>{});
3794
+ await run.done;
3795
+ runLifecycle.clearIfActive(run);
3796
+ };
3797
+ const latestRerunScheduler = createHeadlessLatestRerunScheduler({
3798
+ getActiveRun: ()=>runLifecycle.activeSession,
3799
+ isRunCancelled: (run)=>run.cancelled,
3800
+ invalidateActiveRun: ()=>{
3801
+ runLifecycle.invalidateActiveToken();
3802
+ },
3803
+ interruptActiveRun: async (run)=>{
3804
+ await cancelRun(run, false);
3805
+ },
3806
+ runFiles: async (files)=>{
3807
+ await notifyTestRunStart();
3808
+ const rerunStartTime = Date.now();
3809
+ const fatalErrorBeforeRun = fatalError;
3810
+ let rerunError;
3811
+ try {
3812
+ await runFilesWithPool(files);
3813
+ } catch (error) {
3814
+ rerunError = toError(error);
3815
+ throw error;
3816
+ } finally{
3817
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
3818
+ const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
3819
+ await notifyTestRunEnd({
3820
+ duration: {
3821
+ totalTime: testTime,
3822
+ buildTime: 0,
3823
+ testTime
3824
+ },
3825
+ filterRerunTestPaths: files.map((file)=>file.testPath),
3826
+ unhandledErrors: rerunError ? [
3827
+ rerunError
3828
+ ] : rerunFatalError ? [
3829
+ rerunFatalError
3830
+ ] : void 0
3831
+ });
3832
+ logBrowserWatchReadyMessage(enableCliShortcuts);
3833
+ }
3834
+ },
3835
+ onError: async (error)=>{
3836
+ const formatted = toError(error);
3837
+ await handleFatal({
3838
+ message: formatted.message,
3839
+ stack: formatted.stack
3840
+ });
3841
+ },
3842
+ onInterrupt: (run)=>{
3843
+ logger.debug(`[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`);
3844
+ }
3845
+ });
3846
+ const testStart = Date.now();
3847
+ await runFilesWithPool(allTestFiles);
3848
+ const testTime = Date.now() - testStart;
3849
+ if (isWatchMode) triggerRerun = async ()=>{
3850
+ const newProjectEntries = await collectProjectEntries(context);
3851
+ const rerunPlan = planWatchRerun({
3852
+ projectEntries: newProjectEntries,
3853
+ previousTestFiles: watchContext.lastTestFiles,
3854
+ affectedTestFiles: watchContext.affectedTestFiles
3855
+ });
3856
+ watchContext.affectedTestFiles = [];
3857
+ if (rerunPlan.filesChanged) {
3858
+ const deletedTestPaths = collectDeletedTestPaths(watchContext.lastTestFiles, rerunPlan.currentTestFiles);
3859
+ if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
3860
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3861
+ if (0 === rerunPlan.currentTestFiles.length) {
3862
+ await latestRerunScheduler.enqueueLatest([]);
3863
+ logger.log(color.cyan('No browser test files remain after update.\n'));
3864
+ logBrowserWatchReadyMessage(enableCliShortcuts);
3865
+ return;
3866
+ }
3867
+ logger.log(color.cyan(`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`));
3868
+ latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3869
+ return;
3870
+ }
3871
+ if (0 === rerunPlan.affectedTestFiles.length) {
3872
+ logger.log(color.cyan('No affected browser test files detected, skipping re-run.\n'));
3873
+ logBrowserWatchReadyMessage(enableCliShortcuts);
3874
+ return;
3875
+ }
3876
+ logger.log(color.cyan(`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`));
3877
+ latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3878
+ };
3879
+ const closeHeadlessRuntime = isWatchMode ? void 0 : async ()=>{
3880
+ sessionRegistry.clear();
3881
+ await destroyBrowserRuntime(runtime);
3882
+ };
3883
+ if (fatalError) return failWithError(fatalError, closeHeadlessRuntime);
3884
+ const duration = {
3885
+ totalTime: buildTime + testTime,
3886
+ buildTime,
3887
+ testTime
3888
+ };
3889
+ context.updateReporterResultState(reporterResults, caseResults);
3890
+ const isFailure = reporterResults.some((result)=>'fail' === result.status);
3891
+ if (isFailure) ensureProcessExitCode(1);
3892
+ const result = {
3893
+ results: reporterResults,
3894
+ testResults: caseResults,
3895
+ duration,
3896
+ hasFailure: isFailure,
3897
+ getSourcemap: getBrowserSourcemap,
3898
+ resolveSourcemap: resolveBrowserSourcemap,
3899
+ close: skipOnTestRunEnd ? closeHeadlessRuntime : void 0
3900
+ };
3901
+ if (!skipOnTestRunEnd) try {
3902
+ await notifyTestRunEnd({
3903
+ duration
3904
+ });
3905
+ } finally{
3906
+ await closeHeadlessRuntime?.();
3907
+ }
3908
+ if (isWatchMode && triggerRerun) {
3909
+ watchContext.hooksEnabled = true;
3910
+ logBrowserWatchReadyMessage(enableCliShortcuts);
3911
+ }
3912
+ return result;
3913
+ }
3914
+ let currentTestFiles = allTestFiles;
3915
+ const RUNNER_FRAMES_READY_TIMEOUT_MS = 30000;
3916
+ let currentRunnerFramesSignature = null;
3917
+ const runnerFramesWaiters = new Map();
3918
+ const createTestFilesSignature = (testFiles)=>JSON.stringify(testFiles.map((testFile)=>normalize(testFile)));
3919
+ const markRunnerFramesReady = (testFiles)=>{
3920
+ const signature = createTestFilesSignature(testFiles);
3921
+ currentRunnerFramesSignature = signature;
3922
+ const waiters = runnerFramesWaiters.get(signature);
3923
+ if (!waiters) return;
3924
+ runnerFramesWaiters.delete(signature);
3925
+ for (const waiter of waiters)waiter();
3926
+ };
3927
+ const waitForRunnerFramesReady = async (testFiles)=>{
3928
+ const signature = createTestFilesSignature(testFiles);
3929
+ if (currentRunnerFramesSignature === signature) return;
3930
+ await new Promise((resolve, reject)=>{
3931
+ const waiters = runnerFramesWaiters.get(signature) ?? new Set();
3932
+ let timeoutId;
3933
+ const cleanup = ()=>{
3934
+ const currentWaiters = runnerFramesWaiters.get(signature);
3935
+ if (!currentWaiters) return;
3936
+ currentWaiters.delete(onReady);
3937
+ if (0 === currentWaiters.size) runnerFramesWaiters.delete(signature);
3938
+ };
3939
+ const onReady = ()=>{
3940
+ if (timeoutId) clearTimeout(timeoutId);
3941
+ cleanup();
3942
+ resolve();
3943
+ };
3944
+ timeoutId = setTimeout(()=>{
3945
+ cleanup();
3946
+ reject(new Error(`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`));
3947
+ }, RUNNER_FRAMES_READY_TIMEOUT_MS);
3948
+ waiters.add(onReady);
3949
+ runnerFramesWaiters.set(signature, waiters);
3950
+ if (currentRunnerFramesSignature === signature) onReady();
3951
+ });
3952
+ };
3953
+ const getTestFileInfo = (testFile)=>{
3954
+ const normalizedTestFile = normalize(testFile);
3955
+ const fileInfo = currentTestFiles.find((file)=>file.testPath === normalizedTestFile);
3956
+ if (!fileInfo) throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
3957
+ return fileInfo;
3958
+ };
3959
+ const getHeadedPerFileTimeoutMs = (file)=>{
3960
+ const projectRuntime = projectRuntimeConfigs.find((project)=>project.name === file.projectName);
3961
+ return (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) + 30000;
3962
+ };
2396
3963
  let containerContext;
2397
3964
  let containerPage;
2398
3965
  let isNewPage = false;
@@ -2418,76 +3985,62 @@ const runBrowserController = async (context, options)=>{
2418
3985
  }
2419
3986
  containerPage.on('console', (msg)=>{
2420
3987
  const text = msg.text();
2421
- if (text.startsWith('[Container]') || text.startsWith('[Runner]') || text.startsWith('[Scheduler]')) logger.log(color.gray(`[Browser Console] ${text}`));
3988
+ if (text.startsWith('[Container]') || text.startsWith('[Runner]')) logger.log(color.gray(`[Browser Console] ${text}`));
2422
3989
  });
2423
3990
  }
3991
+ activeContainerPage = containerPage;
3992
+ const dispatchRouter = createDispatchRouter();
3993
+ const headedReloadQueue = createHeadedSerialTaskQueue();
3994
+ let enqueueHeadedReload = async (_file, _testNamePattern)=>{
3995
+ throw new Error('Headed reload queue is not initialized');
3996
+ };
3997
+ const reloadTestFileWithTimeout = async (file, testNamePattern)=>{
3998
+ const timeoutMs = getHeadedPerFileTimeoutMs(file);
3999
+ let timeoutId;
4000
+ try {
4001
+ await Promise.race([
4002
+ rpcManager.reloadTestFile(file.testPath, testNamePattern),
4003
+ new Promise((_, reject)=>{
4004
+ timeoutId = setTimeout(()=>{
4005
+ reject(new Error(`Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`));
4006
+ }, timeoutMs);
4007
+ })
4008
+ ]);
4009
+ } finally{
4010
+ if (timeoutId) clearTimeout(timeoutId);
4011
+ }
4012
+ };
2424
4013
  const createRpcMethods = ()=>({
2425
4014
  async rerunTest (testFile, testNamePattern) {
2426
4015
  const projectName = context.normalizedConfig.name || 'project';
2427
4016
  const relativePath = relative(context.rootPath, testFile);
2428
4017
  const displayPath = `<${projectName}>/${relativePath}`;
2429
4018
  logger.log(color.cyan(`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`));
2430
- await rpcManager.reloadTestFile(testFile, testNamePattern);
4019
+ await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
2431
4020
  },
2432
4021
  async getTestFiles () {
2433
- return allTestFiles;
4022
+ return currentTestFiles;
4023
+ },
4024
+ async onRunnerFramesReady (testFiles) {
4025
+ markRunnerFramesReady(testFiles);
2434
4026
  },
2435
4027
  async onTestFileStart (payload) {
2436
- await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileStart?.({
2437
- testPath: payload.testPath,
2438
- tests: []
2439
- })));
4028
+ await handleTestFileStart(payload);
2440
4029
  },
2441
4030
  async onTestCaseResult (payload) {
2442
- caseResults.push(payload);
2443
- await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseResult?.(payload)));
4031
+ await handleTestCaseResult(payload);
2444
4032
  },
2445
4033
  async onTestFileComplete (payload) {
2446
- reporterResults.push(payload);
2447
- if (payload.snapshotResult) context.snapshotManager.add(payload.snapshotResult);
2448
- await Promise.all(context.reporters.map((reporter)=>reporter.onTestFileResult?.(payload)));
2449
- completedTests++;
2450
- if (completedTests >= allTestFiles.length && resolveAllTests) resolveAllTests();
4034
+ await handleTestFileComplete(payload);
2451
4035
  },
2452
4036
  async onLog (payload) {
2453
- const log = {
2454
- content: payload.content,
2455
- name: payload.level,
2456
- testPath: payload.testPath,
2457
- type: payload.type,
2458
- trace: payload.trace
2459
- };
2460
- const shouldLog = context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
2461
- if (shouldLog) await Promise.all(context.reporters.map((reporter)=>reporter.onUserConsoleLog?.(log)));
4037
+ await handleLog(payload);
2462
4038
  },
2463
4039
  async onFatal (payload) {
2464
- fatalError = new Error(payload.message);
2465
- fatalError.stack = payload.stack;
2466
- if (resolveAllTests) resolveAllTests();
2467
- },
2468
- async resolveSnapshotPath (testPath) {
2469
- const snapExtension = '.snap';
2470
- const resolver = context.normalizedConfig.resolveSnapshotPath || (()=>join(dirname(testPath), '__snapshots__', `${basename(testPath)}${snapExtension}`));
2471
- return resolver(testPath, snapExtension);
2472
- },
2473
- async readSnapshotFile (filepath) {
2474
- try {
2475
- return await promises.readFile(filepath, 'utf-8');
2476
- } catch {
2477
- return null;
2478
- }
2479
- },
2480
- async saveSnapshotFile (filepath, content) {
2481
- const dir = dirname(filepath);
2482
- await promises.mkdir(dir, {
2483
- recursive: true
2484
- });
2485
- await promises.writeFile(filepath, content, 'utf-8');
4040
+ await handleFatal(payload);
2486
4041
  },
2487
- async removeSnapshotFile (filepath) {
2488
- try {
2489
- await promises.unlink(filepath);
2490
- } catch {}
4042
+ async dispatch (request) {
4043
+ return dispatchRouter.dispatch(request);
2491
4044
  }
2492
4045
  });
2493
4046
  let rpcManager;
@@ -2501,52 +4054,80 @@ const runBrowserController = async (context, options)=>{
2501
4054
  if (isWatchMode) runtime.rpcManager = rpcManager;
2502
4055
  }
2503
4056
  if (isNewPage) {
2504
- const pagePath = useSchedulerPage ? '/scheduler.html' : '/';
2505
- if (useSchedulerPage) {
2506
- const serializedOptions = serializeForInlineScript(hostOptions);
2507
- await containerPage.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
2508
- }
4057
+ const pagePath = '/';
2509
4058
  await containerPage.goto(`http://localhost:${port}${pagePath}`, {
2510
4059
  waitUntil: 'load'
2511
4060
  });
2512
4061
  logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${port}${pagePath}\n`));
2513
4062
  }
2514
- const maxTestTimeout = Math.max(...browserProjects.map((p)=>p.normalizedConfig.testTimeout ?? 5000));
2515
- const totalTimeoutMs = maxTestTimeout * allTestFiles.length + 30000;
2516
- let timeoutId;
2517
- const testTimeout = new Promise((resolve)=>{
2518
- timeoutId = setTimeout(()=>{
2519
- logger.log(color.yellow(`\nTest execution timeout after ${totalTimeoutMs / 1000}s. Completed: ${completedTests}/${allTestFiles.length}\n`));
2520
- resolve();
2521
- }, totalTimeoutMs);
2522
- });
4063
+ enqueueHeadedReload = async (file, testNamePattern)=>headedReloadQueue.enqueue(async ()=>{
4064
+ if (fatalError) return;
4065
+ await reloadTestFileWithTimeout(file, testNamePattern);
4066
+ });
2523
4067
  const testStart = Date.now();
2524
- await Promise.race([
2525
- allTestsPromise,
2526
- testTimeout
2527
- ]);
2528
- if (timeoutId) clearTimeout(timeoutId);
4068
+ try {
4069
+ await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
4070
+ for (const file of currentTestFiles){
4071
+ await enqueueHeadedReload(file);
4072
+ if (fatalError) break;
4073
+ }
4074
+ } catch (error) {
4075
+ fatalError = fatalError ?? toError(error);
4076
+ ensureProcessExitCode(1);
4077
+ }
2529
4078
  const testTime = Date.now() - testStart;
2530
4079
  if (isWatchMode) triggerRerun = async ()=>{
2531
4080
  const newProjectEntries = await collectProjectEntries(context);
2532
- const currentTestFiles = newProjectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
2533
- testPath: normalize(testPath),
2534
- projectName: entry.project.name
2535
- })));
2536
- const serialize = (files)=>JSON.stringify(files.map((f)=>`${f.projectName}:${f.testPath}`).sort());
2537
- const filesChanged = serialize(currentTestFiles) !== serialize(watchContext.lastTestFiles);
2538
- if (filesChanged) {
2539
- watchContext.lastTestFiles = currentTestFiles;
4081
+ const rerunPlan = planWatchRerun({
4082
+ projectEntries: newProjectEntries,
4083
+ previousTestFiles: watchContext.lastTestFiles,
4084
+ affectedTestFiles: watchContext.affectedTestFiles
4085
+ });
4086
+ watchContext.affectedTestFiles = [];
4087
+ if (rerunPlan.filesChanged) {
4088
+ const deletedTestPaths = collectDeletedTestPaths(watchContext.lastTestFiles, rerunPlan.currentTestFiles);
4089
+ if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
4090
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
4091
+ currentTestFiles = rerunPlan.currentTestFiles;
2540
4092
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
4093
+ await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
4094
+ }
4095
+ if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
4096
+ logger.log(color.cyan(`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`));
4097
+ await notifyTestRunStart();
4098
+ const rerunStartTime = Date.now();
4099
+ const fatalErrorBeforeRun = fatalError;
4100
+ let rerunError;
4101
+ try {
4102
+ for (const testFile of rerunPlan.normalizedAffectedTestFiles)await enqueueHeadedReload(getTestFileInfo(testFile));
4103
+ } catch (error) {
4104
+ rerunError = toError(error);
4105
+ throw error;
4106
+ } finally{
4107
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
4108
+ const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
4109
+ await notifyTestRunEnd({
4110
+ duration: {
4111
+ totalTime: testTime,
4112
+ buildTime: 0,
4113
+ testTime
4114
+ },
4115
+ filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4116
+ unhandledErrors: rerunError ? [
4117
+ rerunError
4118
+ ] : rerunFatalError ? [
4119
+ rerunFatalError
4120
+ ] : void 0
4121
+ });
4122
+ logBrowserWatchReadyMessage(enableCliShortcuts);
4123
+ }
4124
+ } else if (rerunPlan.filesChanged) logBrowserWatchReadyMessage(enableCliShortcuts);
4125
+ else {
4126
+ logger.log(color.cyan('Tests will be re-executed automatically\n'));
4127
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2541
4128
  }
2542
- const affectedFiles = watchContext.affectedTestFiles;
2543
- watchContext.affectedTestFiles = [];
2544
- if (affectedFiles.length > 0) {
2545
- logger.log(color.cyan(`Re-running ${affectedFiles.length} affected test file(s)...\n`));
2546
- for (const testFile of affectedFiles)await rpcManager.reloadTestFile(testFile);
2547
- } else if (!filesChanged) logger.log(color.cyan('Tests will be re-executed automatically\n'));
2548
4129
  };
2549
- if (!isWatchMode) {
4130
+ const closeContainerRuntime = isWatchMode ? void 0 : async ()=>{
2550
4131
  try {
2551
4132
  await containerPage.close();
2552
4133
  } catch {}
@@ -2554,8 +4135,8 @@ const runBrowserController = async (context, options)=>{
2554
4135
  await containerContext.close();
2555
4136
  } catch {}
2556
4137
  await destroyBrowserRuntime(runtime);
2557
- }
2558
- if (fatalError) return failWithError(fatalError);
4138
+ };
4139
+ if (fatalError) return failWithError(fatalError, closeContainerRuntime);
2559
4140
  const duration = {
2560
4141
  totalTime: buildTime + testTime,
2561
4142
  buildTime,
@@ -2568,18 +4149,21 @@ const runBrowserController = async (context, options)=>{
2568
4149
  results: reporterResults,
2569
4150
  testResults: caseResults,
2570
4151
  duration,
2571
- hasFailure: isFailure
4152
+ hasFailure: isFailure,
4153
+ getSourcemap: getBrowserSourcemap,
4154
+ resolveSourcemap: resolveBrowserSourcemap,
4155
+ close: skipOnTestRunEnd ? closeContainerRuntime : void 0
2572
4156
  };
2573
- if (!skipOnTestRunEnd) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
2574
- results: context.reporterResults.results,
2575
- testResults: context.reporterResults.testResults,
2576
- duration,
2577
- snapshotSummary: context.snapshotManager.summary,
2578
- getSourcemap: async ()=>null
2579
- });
4157
+ if (!skipOnTestRunEnd) try {
4158
+ await notifyTestRunEnd({
4159
+ duration
4160
+ });
4161
+ } finally{
4162
+ await closeContainerRuntime?.();
4163
+ }
2580
4164
  if (isWatchMode && triggerRerun) {
2581
4165
  watchContext.hooksEnabled = true;
2582
- logger.log(color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'));
4166
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2583
4167
  }
2584
4168
  return result;
2585
4169
  };
@@ -2596,6 +4180,7 @@ const listBrowserTests = async (context, options)=>{
2596
4180
  manifestPath,
2597
4181
  entries: projectEntries
2598
4182
  });
4183
+ const browserProjects = getBrowserProjects(context);
2599
4184
  let runtime;
2600
4185
  try {
2601
4186
  runtime = await createBrowserRuntime({
@@ -2609,11 +4194,13 @@ const listBrowserTests = async (context, options)=>{
2609
4194
  forceHeadless: true
2610
4195
  });
2611
4196
  } catch (error) {
2612
- logger.error(color.red('Failed to load Playwright. Please install "playwright" to use browser mode.'), error);
4197
+ const providers = [
4198
+ ...new Set(browserProjects.map((p)=>p.normalizedConfig.browser.provider))
4199
+ ];
4200
+ logger.error(color.red(`Failed to initialize browser provider runtime (${providers.join(', ')}).`), error);
2613
4201
  throw error;
2614
4202
  }
2615
4203
  const { browser, port } = runtime;
2616
- const browserProjects = getBrowserProjects(context);
2617
4204
  const projectRuntimeConfigs = browserProjects.map((project)=>({
2618
4205
  name: project.name,
2619
4206
  environmentName: project.environmentName,
@@ -2644,7 +4231,7 @@ const listBrowserTests = async (context, options)=>{
2644
4231
  viewport: null
2645
4232
  });
2646
4233
  const page = await browserContext.newPage();
2647
- await page.exposeFunction('__rstest_dispatch__', (message)=>{
4234
+ await page.exposeFunction(DISPATCH_MESSAGE_TYPE, (message)=>{
2648
4235
  switch(message.type){
2649
4236
  case 'collect-result':
2650
4237
  {
@@ -2726,99 +4313,6 @@ const listBrowserTests = async (context, options)=>{
2726
4313
  close: cleanup
2727
4314
  };
2728
4315
  };
2729
- const BROWSER_VIEWPORT_PRESET_IDS = [
2730
- 'iPhoneSE',
2731
- 'iPhoneXR',
2732
- 'iPhone12Pro',
2733
- 'iPhone14ProMax',
2734
- 'Pixel7',
2735
- 'SamsungGalaxyS8Plus',
2736
- 'SamsungGalaxyS20Ultra',
2737
- 'iPadMini',
2738
- 'iPadAir',
2739
- 'iPadPro',
2740
- 'SurfacePro7',
2741
- 'SurfaceDuo',
2742
- 'GalaxyZFold5',
2743
- 'AsusZenbookFold',
2744
- 'SamsungGalaxyA51A71',
2745
- 'NestHub',
2746
- 'NestHubMax'
2747
- ];
2748
- const BROWSER_VIEWPORT_PRESET_DIMENSIONS = {
2749
- iPhoneSE: {
2750
- width: 375,
2751
- height: 667
2752
- },
2753
- iPhoneXR: {
2754
- width: 414,
2755
- height: 896
2756
- },
2757
- iPhone12Pro: {
2758
- width: 390,
2759
- height: 844
2760
- },
2761
- iPhone14ProMax: {
2762
- width: 430,
2763
- height: 932
2764
- },
2765
- Pixel7: {
2766
- width: 412,
2767
- height: 915
2768
- },
2769
- SamsungGalaxyS8Plus: {
2770
- width: 360,
2771
- height: 740
2772
- },
2773
- SamsungGalaxyS20Ultra: {
2774
- width: 412,
2775
- height: 915
2776
- },
2777
- iPadMini: {
2778
- width: 768,
2779
- height: 1024
2780
- },
2781
- iPadAir: {
2782
- width: 820,
2783
- height: 1180
2784
- },
2785
- iPadPro: {
2786
- width: 1024,
2787
- height: 1366
2788
- },
2789
- SurfacePro7: {
2790
- width: 912,
2791
- height: 1368
2792
- },
2793
- SurfaceDuo: {
2794
- width: 540,
2795
- height: 720
2796
- },
2797
- GalaxyZFold5: {
2798
- width: 344,
2799
- height: 882
2800
- },
2801
- AsusZenbookFold: {
2802
- width: 853,
2803
- height: 1280
2804
- },
2805
- SamsungGalaxyA51A71: {
2806
- width: 412,
2807
- height: 914
2808
- },
2809
- NestHub: {
2810
- width: 1024,
2811
- height: 600
2812
- },
2813
- NestHubMax: {
2814
- width: 1280,
2815
- height: 800
2816
- }
2817
- };
2818
- const resolveBrowserViewportPreset = (presetId)=>{
2819
- const size = BROWSER_VIEWPORT_PRESET_DIMENSIONS[presetId];
2820
- return size ?? null;
2821
- };
2822
4316
  const SUPPORTED_PROVIDERS = [
2823
4317
  'playwright'
2824
4318
  ];