@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.
- package/LICENSE-APACHE-2.0 +202 -0
- package/NOTICE +11 -0
- package/dist/361.js +8 -0
- package/dist/augmentExpect.d.ts +73 -0
- package/dist/browser-container/container-static/css/index.5c72297783.css +1 -0
- package/dist/browser-container/container-static/js/{565.226c9ef5.js → 101.36a8ccdf84.js} +4024 -3856
- package/dist/browser-container/container-static/js/101.36a8ccdf84.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.c1d17467.js → index.28d833de0b.js} +732 -675
- package/dist/browser-container/container-static/js/{lib-react.97ee79b0.js → lib-react.dcf2a5e57a.js} +10 -10
- package/dist/browser-container/container-static/js/lib-react.dcf2a5e57a.js.LICENSE.txt +1 -0
- package/dist/browser-container/index.html +1 -1
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +583 -0
- package/dist/browserRpcRegistry.d.ts +18 -0
- package/dist/client/api.d.ts +3 -0
- package/dist/client/browserRpc.d.ts +2 -0
- package/dist/client/dispatchTransport.d.ts +11 -0
- package/dist/client/entry.d.ts +1 -5
- package/dist/client/locator.d.ts +125 -0
- package/dist/client/snapshot.d.ts +0 -6
- package/dist/concurrency.d.ts +12 -0
- package/dist/dispatchCapabilities.d.ts +34 -0
- package/dist/dispatchRouter.d.ts +20 -0
- package/dist/headedSerialTaskQueue.d.ts +8 -0
- package/dist/headlessLatestRerunScheduler.d.ts +19 -0
- package/dist/headlessTransport.d.ts +12 -0
- package/dist/hostController.d.ts +16 -0
- package/dist/index.js +1790 -296
- package/dist/protocol.d.ts +44 -33
- package/dist/providers/index.d.ts +79 -0
- package/dist/providers/playwright/compileLocator.d.ts +3 -0
- package/dist/providers/playwright/dispatchBrowserRpc.d.ts +13 -0
- package/dist/providers/playwright/expectUtils.d.ts +24 -0
- package/dist/providers/playwright/implementation.d.ts +2 -0
- package/dist/providers/playwright/index.d.ts +1 -0
- package/dist/providers/playwright/runtime.d.ts +5 -0
- package/dist/providers/playwright/textMatcher.d.ts +8 -0
- package/dist/rpcProtocol.d.ts +145 -0
- package/dist/runSession.d.ts +33 -0
- package/dist/sessionRegistry.d.ts +34 -0
- package/dist/sourceMap/sourceMapLoader.d.ts +14 -0
- package/dist/watchCliShortcuts.d.ts +6 -0
- package/dist/watchRerunPlanner.d.ts +21 -0
- package/package.json +17 -12
- package/src/AGENTS.md +128 -0
- package/src/augmentExpect.ts +62 -0
- package/src/browser.ts +3 -0
- package/src/browserRpcRegistry.ts +57 -0
- package/src/client/AGENTS.md +82 -0
- package/src/client/api.ts +213 -0
- package/src/client/browserRpc.ts +86 -0
- package/src/client/dispatchTransport.ts +178 -0
- package/src/client/entry.ts +96 -33
- package/src/client/locator.ts +452 -0
- package/src/client/snapshot.ts +32 -97
- package/src/client/sourceMapSupport.ts +26 -37
- package/src/concurrency.ts +62 -0
- package/src/dispatchCapabilities.ts +162 -0
- package/src/dispatchRouter.ts +82 -0
- package/src/env.d.ts +8 -1
- package/src/headedSerialTaskQueue.ts +19 -0
- package/src/headlessLatestRerunScheduler.ts +76 -0
- package/src/headlessTransport.ts +28 -0
- package/src/hostController.ts +1538 -384
- package/src/protocol.ts +66 -31
- package/src/providers/index.ts +103 -0
- package/src/providers/playwright/compileLocator.ts +130 -0
- package/src/providers/playwright/dispatchBrowserRpc.ts +372 -0
- package/src/providers/playwright/expectUtils.ts +57 -0
- package/src/providers/playwright/implementation.ts +33 -0
- package/src/providers/playwright/index.ts +1 -0
- package/src/providers/playwright/runtime.ts +32 -0
- package/src/providers/playwright/textMatcher.ts +10 -0
- package/src/rpcProtocol.ts +220 -0
- package/src/runSession.ts +110 -0
- package/src/sessionRegistry.ts +89 -0
- package/src/sourceMap/sourceMapLoader.ts +96 -0
- package/src/watchCliShortcuts.ts +77 -0
- package/src/watchRerunPlanner.ts +77 -0
- package/dist/browser-container/container-static/css/index.5a71c757.css +0 -1
- package/dist/browser-container/container-static/js/565.226c9ef5.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/lib-react.97ee79b0.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/scheduler.5accca0c.js +0 -407
- 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
|
|
1943
|
-
if (watchContext.
|
|
1944
|
-
|
|
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
|
-
|
|
2983
|
+
cleanupWatchRuntime();
|
|
1954
2984
|
});
|
|
1955
2985
|
process.once('exit', ()=>{
|
|
1956
|
-
|
|
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
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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:
|
|
1995
|
-
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
|
-
[
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2202
|
-
const browserName = browserConfig.browser;
|
|
3239
|
+
const browserName = browserLaunchOptions.browser ?? 'chromium';
|
|
2203
3240
|
try {
|
|
2204
|
-
const
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
|
2263
|
-
const
|
|
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:
|
|
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
|
-
|
|
2292
|
-
return buildErrorResult(
|
|
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 (
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
|
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
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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]')
|
|
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
|
|
4019
|
+
await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
|
|
2431
4020
|
},
|
|
2432
4021
|
async getTestFiles () {
|
|
2433
|
-
return
|
|
4022
|
+
return currentTestFiles;
|
|
4023
|
+
},
|
|
4024
|
+
async onRunnerFramesReady (testFiles) {
|
|
4025
|
+
markRunnerFramesReady(testFiles);
|
|
2434
4026
|
},
|
|
2435
4027
|
async onTestFileStart (payload) {
|
|
2436
|
-
await
|
|
2437
|
-
testPath: payload.testPath,
|
|
2438
|
-
tests: []
|
|
2439
|
-
})));
|
|
4028
|
+
await handleTestFileStart(payload);
|
|
2440
4029
|
},
|
|
2441
4030
|
async onTestCaseResult (payload) {
|
|
2442
|
-
|
|
2443
|
-
await Promise.all(context.reporters.map((reporter)=>reporter.onTestCaseResult?.(payload)));
|
|
4031
|
+
await handleTestCaseResult(payload);
|
|
2444
4032
|
},
|
|
2445
4033
|
async onTestFileComplete (payload) {
|
|
2446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2488
|
-
|
|
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 =
|
|
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
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
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
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
if (filesChanged) {
|
|
2539
|
-
watchContext.lastTestFiles
|
|
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
|
-
|
|
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)
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
];
|