@rstest/browser 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/browser-container/container-static/js/683.4f821ce8f1.js +194 -0
  2. package/dist/browser-container/container-static/js/index.568aa7cb35.js +1 -0
  3. package/dist/browser-container/container-static/js/lib-react.e24a1d366b.js +2 -0
  4. package/dist/browser-container/index.html +1 -1
  5. package/dist/browserExecutor.d.ts +7 -5
  6. package/dist/browserRsbuild.d.ts +119 -0
  7. package/dist/containerRpc.d.ts +52 -0
  8. package/dist/dispatchCapabilities.d.ts +1 -2
  9. package/dist/headedScheduler.d.ts +58 -0
  10. package/dist/headlessScheduler.d.ts +37 -0
  11. package/dist/hostController.d.ts +12 -47
  12. package/dist/hostPayloads.d.ts +30 -0
  13. package/dist/index.js +1738 -1793
  14. package/dist/protocol.d.ts +5 -0
  15. package/dist/schedulerSeam.d.ts +37 -0
  16. package/dist/watchRerunPlanner.d.ts +5 -0
  17. package/dist/watchRuntime.d.ts +21 -0
  18. package/dist/watchSignals.d.ts +22 -0
  19. package/package.json +5 -5
  20. package/src/browserExecutor.ts +90 -8
  21. package/src/browserRsbuild.ts +1927 -0
  22. package/src/client/entry.ts +3 -0
  23. package/src/containerRpc.ts +206 -0
  24. package/src/dispatchCapabilities.ts +1 -6
  25. package/src/headedScheduler.ts +664 -0
  26. package/src/headlessScheduler.ts +566 -0
  27. package/src/hostController.ts +855 -4163
  28. package/src/hostPayloads.ts +62 -0
  29. package/src/protocol.ts +6 -0
  30. package/src/schedulerSeam.ts +49 -0
  31. package/src/watchRerunPlanner.ts +18 -7
  32. package/src/watchRuntime.ts +83 -0
  33. package/src/watchSignals.ts +93 -0
  34. package/dist/browser-container/container-static/js/243.a8eed2b9e7.js +0 -27406
  35. package/dist/browser-container/container-static/js/243.a8eed2b9e7.js.LICENSE.txt +0 -1
  36. package/dist/browser-container/container-static/js/index.84aaafaf21.js +0 -3058
  37. package/dist/browser-container/container-static/js/lib-react.62b27a21db.js +0 -8454
  38. package/dist/browser-container/container-static/js/lib-react.62b27a21db.js.LICENSE.txt +0 -1
  39. package/dist/headlessLatestRerunScheduler.d.ts +0 -18
  40. package/src/headlessLatestRerunScheduler.ts +0 -76
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { DEFAULT_TEST_TIMEOUT, FATAL_SIGNALS, PhaseTracker, RSTEST_ENV_SYMBOL_KEY, applyWatchInvalidation, applyWebMockRspackConfig, browserIgnoredRuntimeConfigKeys, buildBrowserCoverageMap, color, createCoverageProvider, createRunnerEventSink, createSilentConsoleController, finalizeRunCycle, getNoTestFilesMessage, getNumCpus as browser_getNumCpus, getPrettyConsoleName, getSetupFiles, getTestEntries, hasUserRstestConfigPlugins, importMetaRstestDefine, initModifyRstestConfigHooks, isDebug, isTTY, loadCoverageProvider, logWatchReadyMessage, logger, pluginMockRuntime, prepareWatchRerunState, projectRuntimeConfig, resolveProjectBuildCache, resolveShardedEntries, resolveSnapshotPathDefault, resolveWorkerCount, rsbuild, serializableConfig } from "@rstest/core/internal/browser";
2
- import { existsSync } from "node:fs";
1
+ import { DEFAULT_TEST_TIMEOUT, FATAL_SIGNALS, PhaseTracker, RSTEST_ENV_SYMBOL_KEY, applyWatchInvalidation, applyWebMockRspackConfig, browserIgnoredRuntimeConfigKeys, buildBrowserCoverageMap, color, createCoverageProvider, createRunnerEventSink, createSilentConsoleController, getNumCpus as browser_getNumCpus, getPrettyConsoleName, getSetupFiles, getTestEntries, hasUserRstestConfigPlugins, importMetaRstestDefine, initModifyRstestConfigHooks, isDebug, isTTY, loadCoverageProvider, logWatchReadyMessage, logger, pluginMockRuntime, projectRuntimeConfig, resolveProjectBuildCache, resolveShardedEntries, resolveSnapshotPathDefault, resolveWorkerCount, rsbuild, serializableConfig } from "@rstest/core/internal/browser";
3
2
  import promises from "node:fs/promises";
3
+ import { dirname, join, normalize, relative, resolve as external_pathe_resolve } from "pathe";
4
+ import { existsSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { isDeepStrictEqual } from "node:util";
6
7
  import open_editor from "open-editor";
7
- import { dirname, join, normalize, relative, resolve as external_pathe_resolve } from "pathe";
8
8
  import sirv from "sirv";
9
9
  import { WebSocketServer } from "ws";
10
10
  import convert_source_map from "convert-source-map";
@@ -1654,218 +1654,6 @@ __webpack_require__.add({
1654
1654
  };
1655
1655
  }
1656
1656
  });
1657
- const TYPE_REQUEST = "q";
1658
- const TYPE_RESPONSE = "s";
1659
- function createPromiseWithResolvers() {
1660
- let resolve;
1661
- let reject;
1662
- return {
1663
- promise: new Promise((res, rej)=>{
1664
- resolve = res;
1665
- reject = rej;
1666
- }),
1667
- resolve,
1668
- reject
1669
- };
1670
- }
1671
- const random = Math.random.bind(Math);
1672
- const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
1673
- function nanoid(size = 21) {
1674
- let id = "";
1675
- let i = size;
1676
- while(i--)id += urlAlphabet[64 * random() | 0];
1677
- return id;
1678
- }
1679
- const DEFAULT_TIMEOUT = 6e4;
1680
- const defaultSerialize = (i)=>i;
1681
- const defaultDeserialize = defaultSerialize;
1682
- const { clearTimeout: dist_clearTimeout, setTimeout: dist_setTimeout } = globalThis;
1683
- function createBirpc($functions, options) {
1684
- const { post, on, off = ()=>{}, eventNames = [], serialize = defaultSerialize, deserialize = defaultDeserialize, resolver, bind = "rpc", timeout = DEFAULT_TIMEOUT, proxify = true } = options;
1685
- let $closed = false;
1686
- const _rpcPromiseMap = /* @__PURE__ */ new Map();
1687
- let _promiseInit;
1688
- let rpc;
1689
- async function _call(method, args, event, optional) {
1690
- if ($closed) throw new Error(`[birpc] rpc is closed, cannot call "${method}"`);
1691
- const req = {
1692
- m: method,
1693
- a: args,
1694
- t: TYPE_REQUEST
1695
- };
1696
- if (optional) req.o = true;
1697
- const send = async (_req)=>post(serialize(_req));
1698
- if (event) return void await send(req);
1699
- if (_promiseInit) try {
1700
- await _promiseInit;
1701
- } finally{
1702
- _promiseInit = void 0;
1703
- }
1704
- let { promise, resolve, reject } = createPromiseWithResolvers();
1705
- const id = nanoid();
1706
- req.i = id;
1707
- let timeoutId;
1708
- async function handler(newReq = req) {
1709
- if (timeout >= 0) {
1710
- timeoutId = dist_setTimeout(()=>{
1711
- try {
1712
- if (options.onTimeoutError?.call(rpc, method, args) !== true) throw new Error(`[birpc] timeout on calling "${method}"`);
1713
- } catch (e) {
1714
- reject(e);
1715
- }
1716
- _rpcPromiseMap.delete(id);
1717
- }, timeout);
1718
- if ("object" == typeof timeoutId) timeoutId = timeoutId.unref?.();
1719
- }
1720
- _rpcPromiseMap.set(id, {
1721
- resolve,
1722
- reject,
1723
- timeoutId,
1724
- method
1725
- });
1726
- await send(newReq);
1727
- return promise;
1728
- }
1729
- try {
1730
- if (options.onRequest) await options.onRequest.call(rpc, req, handler, resolve);
1731
- else await handler();
1732
- } catch (e) {
1733
- if (options.onGeneralError?.call(rpc, e) !== true) throw e;
1734
- return;
1735
- } finally{
1736
- dist_clearTimeout(timeoutId);
1737
- _rpcPromiseMap.delete(id);
1738
- }
1739
- return promise;
1740
- }
1741
- const builtinMethods = {
1742
- $call: (method, ...args)=>_call(method, args, false),
1743
- $callOptional: (method, ...args)=>_call(method, args, false, true),
1744
- $callEvent: (method, ...args)=>_call(method, args, true),
1745
- $callRaw: (options$1)=>_call(options$1.method, options$1.args, options$1.event, options$1.optional),
1746
- $rejectPendingCalls,
1747
- get $closed () {
1748
- return $closed;
1749
- },
1750
- get $meta () {
1751
- return options.meta;
1752
- },
1753
- $close,
1754
- $functions
1755
- };
1756
- rpc = proxify ? new Proxy({}, {
1757
- get (_, method) {
1758
- if (Object.prototype.hasOwnProperty.call(builtinMethods, method)) return builtinMethods[method];
1759
- if ("then" === method && !eventNames.includes("then") && !("then" in $functions)) return;
1760
- const sendEvent = (...args)=>_call(method, args, true);
1761
- if (eventNames.includes(method)) {
1762
- sendEvent.asEvent = sendEvent;
1763
- return sendEvent;
1764
- }
1765
- const sendCall = (...args)=>_call(method, args, false);
1766
- sendCall.asEvent = sendEvent;
1767
- return sendCall;
1768
- }
1769
- }) : builtinMethods;
1770
- function $close(customError) {
1771
- $closed = true;
1772
- _rpcPromiseMap.forEach(({ reject, method })=>{
1773
- const error = /* @__PURE__ */ new Error(`[birpc] rpc is closed, cannot call "${method}"`);
1774
- if (customError) {
1775
- customError.cause ??= error;
1776
- return reject(customError);
1777
- }
1778
- reject(error);
1779
- });
1780
- _rpcPromiseMap.clear();
1781
- off(onMessage);
1782
- }
1783
- function $rejectPendingCalls(handler) {
1784
- const handlerResults = Array.from(_rpcPromiseMap.values()).map(({ method, reject })=>{
1785
- if (!handler) return reject(/* @__PURE__ */ new Error(`[birpc]: rejected pending call "${method}".`));
1786
- return handler({
1787
- method,
1788
- reject
1789
- });
1790
- });
1791
- _rpcPromiseMap.clear();
1792
- return handlerResults;
1793
- }
1794
- async function onMessage(data, ...extra) {
1795
- let msg;
1796
- try {
1797
- msg = deserialize(data);
1798
- } catch (e) {
1799
- if (options.onGeneralError?.call(rpc, e) !== true) throw e;
1800
- return;
1801
- }
1802
- if (msg.t === TYPE_REQUEST) {
1803
- const { m: method, a: args, o: optional } = msg;
1804
- let result, error;
1805
- let fn = await (resolver ? resolver.call(rpc, method, $functions[method]) : $functions[method]);
1806
- if (optional) fn ||= ()=>void 0;
1807
- if (fn) try {
1808
- result = await fn.apply("rpc" === bind ? rpc : $functions, args);
1809
- } catch (e) {
1810
- error = e;
1811
- }
1812
- else error = /* @__PURE__ */ new Error(`[birpc] function "${method}" not found`);
1813
- if (msg.i) {
1814
- if (error && options.onFunctionError) {
1815
- if (true === options.onFunctionError.call(rpc, error, method, args)) return;
1816
- }
1817
- if (!error) try {
1818
- await post(serialize({
1819
- t: TYPE_RESPONSE,
1820
- i: msg.i,
1821
- r: result
1822
- }), ...extra);
1823
- return;
1824
- } catch (e) {
1825
- error = e;
1826
- if (options.onGeneralError?.call(rpc, e, method, args) !== true) throw e;
1827
- }
1828
- try {
1829
- await post(serialize({
1830
- t: TYPE_RESPONSE,
1831
- i: msg.i,
1832
- e: error
1833
- }), ...extra);
1834
- } catch (e) {
1835
- if (options.onGeneralError?.call(rpc, e, method, args) !== true) throw e;
1836
- }
1837
- }
1838
- } else {
1839
- const { i: ack, r: result, e: error } = msg;
1840
- const promise = _rpcPromiseMap.get(ack);
1841
- if (promise) {
1842
- dist_clearTimeout(promise.timeoutId);
1843
- if (error) promise.reject(error);
1844
- else promise.resolve(result);
1845
- }
1846
- _rpcPromiseMap.delete(ack);
1847
- }
1848
- }
1849
- _promiseInit = on(onMessage);
1850
- return rpc;
1851
- }
1852
- const DEFAULT_MAX_HEADLESS_WORKERS = 12;
1853
- const resolveHeadlessWorkerCount = ({ command, maxWorkers, totalTasks, numCpus = browser_getNumCpus() })=>{
1854
- const base = Math.max(Math.min(DEFAULT_MAX_HEADLESS_WORKERS, numCpus - 1), 1);
1855
- return resolveWorkerCount({
1856
- command,
1857
- maxWorkers,
1858
- totalTasks,
1859
- recommended: base,
1860
- watchRecommended: Math.max(Math.floor(base / 2), 1),
1861
- numCpus
1862
- });
1863
- };
1864
- const getHeadlessConcurrency = (context, totalTests)=>resolveHeadlessWorkerCount({
1865
- command: context.command,
1866
- maxWorkers: context.normalizedConfig.pool.maxWorkers,
1867
- totalTasks: totalTests
1868
- });
1869
1657
  const toErrorMessage = (error)=>error instanceof Error ? error.message : String(error);
1870
1658
  class HostDispatchRouter {
1871
1659
  handlers = new Map();
@@ -2141,104 +1929,6 @@ const validateBrowserConfig = (context)=>{
2141
1929
  }
2142
1930
  reportUnsupportedBrowserOptions(context);
2143
1931
  };
2144
- const createHeadedSerialTaskQueue = ()=>{
2145
- let queue = Promise.resolve();
2146
- const enqueue = (task)=>{
2147
- const next = queue.then(task);
2148
- queue = next.catch(()=>{});
2149
- return next;
2150
- };
2151
- return {
2152
- enqueue
2153
- };
2154
- };
2155
- const createHeadlessLatestRerunScheduler = (options)=>{
2156
- let pendingFiles = null;
2157
- let draining = null;
2158
- let latestEnqueueVersion = 0;
2159
- const runDrainLoop = async ()=>{
2160
- while(pendingFiles){
2161
- const nextFiles = pendingFiles;
2162
- pendingFiles = null;
2163
- try {
2164
- await options.runFiles(nextFiles);
2165
- } catch (error) {
2166
- try {
2167
- await options.onError?.(error);
2168
- } catch {}
2169
- }
2170
- }
2171
- };
2172
- const ensureDrainLoop = ()=>{
2173
- if (draining) return;
2174
- draining = runDrainLoop().finally(()=>{
2175
- draining = null;
2176
- });
2177
- };
2178
- return {
2179
- async enqueueLatest (files) {
2180
- const enqueueVersion = ++latestEnqueueVersion;
2181
- const activeRun = options.getActiveRun();
2182
- if (activeRun && !options.isRunCancelled(activeRun)) {
2183
- options.onInterrupt?.(activeRun);
2184
- options.invalidateActiveRun();
2185
- await options.interruptActiveRun(activeRun);
2186
- }
2187
- if (enqueueVersion !== latestEnqueueVersion) return;
2188
- pendingFiles = files;
2189
- ensureDrainLoop();
2190
- },
2191
- async whenIdle () {
2192
- await draining;
2193
- }
2194
- };
2195
- };
2196
- const attachHeadlessRunnerTransport = async (page, handlers)=>{
2197
- await page.exposeFunction(DISPATCH_MESSAGE_TYPE, handlers.onDispatchMessage);
2198
- await page.exposeFunction(DISPATCH_RPC_BRIDGE_NAME, handlers.onDispatchRpc);
2199
- };
2200
- const isRecord = (value)=>'object' == typeof value && null !== value;
2201
- const readString = (value, key, label)=>{
2202
- const result = value[key];
2203
- if ('string' != typeof result) throw new Error(`Invalid browser RPC request: ${label} must be a string`);
2204
- return result;
2205
- };
2206
- const readUnknownArray = (value, key, label)=>{
2207
- const result = value[key];
2208
- if (!Array.isArray(result)) throw new Error(`Invalid browser RPC request: ${label} must be an array`);
2209
- return result;
2210
- };
2211
- const parseBrowserLocatorIR = (value, label)=>{
2212
- if (!isRecord(value)) throw new Error(`Invalid browser RPC request: ${label} must be an object`);
2213
- const steps = value.steps;
2214
- if (!Array.isArray(steps)) throw new Error(`Invalid browser RPC request: ${label}.steps must be an array`);
2215
- return value;
2216
- };
2217
- const validateBrowserRpcRequest = (payload)=>{
2218
- if (!isRecord(payload)) throw new Error('Invalid browser RPC request: payload must be an object');
2219
- const kind = readString(payload, 'kind', 'kind');
2220
- if ('locator' !== kind && 'expect' !== kind && 'config' !== kind) throw new Error(`Invalid browser RPC request: unsupported kind ${JSON.stringify(kind)}`);
2221
- const request = {
2222
- id: readString(payload, 'id', 'id'),
2223
- testPath: readString(payload, 'testPath', 'testPath'),
2224
- runId: readString(payload, 'runId', 'runId'),
2225
- kind,
2226
- locator: parseBrowserLocatorIR(payload.locator, 'locator'),
2227
- method: readString(payload, 'method', 'method'),
2228
- args: readUnknownArray(payload, 'args', 'args')
2229
- };
2230
- const isNot = payload.isNot;
2231
- if (void 0 !== isNot) {
2232
- if ('boolean' != typeof isNot) throw new Error('Invalid browser RPC request: isNot must be a boolean');
2233
- request.isNot = isNot;
2234
- }
2235
- const timeout = payload.timeout;
2236
- if (void 0 !== timeout) {
2237
- if ('number' != typeof timeout) throw new Error('Invalid browser RPC request: timeout must be a number');
2238
- request.timeout = timeout;
2239
- }
2240
- return request;
2241
- };
2242
1932
  const supportedLocatorActions = new Set([
2243
1933
  'click',
2244
1934
  'dblclick',
@@ -2686,159 +2376,9 @@ function getBrowserProviderImplementation(provider) {
2686
2376
  if (!implementation) throw new Error(`Unsupported browser provider: ${String(provider)}`);
2687
2377
  return implementation;
2688
2378
  }
2689
- const createCancelSignal = ()=>{
2690
- let settled = false;
2691
- let resolveSignal = ()=>{};
2692
- const signal = new Promise((resolve)=>{
2693
- resolveSignal = ()=>{
2694
- if (!settled) {
2695
- settled = true;
2696
- resolve();
2697
- }
2698
- };
2699
- });
2700
- return {
2701
- signal,
2702
- resolve: resolveSignal
2703
- };
2704
- };
2705
- const createRunSession = (token)=>{
2706
- const { signal, resolve } = createCancelSignal();
2707
- return {
2708
- token,
2709
- cancelled: false,
2710
- cancelSignal: signal,
2711
- signalCancel: resolve
2712
- };
2713
- };
2714
- class RunSessionLifecycle {
2715
- currentToken = 0;
2716
- active = null;
2717
- get activeSession() {
2718
- return this.active;
2719
- }
2720
- get activeToken() {
2721
- return this.currentToken;
2722
- }
2723
- createSession(factory) {
2724
- const session = factory(++this.currentToken);
2725
- this.active = session;
2726
- return session;
2727
- }
2728
- isTokenActive(token) {
2729
- return token === this.currentToken;
2730
- }
2731
- isTokenStale(token) {
2732
- return !this.isTokenActive(token);
2733
- }
2734
- invalidateActiveToken() {
2735
- this.currentToken += 1;
2736
- return this.currentToken;
2737
- }
2738
- clearIfActive(session) {
2739
- if (this.active === session) this.active = null;
2740
- }
2741
- async cancel(session, options) {
2742
- const waitForDone = options?.waitForDone ?? true;
2743
- if (!session.cancelled) {
2744
- session.cancelled = true;
2745
- session.signalCancel();
2746
- await options?.onCancel?.(session);
2747
- }
2748
- if (waitForDone) await session.done?.catch(()=>{});
2749
- }
2750
- async cancelActive(options) {
2751
- if (!this.active) return;
2752
- await this.cancel(this.active, options);
2753
- }
2754
- }
2755
- class RunnerSessionRegistry {
2756
- nextId = 0;
2757
- sessionsById = new Map();
2758
- sessionIdByTestFile = new Map();
2759
- register(input) {
2760
- const id = input.id ?? `runner-session-${++this.nextId}`;
2761
- const createdAt = input.createdAt ?? Date.now();
2762
- const record = {
2763
- ...input,
2764
- id,
2765
- createdAt
2766
- };
2767
- this.sessionsById.set(id, record);
2768
- this.sessionIdByTestFile.set(record.testFile, id);
2769
- return record;
2770
- }
2771
- getById(id) {
2772
- return this.sessionsById.get(id);
2773
- }
2774
- getByTestFile(testFile) {
2775
- const id = this.sessionIdByTestFile.get(testFile);
2776
- if (!id) return;
2777
- return this.sessionsById.get(id);
2778
- }
2779
- list() {
2780
- return Array.from(this.sessionsById.values());
2781
- }
2782
- listByRunToken(runToken) {
2783
- return this.list().filter((session)=>session.runToken === runToken);
2784
- }
2785
- deleteById(id) {
2786
- const record = this.sessionsById.get(id);
2787
- if (!record) return false;
2788
- this.sessionsById.delete(id);
2789
- if (this.sessionIdByTestFile.get(record.testFile) === id) this.sessionIdByTestFile.delete(record.testFile);
2790
- return true;
2791
- }
2792
- deleteByTestFile(testFile) {
2793
- const id = this.sessionIdByTestFile.get(testFile);
2794
- if (!id) return false;
2795
- return this.deleteById(id);
2796
- }
2797
- clear() {
2798
- this.sessionsById.clear();
2799
- this.sessionIdByTestFile.clear();
2800
- }
2801
- }
2802
- const normalizeJavaScriptUrl = (value, options)=>{
2803
- try {
2804
- const url = options?.origin ? new URL(value, options.origin) : new URL(value);
2805
- if ('http:' !== url.protocol && 'https:' !== url.protocol) return null;
2806
- url.search = '';
2807
- url.hash = '';
2808
- return url.toString();
2809
- } catch {
2810
- return null;
2811
- }
2812
- };
2813
- const resolveInlineSourceMap = (code)=>{
2814
- const converter = convert_source_map.fromSource(code);
2815
- if (!converter) return null;
2816
- return converter.toObject();
2817
- };
2818
- const fetchSourceMap = async (jsUrl, fetcher)=>{
2819
- const jsResponse = await fetcher(jsUrl);
2820
- if (!jsResponse.ok) return null;
2821
- const code = await jsResponse.text();
2822
- const inlineMap = resolveInlineSourceMap(code);
2823
- if (inlineMap) return inlineMap;
2824
- const mapResponse = await fetcher(`${jsUrl}.map`);
2825
- if (!mapResponse.ok) return null;
2826
- return await mapResponse.json();
2827
- };
2828
- const loadSourceMapWithCache = async ({ jsUrl, cache, force = false, origin, fetcher = fetch })=>{
2829
- const normalizedUrl = normalizeJavaScriptUrl(jsUrl, {
2830
- origin
2831
- });
2832
- if (!normalizedUrl) return null;
2833
- if (!force && cache.has(normalizedUrl)) return cache.get(normalizedUrl) ?? null;
2834
- try {
2835
- const sourceMap = await fetchSourceMap(normalizedUrl, fetcher);
2836
- cache.set(normalizedUrl, sourceMap);
2837
- return sourceMap;
2838
- } catch {
2839
- cache.set(normalizedUrl, null);
2840
- return null;
2841
- }
2379
+ const collectDeletedTestPaths = (previous, current)=>{
2380
+ const currentPathSet = new Set(current.map((file)=>file.testPath));
2381
+ return previous.map((file)=>file.testPath).filter((testPath)=>!currentPathSet.has(testPath));
2842
2382
  };
2843
2383
  const serializeTestFiles = (files)=>JSON.stringify(files.map((f)=>`${f.projectName}:${f.testPath}`).sort());
2844
2384
  const normalizeTestFiles = (files)=>files.map((file)=>({
@@ -2854,11 +2394,8 @@ const planWatchRerun = ({ projectEntries, previousTestFiles, affectedTestFiles }
2854
2394
  const normalizedPrevious = normalizeTestFiles(previousTestFiles);
2855
2395
  const filesChanged = serializeTestFiles(currentTestFiles) !== serializeTestFiles(normalizedPrevious);
2856
2396
  const normalizedAffectedTestFiles = affectedTestFiles.map((testFile)=>normalize(testFile));
2857
- const currentFileMap = new Map(currentTestFiles.map((file)=>[
2858
- file.testPath,
2859
- file
2860
- ]));
2861
- const matchedAffectedFiles = normalizedAffectedTestFiles.map((testFile)=>currentFileMap.get(testFile)).filter((file)=>Boolean(file));
2397
+ const affectedPathSet = new Set(normalizedAffectedTestFiles);
2398
+ const matchedAffectedFiles = currentTestFiles.filter((file)=>affectedPathSet.has(file.testPath));
2862
2399
  return {
2863
2400
  currentTestFiles,
2864
2401
  filesChanged,
@@ -2869,162 +2406,49 @@ const planWatchRerun = ({ projectEntries, previousTestFiles, affectedTestFiles }
2869
2406
  const picomatch = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.5/node_modules/picomatch/index.js");
2870
2407
  var picomatch_default = /*#__PURE__*/ __webpack_require__.n(picomatch);
2871
2408
  const { createRsbuild: createRsbuild, rspack: rspack } = rsbuild;
2872
- const hostController_dirname = dirname(fileURLToPath(import.meta.url));
2409
+ const browserRsbuild_dirname = dirname(fileURLToPath(import.meta.url));
2873
2410
  const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
2874
- let nextBrowserFilePid = 1000000000;
2875
2411
  const serializeForInlineScript = (value)=>JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
2876
2412
  const getBrowserProviderOptions = (project)=>{
2877
2413
  const browserConfig = project.normalizedConfig.browser;
2878
2414
  return browserConfig.providerOptions ?? {};
2879
2415
  };
2880
- const getFileTaskId = (testPath)=>`file:${testPath}`;
2881
- const createDeferredPromise = ()=>{
2882
- let resolve;
2883
- let reject;
2884
- const promise = new Promise((res, rej)=>{
2885
- resolve = res;
2886
- reject = rej;
2416
+ const createBrowserWatchState = ()=>({
2417
+ lastTestFiles: [],
2418
+ hooksEnabled: false,
2419
+ invalidation: new Map(),
2420
+ pendingAffectedTestFiles: new Map(),
2421
+ compileStartTimes: new Map(),
2422
+ pendingBuildTimeMs: 0
2887
2423
  });
2888
- return {
2889
- promise,
2890
- resolve,
2891
- reject
2424
+ const drainPendingBuildTime = (watchState)=>{
2425
+ const buildTime = watchState.pendingBuildTimeMs;
2426
+ watchState.pendingBuildTimeMs = 0;
2427
+ return buildTime;
2428
+ };
2429
+ const drainPendingAffectedTestFiles = (watchState)=>{
2430
+ const affected = new Set();
2431
+ for (const files of watchState.pendingAffectedTestFiles.values())for (const file of files)affected.add(file);
2432
+ watchState.pendingAffectedTestFiles.clear();
2433
+ return Array.from(affected);
2434
+ };
2435
+ const resolveViewport = (viewport)=>{
2436
+ if (!viewport) return null;
2437
+ if ('string' == typeof viewport) return resolveBrowserViewportPreset(viewport);
2438
+ if ('number' == typeof viewport.width && Number.isFinite(viewport.width) && viewport.width > 0 && 'number' == typeof viewport.height && Number.isFinite(viewport.height) && viewport.height > 0) return {
2439
+ width: viewport.width,
2440
+ height: viewport.height
2892
2441
  };
2442
+ return null;
2893
2443
  };
2894
- class ContainerRpcManager {
2895
- wss;
2896
- ws = null;
2897
- rpc = null;
2898
- methods;
2899
- onDisconnect;
2900
- detachActiveSocketListeners = null;
2901
- constructor(wss, methods, onDisconnect){
2902
- this.wss = wss;
2903
- this.methods = methods;
2904
- this.onDisconnect = onDisconnect;
2905
- this.setupConnectionHandler();
2906
- }
2907
- updateMethods(methods, onDisconnect) {
2908
- this.methods = methods;
2909
- this.onDisconnect = onDisconnect;
2910
- if (this.ws && this.ws.readyState === this.ws.OPEN) this.attachWebSocket(this.ws);
2911
- }
2912
- setupConnectionHandler() {
2913
- this.wss.on('connection', (ws)=>{
2914
- logger.debug('[Browser UI] Container WebSocket connected');
2915
- logger.debug(`[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`);
2916
- this.attachWebSocket(ws);
2917
- });
2918
- }
2919
- attachWebSocket(ws) {
2920
- this.detachActiveSocketListeners?.();
2921
- if (this.rpc && !this.rpc.$closed) this.rpc.$close(new Error('Container RPC transport reattached'));
2922
- this.ws = ws;
2923
- const messageHandlers = new WeakMap();
2924
- this.rpc = createBirpc(this.methods, {
2925
- timeout: -1,
2926
- post: (data)=>{
2927
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(data));
2928
- },
2929
- on: (fn)=>{
2930
- const handler = (message)=>{
2931
- try {
2932
- const data = JSON.parse(message.toString());
2933
- fn(data);
2934
- } catch {}
2935
- };
2936
- messageHandlers.set(fn, handler);
2937
- ws.on('message', handler);
2938
- },
2939
- off: (fn)=>{
2940
- const handler = messageHandlers.get(fn);
2941
- if (!handler) return;
2942
- ws.off('message', handler);
2943
- messageHandlers.delete(fn);
2944
- }
2945
- });
2946
- const handleClose = ()=>{
2947
- if (this.ws === ws) this.ws = null;
2948
- this.detachActiveSocketListeners?.();
2949
- this.detachActiveSocketListeners = null;
2950
- if (this.rpc && !this.rpc.$closed) {
2951
- const disconnectError = new Error('Browser UI WebSocket disconnected before reload completed');
2952
- this.rpc.$close(disconnectError);
2953
- this.onDisconnect?.(disconnectError);
2954
- }
2955
- this.rpc = null;
2956
- };
2957
- ws.on('close', handleClose);
2958
- this.detachActiveSocketListeners = ()=>{
2959
- ws.off('close', handleClose);
2960
- };
2961
- }
2962
- get isConnected() {
2963
- return null !== this.ws && this.ws.readyState === this.ws.OPEN;
2964
- }
2965
- get currentWebSocket() {
2966
- return this.ws;
2967
- }
2968
- reattach(ws) {
2969
- this.attachWebSocket(ws);
2970
- }
2971
- async notifyTestFileUpdate(files) {
2972
- await this.rpc?.onTestFileUpdate(files);
2973
- }
2974
- async updateHostConfig(config) {
2975
- await this.rpc?.onHostConfigUpdate(config);
2976
- }
2977
- async reloadTestFile(testFile, testNamePattern) {
2978
- logger.debug(`[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`);
2979
- if (!this.rpc) throw new Error('Browser UI RPC not available for reloadTestFile');
2980
- logger.debug(`[Browser UI] Calling reloadTestFile: ${testFile}`);
2981
- return this.rpc.reloadTestFile(testFile, testNamePattern);
2982
- }
2983
- }
2984
- const createBrowserWatchState = ()=>({
2985
- lastTestFiles: [],
2986
- hooksEnabled: false,
2987
- invalidation: new Map(),
2988
- pendingAffectedTestFiles: new Map(),
2989
- compileStartTimes: new Map(),
2990
- pendingBuildTimeMs: 0
2991
- });
2992
- const drainPendingBuildTime = (watchState)=>{
2993
- const buildTime = watchState.pendingBuildTimeMs;
2994
- watchState.pendingBuildTimeMs = 0;
2995
- return buildTime;
2996
- };
2997
- const drainPendingAffectedTestFiles = (watchState)=>{
2998
- const affected = new Set();
2999
- for (const files of watchState.pendingAffectedTestFiles.values())for (const file of files)affected.add(file);
3000
- watchState.pendingAffectedTestFiles.clear();
3001
- return Array.from(affected);
3002
- };
3003
- const watchContext = {
3004
- runtime: null,
3005
- cleanupRegistered: false,
3006
- cleanupPromise: null
3007
- };
3008
- const resolveViewport = (viewport)=>{
3009
- if (!viewport) return null;
3010
- if ('string' == typeof viewport) return resolveBrowserViewportPreset(viewport);
3011
- if ('number' == typeof viewport.width && Number.isFinite(viewport.width) && viewport.width > 0 && 'number' == typeof viewport.height && Number.isFinite(viewport.height) && viewport.height > 0) return {
3012
- width: viewport.width,
3013
- height: viewport.height
3014
- };
3015
- return null;
3016
- };
3017
- const mapViewportByProject = (projects)=>{
3018
- const map = new Map();
3019
- for (const project of projects){
3020
- const viewport = resolveViewport(project.viewport);
3021
- if (viewport) map.set(project.name, viewport);
2444
+ const mapViewportByProject = (projects)=>{
2445
+ const map = new Map();
2446
+ for (const project of projects){
2447
+ const viewport = resolveViewport(project.viewport);
2448
+ if (viewport) map.set(project.name, viewport);
3022
2449
  }
3023
2450
  return map;
3024
2451
  };
3025
- const ensureProcessExitCode = (code)=>{
3026
- if (void 0 === process.exitCode || 0 === process.exitCode) process.exitCode = code;
3027
- };
3028
2452
  const castArray = (arr)=>{
3029
2453
  if (void 0 === arr) return [];
3030
2454
  return Array.isArray(arr) ? arr : [
@@ -3228,7 +2652,6 @@ const getBrowserRsbuildEnvironmentConfig = (project)=>({
3228
2652
  plugins: project.normalizedConfig.plugins,
3229
2653
  root: project.rootPath
3230
2654
  });
3231
- const getMaxTestTimeoutForRpc = (projects)=>Math.max(...projects.map((p)=>p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT));
3232
2655
  const getBrowserLaunchOptions = (project)=>({
3233
2656
  provider: project.normalizedConfig.browser.provider,
3234
2657
  browser: project.normalizedConfig.browser.browser,
@@ -3247,15 +2670,7 @@ const ensureConsistentBrowserLaunchOptions = (projects)=>{
3247
2670
  }
3248
2671
  return firstOptions;
3249
2672
  };
3250
- const resolveProviderForTestPath = ({ testPath, browserProjects })=>{
3251
- const normalizedTestPath = normalize(testPath);
3252
- const sortedProjects = [
3253
- ...browserProjects
3254
- ].sort((a, b)=>b.rootPath.length - a.rootPath.length);
3255
- for (const project of sortedProjects)if (normalizedTestPath.startsWith(project.rootPath)) return project.provider;
3256
- throw new Error(`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. Known project roots: ${JSON.stringify(sortedProjects.map((p)=>p.rootPath))}`);
3257
- };
3258
- const collectProjectEntries = async (context, browserProjects = getBrowserProjects(context))=>Promise.all(browserProjects.map(async (project)=>{
2673
+ const browserRsbuild_collectProjectEntries = async (context, browserProjects = getBrowserProjects(context))=>Promise.all(browserProjects.map(async (project)=>{
3259
2674
  const { normalizedConfig: { include, exclude, includeSource, setupFiles } } = project;
3260
2675
  const tests = await getTestEntries({
3261
2676
  include,
@@ -3275,14 +2690,14 @@ const collectProjectEntries = async (context, browserProjects = getBrowserProjec
3275
2690
  }));
3276
2691
  const resolveBrowserFile = (relativePath)=>{
3277
2692
  const candidates = [
3278
- external_pathe_resolve(hostController_dirname, '../src', relativePath),
3279
- external_pathe_resolve(hostController_dirname, relativePath)
2693
+ external_pathe_resolve(browserRsbuild_dirname, '../src', relativePath),
2694
+ external_pathe_resolve(browserRsbuild_dirname, relativePath)
3280
2695
  ];
3281
2696
  for (const candidate of candidates)if (existsSync(candidate)) return candidate;
3282
2697
  throw new Error(`Unable to resolve browser client file: ${relativePath}`);
3283
2698
  };
3284
2699
  const resolveContainerDist = ()=>{
3285
- const distPath = external_pathe_resolve(hostController_dirname, 'browser-container');
2700
+ const distPath = external_pathe_resolve(browserRsbuild_dirname, 'browser-container');
3286
2701
  if (existsSync(distPath)) return distPath;
3287
2702
  throw new Error(`Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`);
3288
2703
  };
@@ -3424,26 +2839,6 @@ const destroyBrowserRuntime = async (runtime)=>{
3424
2839
  force: true
3425
2840
  }).catch(()=>{});
3426
2841
  };
3427
- const cleanupWatchRuntime = ()=>{
3428
- if (watchContext.cleanupPromise) return watchContext.cleanupPromise;
3429
- watchContext.cleanupPromise = (async ()=>{
3430
- if (!watchContext.runtime) return;
3431
- await destroyBrowserRuntime(watchContext.runtime);
3432
- watchContext.runtime = null;
3433
- })();
3434
- return watchContext.cleanupPromise;
3435
- };
3436
- const registerWatchCleanup = (embedded)=>{
3437
- if (watchContext.cleanupRegistered) return;
3438
- watchContext.cleanupRegistered = true;
3439
- if (embedded) return;
3440
- for (const signal of FATAL_SIGNALS)process.once(signal, ()=>{
3441
- cleanupWatchRuntime();
3442
- });
3443
- process.once('exit', ()=>{
3444
- cleanupWatchRuntime();
3445
- });
3446
- };
3447
2842
  const createBrowserRuntime = async ({ context, projectEntries: initialProjectEntries, browserProjects, shardedEntries, freezeShardedEntries, tempDir, isWatchMode, onTriggerRerun, containerDistPath, containerDevServer, forceHeadless, skipProviderLaunch, appliedModifyRstestConfigEnvironments })=>{
3448
2843
  const containerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'index.html'), 'utf-8') : null;
3449
2844
  let injectedContainerHtml = null;
@@ -3549,6 +2944,30 @@ const createBrowserRuntime = async ({ context, projectEntries: initialProjectEnt
3549
2944
  return false;
3550
2945
  }
3551
2946
  };
2947
+ const serveContainerRoute = async (req, res, next)=>{
2948
+ if (!req.url) return void next();
2949
+ const url = new URL(req.url, 'http://localhost');
2950
+ if ('/' === url.pathname) {
2951
+ if (await respondWithDevServerHtml(url, res)) return;
2952
+ const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
2953
+ if (html) {
2954
+ res.setHeader('Content-Type', 'text/html');
2955
+ res.end(html);
2956
+ return;
2957
+ }
2958
+ res.statusCode = 502;
2959
+ res.end('Container UI is not available.');
2960
+ return;
2961
+ }
2962
+ if (url.pathname.startsWith('/container-static/')) {
2963
+ if (await proxyDevServerAsset(req, res)) return;
2964
+ if (serveContainer) return void serveContainer(req, res, next);
2965
+ res.statusCode = 502;
2966
+ res.end('Container assets are not available.');
2967
+ return;
2968
+ }
2969
+ next();
2970
+ };
3552
2971
  const buildProjectServer = async (project, isContainerServer)=>{
3553
2972
  const manifestPath = join(tempDir, toSafeVarName(project.environmentName), VIRTUAL_MANIFEST_FILENAME);
3554
2973
  const entry = getProjectEntry(project);
@@ -3585,7 +3004,10 @@ const createBrowserRuntime = async ({ context, projectEntries: initialProjectEnt
3585
3004
  server: {
3586
3005
  printUrls: false,
3587
3006
  port: project.normalizedConfig.browser.port ?? (isContainerServer ? 4000 : 0),
3588
- strictPort: project.normalizedConfig.browser.strictPort
3007
+ strictPort: project.normalizedConfig.browser.strictPort,
3008
+ setup: isContainerServer ? ({ server })=>{
3009
+ server.middlewares.use(serveContainerRoute);
3010
+ } : void 0
3589
3011
  },
3590
3012
  dev: createBrowserRsbuildDevConfig(enableHmr),
3591
3013
  environments: {
@@ -3704,7 +3126,7 @@ const createBrowserRuntime = async ({ context, projectEntries: initialProjectEnt
3704
3126
  if (watchState.hooksEnabled) watchState.pendingBuildTimeMs = Math.max(watchState.pendingBuildTimeMs, Date.now() - compileStart);
3705
3127
  }
3706
3128
  if (stats) {
3707
- const [projectEntry] = await collectProjectEntries(context, [
3129
+ const [projectEntry] = await browserRsbuild_collectProjectEntries(context, [
3708
3130
  project
3709
3131
  ]);
3710
3132
  const entryTestFiles = new Set(collectWatchTestFiles(projectEntry ? [
@@ -3800,27 +3222,6 @@ const createBrowserRuntime = async ({ context, projectEntries: initialProjectEnt
3800
3222
  }
3801
3223
  return;
3802
3224
  }
3803
- if (isContainerServer) {
3804
- if ('/' === url.pathname) {
3805
- if (await respondWithDevServerHtml(url, res)) return;
3806
- const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
3807
- if (html) {
3808
- res.setHeader('Content-Type', 'text/html');
3809
- res.end(html);
3810
- return;
3811
- }
3812
- res.statusCode = 502;
3813
- res.end('Container UI is not available.');
3814
- return;
3815
- }
3816
- if (url.pathname.startsWith('/container-static/')) {
3817
- if (await proxyDevServerAsset(req, res)) return;
3818
- if (serveContainer) return void serveContainer(req, res, next);
3819
- res.statusCode = 502;
3820
- res.end('Container assets are not available.');
3821
- return;
3822
- }
3823
- }
3824
3225
  if ('/runner.html' === url.pathname) {
3825
3226
  res.setHeader('Content-Type', 'text/html');
3826
3227
  res.end(htmlTemplate);
@@ -3903,1209 +3304,1722 @@ async function resolveProjectEntries(context, shardedEntries, browserProjects) {
3903
3304
  }
3904
3305
  return projectEntries;
3905
3306
  }
3906
- return collectProjectEntries(context, browserProjects);
3307
+ return browserRsbuild_collectProjectEntries(context, browserProjects);
3907
3308
  }
3908
- const runBrowserController = async (context, options)=>{
3909
- const { allowEmptyWatchRun = false, allowEmptyRun = false, filesOnly = false, onTraceEvents, env } = options ?? {};
3910
- const buildStart = Date.now();
3911
- const isWatchMode = 'watch' === context.command;
3912
- const phaseTrackers = onTraceEvents ? new Map() : void 0;
3913
- const browserProjects = options?.projects ?? getBrowserProjects(context);
3914
- const useHeadlessDirect = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
3915
- const browserSourceMapCache = new Map();
3916
- const isHttpLikeFile = (file)=>/^https?:\/\//.test(file);
3917
- const resolveBrowserSourcemap = async (sourcePath)=>{
3918
- if (!isHttpLikeFile(sourcePath)) return {
3919
- handled: false,
3920
- sourcemap: null
3921
- };
3922
- const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
3923
- if (!normalizedUrl) return {
3924
- handled: true,
3925
- sourcemap: null
3926
- };
3927
- if (browserSourceMapCache.has(normalizedUrl)) return {
3928
- handled: true,
3929
- sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null
3930
- };
3931
- return {
3932
- handled: true,
3933
- sourcemap: await loadSourceMapWithCache({
3934
- jsUrl: normalizedUrl,
3935
- cache: browserSourceMapCache
3936
- })
3937
- };
3938
- };
3939
- const getBrowserSourcemap = async (sourcePath)=>{
3940
- const result = await resolveBrowserSourcemap(sourcePath);
3941
- return result.handled ? result.sourcemap : null;
3309
+ const TYPE_REQUEST = "q";
3310
+ const TYPE_RESPONSE = "s";
3311
+ function createPromiseWithResolvers() {
3312
+ let resolve;
3313
+ let reject;
3314
+ return {
3315
+ promise: new Promise((res, rej)=>{
3316
+ resolve = res;
3317
+ reject = rej;
3318
+ }),
3319
+ resolve,
3320
+ reject
3942
3321
  };
3943
- const buildErrorResult = async (error, close)=>{
3944
- const elapsed = Math.max(0, Date.now() - buildStart);
3945
- const errorResult = {
3946
- results: [],
3947
- testResults: [],
3948
- duration: {
3949
- totalTime: elapsed,
3950
- buildTime: elapsed,
3951
- testTime: 0
3952
- },
3953
- hasFailure: true,
3954
- unhandledErrors: [
3955
- error
3956
- ],
3957
- getSourcemap: getBrowserSourcemap,
3958
- resolveSourcemap: resolveBrowserSourcemap,
3959
- close
3322
+ }
3323
+ const random = Math.random.bind(Math);
3324
+ const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
3325
+ function nanoid(size = 21) {
3326
+ let id = "";
3327
+ let i = size;
3328
+ while(i--)id += urlAlphabet[64 * random() | 0];
3329
+ return id;
3330
+ }
3331
+ const DEFAULT_TIMEOUT = 6e4;
3332
+ const defaultSerialize = (i)=>i;
3333
+ const defaultDeserialize = defaultSerialize;
3334
+ const { clearTimeout: dist_clearTimeout, setTimeout: dist_setTimeout } = globalThis;
3335
+ function createBirpc($functions, options) {
3336
+ const { post, on, off = ()=>{}, eventNames = [], serialize = defaultSerialize, deserialize = defaultDeserialize, resolver, bind = "rpc", timeout = DEFAULT_TIMEOUT, proxify = true } = options;
3337
+ let $closed = false;
3338
+ const _rpcPromiseMap = /* @__PURE__ */ new Map();
3339
+ let _promiseInit;
3340
+ let rpc;
3341
+ async function _call(method, args, event, optional) {
3342
+ if ($closed) throw new Error(`[birpc] rpc is closed, cannot call "${method}"`);
3343
+ const req = {
3344
+ m: method,
3345
+ a: args,
3346
+ t: TYPE_REQUEST
3960
3347
  };
3961
- if (isWatchMode) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
3962
- results: [],
3963
- testResults: [],
3964
- duration: errorResult.duration,
3965
- snapshotSummary: context.snapshotManager.summary,
3966
- getSourcemap: getBrowserSourcemap,
3967
- unhandledErrors: errorResult.unhandledErrors
3968
- });
3969
- return errorResult;
3970
- };
3971
- const toError = (error)=>error instanceof Error ? error : new Error(String(error));
3972
- const failWithError = async (error, cleanup)=>{
3973
- if (isWatchMode) ensureProcessExitCode(1);
3974
- const normalizedError = toError(error);
3975
- if (cleanup && !isWatchMode) return buildErrorResult(normalizedError, cleanup);
3976
- try {
3977
- return await buildErrorResult(normalizedError);
3348
+ if (optional) req.o = true;
3349
+ const send = async (_req)=>post(serialize(_req));
3350
+ if (event) return void await send(req);
3351
+ if (_promiseInit) try {
3352
+ await _promiseInit;
3978
3353
  } finally{
3979
- await cleanup?.();
3980
- }
3981
- };
3982
- const collectDeletedTestPaths = (previous, current)=>{
3983
- const currentPathSet = new Set(current.map((file)=>file.testPath));
3984
- return previous.map((file)=>file.testPath).filter((testPath)=>!currentPathSet.has(testPath));
3985
- };
3986
- const notifyTestRunStart = async ()=>{
3987
- if (!isWatchMode) return;
3988
- for (const reporter of context.reporters)await reporter.onTestRunStart?.();
3989
- };
3990
- const coverageConfig = browserProjects.find((project)=>project.normalizedConfig.coverage?.enabled)?.normalizedConfig.coverage;
3991
- const coverageProvider = coverageConfig?.enabled ? await createCoverageProvider(coverageConfig, context.rootPath) : null;
3992
- const notifyTestRunEnd = async ({ duration, unhandledErrors, filterRerunTestPaths })=>{
3993
- if (!isWatchMode) return;
3994
- let mergedCoverage;
3995
- if (coverageProvider) {
3996
- const coverageMap = coverageProvider.createCoverageMap();
3997
- let hasCoverage = false;
3998
- for (const result of context.reporterResults.results)if (result.coverage) {
3999
- coverageMap.merge(result.coverage);
4000
- hasCoverage = true;
4001
- }
4002
- if (hasCoverage) mergedCoverage = coverageMap.toJSON();
3354
+ _promiseInit = void 0;
4003
3355
  }
4004
- for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
4005
- results: context.reporterResults.results,
4006
- coverage: mergedCoverage,
4007
- testResults: context.reporterResults.testResults,
4008
- duration,
4009
- snapshotSummary: context.snapshotManager.summary,
4010
- getSourcemap: getBrowserSourcemap,
4011
- unhandledErrors,
4012
- filterRerunTestPaths
4013
- });
4014
- };
4015
- const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
4016
- let containerDevServer;
4017
- let containerDistPath;
4018
- if (!useHeadlessDirect) {
4019
- if (containerDevServerEnv) try {
4020
- containerDevServer = new URL(containerDevServerEnv).toString();
4021
- logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
4022
- } catch (error) {
4023
- const originalError = toError(error);
4024
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
4025
- return failWithError(originalError);
4026
- }
4027
- if (!containerDevServer) try {
4028
- containerDistPath = resolveContainerDist();
4029
- } catch (error) {
4030
- return failWithError(error);
4031
- }
4032
- }
4033
- let projectEntries = await resolveProjectEntries(context, options?.shardedEntries, browserProjects);
4034
- let totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
4035
- const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
4036
- const shouldInitializeEmptyBrowserHooks = 0 === totalTests && hasUserRstestConfigPlugins(browserProjects);
4037
- const createEmptyRunResult = ()=>{
4038
- const elapsed = Math.max(0, Date.now() - buildStart);
4039
- return {
4040
- results: [],
4041
- testResults: [],
4042
- duration: {
4043
- totalTime: elapsed,
4044
- buildTime: elapsed,
4045
- testTime: 0
4046
- },
4047
- hasFailure: false,
4048
- getSourcemap: getBrowserSourcemap,
4049
- resolveSourcemap: resolveBrowserSourcemap
4050
- };
4051
- };
4052
- const reportEmptyTestSet = ()=>{
4053
- const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
4054
- if (isWatchMode || !allowEmptyRun) {
4055
- const message = shouldKeepWatchingWithEmptySet ? 'No test files found.' : getNoTestFilesMessage({
4056
- context,
4057
- code,
4058
- defaultMessage: `No test files found, exiting with code ${code}.`
3356
+ let { promise, resolve, reject } = createPromiseWithResolvers();
3357
+ const id = nanoid();
3358
+ req.i = id;
3359
+ let timeoutId;
3360
+ async function handler(newReq = req) {
3361
+ if (timeout >= 0) {
3362
+ timeoutId = dist_setTimeout(()=>{
3363
+ try {
3364
+ if (options.onTimeoutError?.call(rpc, method, args) !== true) throw new Error(`[birpc] timeout on calling "${method}"`);
3365
+ } catch (e) {
3366
+ reject(e);
3367
+ }
3368
+ _rpcPromiseMap.delete(id);
3369
+ }, timeout);
3370
+ if ("object" == typeof timeoutId) timeoutId = timeoutId.unref?.();
3371
+ }
3372
+ _rpcPromiseMap.set(id, {
3373
+ resolve,
3374
+ reject,
3375
+ timeoutId,
3376
+ method
4059
3377
  });
4060
- if (0 === code) logger.log(color.yellow(message));
4061
- else logger.error(color.red(message));
4062
- if (context.relatedFilters?.length) logger.log(color.gray('related: '), context.relatedFilters.join(color.gray(', ')));
4063
- else if (context.fileFilters?.length) logger.log(color.gray('filter: '), context.fileFilters.join(color.gray(', ')));
3378
+ await send(newReq);
3379
+ return promise;
4064
3380
  }
4065
- if (isWatchMode && 0 !== code && !shouldKeepWatchingWithEmptySet && !allowEmptyRun) ensureProcessExitCode(code);
4066
- return !shouldKeepWatchingWithEmptySet;
4067
- };
4068
- if (0 === totalTests && !shouldInitializeEmptyBrowserHooks) {
4069
- if (reportEmptyTestSet()) return allowEmptyRun ? createEmptyRunResult() : void 0;
4070
- }
4071
- if (!filesOnly) await notifyTestRunStart();
4072
- const enableCliShortcuts = isWatchMode && isTTY('stdin');
4073
- const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
4074
- const tempDir = isWatchMode && watchContext.runtime ? watchContext.runtime.tempDir : isWatchMode ? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch') : join(context.rootPath, browserTempOutputRoot, 'browser', Date.now().toString());
4075
- let runtime = isWatchMode ? watchContext.runtime : null;
4076
- let triggerRerun;
4077
- let awaitHeadlessRerunIdle;
4078
- if (!runtime) {
4079
3381
  try {
4080
- runtime = await createBrowserRuntime({
4081
- context,
4082
- projectEntries,
4083
- browserProjects,
4084
- shardedEntries: options?.shardedEntries,
4085
- freezeShardedEntries: options?.freezeShardedEntries,
4086
- tempDir,
4087
- isWatchMode,
4088
- onTriggerRerun: isWatchMode ? async ()=>{
4089
- await triggerRerun?.();
4090
- } : void 0,
4091
- containerDistPath,
4092
- containerDevServer,
4093
- skipProviderLaunch: filesOnly,
4094
- appliedModifyRstestConfigEnvironments: options?.appliedModifyRstestConfigEnvironments
4095
- });
4096
- } catch (error) {
4097
- return failWithError(error, async ()=>{
4098
- await promises.rm(tempDir, {
4099
- recursive: true,
4100
- force: true
4101
- }).catch(()=>{});
4102
- });
4103
- }
4104
- if (isWatchMode) {
4105
- watchContext.runtime = runtime;
4106
- registerWatchCleanup(context.embedded);
3382
+ if (options.onRequest) await options.onRequest.call(rpc, req, handler, resolve);
3383
+ else await handler();
3384
+ } catch (e) {
3385
+ if (options.onGeneralError?.call(rpc, e) !== true) throw e;
3386
+ return;
3387
+ } finally{
3388
+ dist_clearTimeout(timeoutId);
3389
+ _rpcPromiseMap.delete(id);
4107
3390
  }
3391
+ return promise;
4108
3392
  }
4109
- const watchState = runtime.watchState;
4110
- if (isWatchMode) watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
4111
- const seedPendingRerun = (testPaths)=>{
4112
- const wanted = testPaths ? new Set(testPaths.map((testPath)=>normalize(testPath))) : null;
4113
- let seeded = 0;
4114
- for (const file of watchState.lastTestFiles){
4115
- if (wanted && !wanted.has(file.testPath)) continue;
4116
- const pending = watchState.pendingAffectedTestFiles.get(file.projectName) ?? new Set();
4117
- pending.add(file.testPath);
4118
- watchState.pendingAffectedTestFiles.set(file.projectName, pending);
4119
- seeded += 1;
4120
- }
4121
- return seeded;
4122
- };
4123
- const watchHandles = isWatchMode ? {
4124
- rerun: async (testPaths)=>{
4125
- const seeded = seedPendingRerun(testPaths);
4126
- if (testPaths && 0 === seeded) return;
4127
- await triggerRerun?.();
4128
- await awaitHeadlessRerunIdle?.();
3393
+ const builtinMethods = {
3394
+ $call: (method, ...args)=>_call(method, args, false),
3395
+ $callOptional: (method, ...args)=>_call(method, args, false, true),
3396
+ $callEvent: (method, ...args)=>_call(method, args, true),
3397
+ $callRaw: (options$1)=>_call(options$1.method, options$1.args, options$1.event, options$1.optional),
3398
+ $rejectPendingCalls,
3399
+ get $closed () {
3400
+ return $closed;
4129
3401
  },
4130
- close: cleanupWatchRuntime
4131
- } : void 0;
4132
- const finalizeWatchRerun = async ({ rerunTestPaths, testTime, unhandledErrors })=>{
4133
- const rerunPathSet = new Set(rerunTestPaths);
4134
- let sessionCoverage;
4135
- const coverageMap = buildBrowserCoverageMap(context.reporterResults.results, coverageProvider, {
4136
- keepResultCoverage: true
4137
- });
4138
- if (coverageMap && coverageMap.files().length > 0) sessionCoverage = coverageMap.toJSON();
4139
- const outcome = {
4140
- results: context.reporterResults.results.filter((result)=>rerunPathSet.has(result.testPath)),
4141
- testResults: context.reporterResults.testResults.filter((result)=>rerunPathSet.has(result.testPath)),
4142
- errors: unhandledErrors ?? [],
4143
- testPaths: rerunTestPaths,
4144
- duration: {
4145
- buildTime: drainPendingBuildTime(watchState),
4146
- testTime
4147
- },
4148
- coverage: sessionCoverage ? {
4149
- map: sessionCoverage
4150
- } : void 0,
4151
- resolveSourcemap: resolveBrowserSourcemap
4152
- };
4153
- await finalizeRunCycle(context, {
4154
- outcomes: [
4155
- outcome
4156
- ],
4157
- mode: 'on-demand',
4158
- isWatchMode: true,
4159
- coverageProvider,
4160
- reportOnFailure: coverageConfig?.reportOnFailure ?? false
4161
- });
4162
- };
4163
- projectEntries = runtime.projectEntries;
4164
- totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
4165
- const buildTime = Date.now() - buildStart;
4166
- if (filesOnly) return {
4167
- results: [],
4168
- testResults: [],
4169
- duration: {
4170
- totalTime: buildTime,
4171
- buildTime,
4172
- testTime: 0
3402
+ get $meta () {
3403
+ return options.meta;
4173
3404
  },
4174
- hasFailure: false,
4175
- getSourcemap: getBrowserSourcemap,
4176
- resolveSourcemap: resolveBrowserSourcemap,
4177
- close: ()=>destroyBrowserRuntime(runtime)
3405
+ $close,
3406
+ $functions
4178
3407
  };
4179
- if (0 === totalTests && reportEmptyTestSet()) {
4180
- await destroyBrowserRuntime(runtime);
4181
- return allowEmptyRun ? createEmptyRunResult() : void 0;
3408
+ rpc = proxify ? new Proxy({}, {
3409
+ get (_, method) {
3410
+ if (Object.prototype.hasOwnProperty.call(builtinMethods, method)) return builtinMethods[method];
3411
+ if ("then" === method && !eventNames.includes("then") && !("then" in $functions)) return;
3412
+ const sendEvent = (...args)=>_call(method, args, true);
3413
+ if (eventNames.includes(method)) {
3414
+ sendEvent.asEvent = sendEvent;
3415
+ return sendEvent;
3416
+ }
3417
+ const sendCall = (...args)=>_call(method, args, false);
3418
+ sendCall.asEvent = sendEvent;
3419
+ return sendCall;
3420
+ }
3421
+ }) : builtinMethods;
3422
+ function $close(customError) {
3423
+ $closed = true;
3424
+ _rpcPromiseMap.forEach(({ reject, method })=>{
3425
+ const error = /* @__PURE__ */ new Error(`[birpc] rpc is closed, cannot call "${method}"`);
3426
+ if (customError) {
3427
+ customError.cause ??= error;
3428
+ return reject(customError);
3429
+ }
3430
+ reject(error);
3431
+ });
3432
+ _rpcPromiseMap.clear();
3433
+ off(onMessage);
4182
3434
  }
4183
- const { browser, browserLaunchOptions, wsPort, wss } = runtime;
4184
- const allTestFiles = projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
4185
- testPath: normalize(testPath),
4186
- projectName: entry.project.name
4187
- })));
4188
- const projectRuntimeConfigs = browserProjects.map((project)=>({
4189
- name: project.name,
4190
- environmentName: project.environmentName,
4191
- projectRoot: normalize(project.rootPath),
4192
- runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
4193
- envMode: 'static',
4194
- envOverlay: env
4195
- })),
4196
- viewport: project.normalizedConfig.browser.viewport
4197
- }));
4198
- const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
4199
- const projectRunnerUrls = Object.fromEntries([
4200
- ...runtime.projectServers
4201
- ].map(([name, server])=>[
4202
- name,
4203
- `http://localhost:${server.port}`
4204
- ]));
4205
- const hostOptions = {
4206
- rootPath: normalize(context.rootPath),
4207
- projects: projectRuntimeConfigs,
4208
- snapshot: {
4209
- updateSnapshot: options?.updateSnapshot ?? context.snapshotManager.options.updateSnapshot
4210
- },
4211
- runnerUrl: `http://localhost:${runtime.containerServer.port}`,
4212
- projectRunnerUrls,
4213
- wsPort,
4214
- debug: isDebug(),
4215
- rpcTimeout: maxTestTimeoutForRpc
4216
- };
4217
- const browserProviderProjects = browserProjects.map((project)=>({
4218
- rootPath: normalize(project.rootPath),
4219
- provider: project.normalizedConfig.browser.provider
4220
- }));
4221
- const implementationByProvider = new Map();
4222
- for (const browserProject of browserProviderProjects)if (!implementationByProvider.has(browserProject.provider)) implementationByProvider.set(browserProject.provider, getBrowserProviderImplementation(browserProject.provider));
4223
- let activeContainerPage = null;
4224
- let getHeadlessRunnerPageBySessionId;
4225
- const dispatchBrowserRpcRequest = async ({ request, target })=>{
4226
- const timeoutFallbackMs = maxTestTimeoutForRpc;
4227
- const provider = resolveProviderForTestPath({
4228
- testPath: request.testPath,
4229
- browserProjects: browserProviderProjects
3435
+ function $rejectPendingCalls(handler) {
3436
+ const handlerResults = Array.from(_rpcPromiseMap.values()).map(({ method, reject })=>{
3437
+ if (!handler) return reject(/* @__PURE__ */ new Error(`[birpc]: rejected pending call "${method}".`));
3438
+ return handler({
3439
+ method,
3440
+ reject
3441
+ });
4230
3442
  });
4231
- const implementation = implementationByProvider.get(provider);
4232
- if (!implementation) throw new Error(`Browser provider implementation not found: ${provider}`);
4233
- const runnerPage = target?.sessionId ? getHeadlessRunnerPageBySessionId?.(target.sessionId) : void 0;
4234
- if (target?.sessionId && !runnerPage) throw new Error(`Runner page session not found for browser dispatch: ${target.sessionId}`);
4235
- if (!runnerPage && !activeContainerPage) throw new Error('Browser container page is not initialized');
3443
+ _rpcPromiseMap.clear();
3444
+ return handlerResults;
3445
+ }
3446
+ async function onMessage(data, ...extra) {
3447
+ let msg;
4236
3448
  try {
4237
- return await implementation.dispatchRpc({
4238
- containerPage: runnerPage ? void 0 : activeContainerPage ?? void 0,
4239
- runnerPage,
4240
- request,
4241
- timeoutFallbackMs
4242
- });
4243
- } catch (error) {
4244
- if (error instanceof Error) throw error.message;
4245
- throw String(error);
3449
+ msg = deserialize(data);
3450
+ } catch (e) {
3451
+ if (options.onGeneralError?.call(rpc, e) !== true) throw e;
3452
+ return;
4246
3453
  }
4247
- };
4248
- runtime.dispatchHandlers.set(DISPATCH_NAMESPACE_BROWSER, async (dispatchRequest)=>{
4249
- const request = validateBrowserRpcRequest(dispatchRequest.args);
4250
- return dispatchBrowserRpcRequest({
4251
- request,
4252
- target: dispatchRequest.target
3454
+ if (msg.t === TYPE_REQUEST) {
3455
+ const { m: method, a: args, o: optional } = msg;
3456
+ let result, error;
3457
+ let fn = await (resolver ? resolver.call(rpc, method, $functions[method]) : $functions[method]);
3458
+ if (optional) fn ||= ()=>void 0;
3459
+ if (fn) try {
3460
+ result = await fn.apply("rpc" === bind ? rpc : $functions, args);
3461
+ } catch (e) {
3462
+ error = e;
3463
+ }
3464
+ else error = /* @__PURE__ */ new Error(`[birpc] function "${method}" not found`);
3465
+ if (msg.i) {
3466
+ if (error && options.onFunctionError) {
3467
+ if (true === options.onFunctionError.call(rpc, error, method, args)) return;
3468
+ }
3469
+ if (!error) try {
3470
+ await post(serialize({
3471
+ t: TYPE_RESPONSE,
3472
+ i: msg.i,
3473
+ r: result
3474
+ }), ...extra);
3475
+ return;
3476
+ } catch (e) {
3477
+ error = e;
3478
+ if (options.onGeneralError?.call(rpc, e, method, args) !== true) throw e;
3479
+ }
3480
+ try {
3481
+ await post(serialize({
3482
+ t: TYPE_RESPONSE,
3483
+ i: msg.i,
3484
+ e: error
3485
+ }), ...extra);
3486
+ } catch (e) {
3487
+ if (options.onGeneralError?.call(rpc, e, method, args) !== true) throw e;
3488
+ }
3489
+ }
3490
+ } else {
3491
+ const { i: ack, r: result, e: error } = msg;
3492
+ const promise = _rpcPromiseMap.get(ack);
3493
+ if (promise) {
3494
+ dist_clearTimeout(promise.timeoutId);
3495
+ if (error) promise.reject(error);
3496
+ else promise.resolve(result);
3497
+ }
3498
+ _rpcPromiseMap.delete(ack);
3499
+ }
3500
+ }
3501
+ _promiseInit = on(onMessage);
3502
+ return rpc;
3503
+ }
3504
+ class ContainerRpcManager {
3505
+ wss;
3506
+ ws = null;
3507
+ rpc = null;
3508
+ methods;
3509
+ onDisconnect;
3510
+ detachActiveSocketListeners = null;
3511
+ constructor(wss, methods, onDisconnect){
3512
+ this.wss = wss;
3513
+ this.methods = methods;
3514
+ this.onDisconnect = onDisconnect;
3515
+ this.setupConnectionHandler();
3516
+ }
3517
+ updateMethods(methods, onDisconnect) {
3518
+ this.methods = methods;
3519
+ this.onDisconnect = onDisconnect;
3520
+ if (this.ws && this.ws.readyState === this.ws.OPEN) this.attachWebSocket(this.ws);
3521
+ }
3522
+ setupConnectionHandler() {
3523
+ this.wss.on('connection', (ws)=>{
3524
+ logger.debug('[Browser UI] Container WebSocket connected');
3525
+ logger.debug(`[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`);
3526
+ this.attachWebSocket(ws);
3527
+ });
3528
+ }
3529
+ attachWebSocket(ws) {
3530
+ this.detachActiveSocketListeners?.();
3531
+ if (this.rpc && !this.rpc.$closed) this.rpc.$close(new Error('Container RPC transport reattached'));
3532
+ this.ws = ws;
3533
+ const messageHandlers = new WeakMap();
3534
+ this.rpc = createBirpc(this.methods, {
3535
+ timeout: -1,
3536
+ post: (data)=>{
3537
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(data));
3538
+ },
3539
+ on: (fn)=>{
3540
+ const handler = (message)=>{
3541
+ try {
3542
+ const data = JSON.parse(message.toString());
3543
+ fn(data);
3544
+ } catch {}
3545
+ };
3546
+ messageHandlers.set(fn, handler);
3547
+ ws.on('message', handler);
3548
+ },
3549
+ off: (fn)=>{
3550
+ const handler = messageHandlers.get(fn);
3551
+ if (!handler) return;
3552
+ ws.off('message', handler);
3553
+ messageHandlers.delete(fn);
3554
+ }
4253
3555
  });
3556
+ const handleClose = ()=>{
3557
+ if (this.ws === ws) this.ws = null;
3558
+ this.detachActiveSocketListeners?.();
3559
+ this.detachActiveSocketListeners = null;
3560
+ if (this.rpc && !this.rpc.$closed) {
3561
+ const disconnectError = new Error('Browser UI WebSocket disconnected before reload completed');
3562
+ this.rpc.$close(disconnectError);
3563
+ this.onDisconnect?.(disconnectError);
3564
+ }
3565
+ this.rpc = null;
3566
+ };
3567
+ ws.on('close', handleClose);
3568
+ this.detachActiveSocketListeners = ()=>{
3569
+ ws.off('close', handleClose);
3570
+ };
3571
+ }
3572
+ get isConnected() {
3573
+ return null !== this.ws && this.ws.readyState === this.ws.OPEN;
3574
+ }
3575
+ get currentWebSocket() {
3576
+ return this.ws;
3577
+ }
3578
+ reattach(ws) {
3579
+ this.attachWebSocket(ws);
3580
+ }
3581
+ async notifyTestFileUpdate(files) {
3582
+ await this.rpc?.onTestFileUpdate(files);
3583
+ }
3584
+ async updateHostConfig(config) {
3585
+ await this.rpc?.onHostConfigUpdate(config);
3586
+ }
3587
+ async reloadTestFile(testFile, testNamePattern) {
3588
+ logger.debug(`[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`);
3589
+ if (!this.rpc) throw new Error('Browser UI RPC not available for reloadTestFile');
3590
+ logger.debug(`[Browser UI] Calling reloadTestFile: ${testFile}`);
3591
+ return this.rpc.reloadTestFile(testFile, testNamePattern);
3592
+ }
3593
+ }
3594
+ const createHeadedSerialTaskQueue = ()=>{
3595
+ let queue = Promise.resolve();
3596
+ const enqueue = (task)=>{
3597
+ const next = queue.then(task);
3598
+ queue = next.catch(()=>{});
3599
+ return next;
3600
+ };
3601
+ return {
3602
+ enqueue
3603
+ };
3604
+ };
3605
+ const getFileTaskId = (testPath)=>`file:${testPath}`;
3606
+ const toError = (error)=>error instanceof Error ? error : new Error(String(error));
3607
+ const createDeferredPromise = ()=>{
3608
+ let resolve;
3609
+ let reject;
3610
+ const promise = new Promise((res, rej)=>{
3611
+ resolve = res;
3612
+ reject = rej;
4254
3613
  });
4255
- runtime.setContainerOptions(hostOptions);
4256
- const reporterResults = [];
4257
- const caseResults = [];
4258
- let fatalError = null;
4259
- const runnerSinks = new Map(browserProjects.map((project)=>[
4260
- project.name,
4261
- createRunnerEventSink(context, project.normalizedConfig)
3614
+ return {
3615
+ promise,
3616
+ resolve,
3617
+ reject
3618
+ };
3619
+ };
3620
+ const claimHeadedCycleScope = (testPaths, currentTestFiles, pendingTestNamePatterns)=>{
3621
+ const scope = [];
3622
+ const filesByPath = new Map(currentTestFiles.map((file)=>[
3623
+ file.testPath,
3624
+ file
4262
3625
  ]));
4263
- const firstBrowserSink = runnerSinks.get(browserProjects[0].name);
4264
- const projectNameByTestPath = new Map();
4265
- const sinkForProjectName = (projectName)=>runnerSinks.get(projectName) ?? firstBrowserSink;
4266
- const sinkForTestPath = (testPath)=>{
4267
- const projectName = projectNameByTestPath.get(testPath);
4268
- return projectName ? sinkForProjectName(projectName) : firstBrowserSink;
3626
+ for (const testPath of testPaths){
3627
+ const normalizedTestPath = normalize(testPath);
3628
+ const file = filesByPath.get(normalizedTestPath);
3629
+ if (file) {
3630
+ scope.push({
3631
+ file,
3632
+ testNamePattern: pendingTestNamePatterns.get(normalizedTestPath)
3633
+ });
3634
+ pendingTestNamePatterns.delete(normalizedTestPath);
3635
+ }
3636
+ }
3637
+ return scope;
3638
+ };
3639
+ const createHeadedScheduler = async ({ context, runtime, allTestFiles, hostOptions, isWatchMode, createDispatchRouter, handlers: { handleTestFileStart, handleTestCaseResult, handleTestFileComplete, handleLog, handleFatal }, fatalErrorRef, watchSignals, setDispatchPageResolver, createWatchSession, collectProjectEntries, logWatchReady, destroyRuntime })=>{
3640
+ const { browser, browserLaunchOptions, watchState, wss } = runtime;
3641
+ let currentTestFiles = allTestFiles;
3642
+ const RUNNER_FRAMES_READY_TIMEOUT_MS = 30000;
3643
+ let currentRunnerFramesSignature = null;
3644
+ const runnerFramesWaiters = new Map();
3645
+ const createTestFilesSignature = (testFiles)=>JSON.stringify(testFiles.map((testFile)=>normalize(testFile)));
3646
+ const markRunnerFramesReady = (testFiles)=>{
3647
+ const signature = createTestFilesSignature(testFiles);
3648
+ currentRunnerFramesSignature = signature;
3649
+ const waiters = runnerFramesWaiters.get(signature);
3650
+ if (!waiters) return;
3651
+ runnerFramesWaiters.delete(signature);
3652
+ for (const waiter of waiters)waiter();
4269
3653
  };
4270
- const silentConsoleController = createSilentConsoleController({
4271
- runtimeConfig: {
4272
- silent: context.normalizedConfig.silent,
4273
- disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept
4274
- },
4275
- emitInterceptedLog: (log)=>sinkForTestPath(log.testPath).onConsoleLog(log),
4276
- writeOriginalLog: ()=>{}
4277
- });
4278
- const snapshotRpcMethods = {
4279
- async resolveSnapshotPath (testPath) {
4280
- return resolveSnapshotPathDefault(testPath, context.normalizedConfig.resolveSnapshotPath);
4281
- },
4282
- async readSnapshotFile (filepath) {
4283
- try {
4284
- return await promises.readFile(filepath, 'utf-8');
4285
- } catch {
4286
- return null;
3654
+ const waitForRunnerFramesReady = async (testFiles)=>{
3655
+ const signature = createTestFilesSignature(testFiles);
3656
+ if (currentRunnerFramesSignature === signature) return;
3657
+ await new Promise((resolve, reject)=>{
3658
+ const waiters = runnerFramesWaiters.get(signature) ?? new Set();
3659
+ const cleanup = ()=>{
3660
+ const currentWaiters = runnerFramesWaiters.get(signature);
3661
+ if (!currentWaiters) return;
3662
+ currentWaiters.delete(onReady);
3663
+ if (0 === currentWaiters.size) runnerFramesWaiters.delete(signature);
3664
+ };
3665
+ const onReady = ()=>{
3666
+ if (timeoutId) clearTimeout(timeoutId);
3667
+ cleanup();
3668
+ resolve();
3669
+ };
3670
+ const timeoutId = setTimeout(()=>{
3671
+ cleanup();
3672
+ reject(new Error(`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`));
3673
+ }, RUNNER_FRAMES_READY_TIMEOUT_MS);
3674
+ waiters.add(onReady);
3675
+ runnerFramesWaiters.set(signature, waiters);
3676
+ if (currentRunnerFramesSignature === signature) onReady();
3677
+ });
3678
+ };
3679
+ const getTestFileInfo = (testFile)=>{
3680
+ const normalizedTestFile = normalize(testFile);
3681
+ const fileInfo = currentTestFiles.find((file)=>file.testPath === normalizedTestFile);
3682
+ if (!fileInfo) throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
3683
+ return fileInfo;
3684
+ };
3685
+ let containerContext;
3686
+ let containerPage;
3687
+ let isNewPage = false;
3688
+ if (isWatchMode && runtime.containerPage && runtime.containerContext) {
3689
+ containerContext = runtime.containerContext;
3690
+ containerPage = runtime.containerPage;
3691
+ logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
3692
+ } else {
3693
+ isNewPage = true;
3694
+ containerContext = await browser.newContext({
3695
+ providerOptions: browserLaunchOptions.providerOptions,
3696
+ viewport: null
3697
+ });
3698
+ containerPage = await containerContext.newPage();
3699
+ containerPage.on('popup', async (popup)=>{
3700
+ await popup.close().catch(()=>{});
3701
+ });
3702
+ containerContext.on('page', async (page)=>{
3703
+ if (page !== containerPage) await page.close().catch(()=>{});
3704
+ });
3705
+ if (isWatchMode) {
3706
+ runtime.containerPage = containerPage;
3707
+ runtime.containerContext = containerContext;
3708
+ }
3709
+ containerPage.on('console', (msg)=>{
3710
+ const text = msg.text();
3711
+ if (text.startsWith('[Container]') || text.startsWith('[Runner]')) logger.log(color.gray(`[Browser Console] ${text}`));
3712
+ });
3713
+ }
3714
+ setDispatchPageResolver(()=>({
3715
+ containerPage
3716
+ }));
3717
+ const dispatchRouter = createDispatchRouter();
3718
+ const headedReloadQueue = createHeadedSerialTaskQueue();
3719
+ const pendingHeadedReloads = new Map();
3720
+ let enqueueHeadedReload = async (_file, _testNamePattern)=>{
3721
+ throw new Error('Headed reload queue is not initialized');
3722
+ };
3723
+ const rejectPendingHeadedReload = (testPath, error, runId)=>{
3724
+ const pending = pendingHeadedReloads.get(testPath);
3725
+ if (!pending) return;
3726
+ if (runId && pending.runId !== runId) return;
3727
+ pendingHeadedReloads.delete(testPath);
3728
+ pending.deferred.reject(error);
3729
+ };
3730
+ const rejectAllPendingHeadedReloads = (error)=>{
3731
+ for (const [testPath, pending] of pendingHeadedReloads){
3732
+ pendingHeadedReloads.delete(testPath);
3733
+ pending.deferred.reject(error);
3734
+ }
3735
+ };
3736
+ const registerPendingHeadedReload = (testPath, runId)=>{
3737
+ const previousPending = pendingHeadedReloads.get(testPath);
3738
+ if (previousPending) {
3739
+ previousPending.deferred.reject(new Error(`Reload for "${testPath}" was superseded by a newer request.`));
3740
+ pendingHeadedReloads.delete(testPath);
3741
+ }
3742
+ const deferred = createDeferredPromise();
3743
+ pendingHeadedReloads.set(testPath, {
3744
+ runId,
3745
+ deferred
3746
+ });
3747
+ return deferred.promise;
3748
+ };
3749
+ const resolvePendingHeadedReload = (testPath, runId)=>{
3750
+ const pending = pendingHeadedReloads.get(testPath);
3751
+ if (!pending) return;
3752
+ if (runId && pending.runId !== runId) return void logger.debug(`[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`);
3753
+ pendingHeadedReloads.delete(testPath);
3754
+ pending.deferred.resolve();
3755
+ };
3756
+ const reloadTestFileAndWait = async (file, testNamePattern)=>{
3757
+ let reloadAck;
3758
+ try {
3759
+ reloadAck = await rpcManager.reloadTestFile(file.testPath, testNamePattern);
3760
+ await registerPendingHeadedReload(file.testPath, reloadAck.runId);
3761
+ } catch (error) {
3762
+ if (reloadAck?.runId) rejectPendingHeadedReload(file.testPath, toError(error), reloadAck.runId);
3763
+ throw error;
3764
+ }
3765
+ };
3766
+ let runUiRequestedRerun = async (file, testNamePattern)=>{
3767
+ await enqueueHeadedReload(file, testNamePattern);
3768
+ };
3769
+ const createRpcMethods = ()=>({
3770
+ async rerunTest (testFile, testNamePattern) {
3771
+ const projectName = context.normalizedConfig.name || 'project';
3772
+ const relativePath = relative(context.rootPath, testFile);
3773
+ const displayPath = `<${projectName}>/${relativePath}`;
3774
+ logger.log(color.cyan(`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`));
3775
+ await runUiRequestedRerun(getTestFileInfo(testFile), testNamePattern);
3776
+ },
3777
+ async getTestFiles () {
3778
+ return currentTestFiles;
3779
+ },
3780
+ async onRunnerFramesReady (testFiles) {
3781
+ markRunnerFramesReady(testFiles);
3782
+ },
3783
+ async onTestFileStart (payload) {
3784
+ await handleTestFileStart(payload);
3785
+ },
3786
+ async onTestCaseResult (payload) {
3787
+ await handleTestCaseResult(payload);
3788
+ },
3789
+ async onTestFileComplete (payload) {
3790
+ try {
3791
+ await handleTestFileComplete(payload);
3792
+ resolvePendingHeadedReload(payload.testPath, payload.runId);
3793
+ } catch (error) {
3794
+ rejectPendingHeadedReload(payload.testPath, toError(error), payload.runId);
3795
+ throw error;
3796
+ }
3797
+ },
3798
+ async onLog (payload) {
3799
+ await handleLog(payload);
3800
+ },
3801
+ async onFatal (payload) {
3802
+ const error = new Error(payload.message);
3803
+ error.stack = payload.stack;
3804
+ rejectAllPendingHeadedReloads(error);
3805
+ await handleFatal(payload);
3806
+ },
3807
+ async dispatch (request) {
3808
+ return dispatchRouter.dispatch(request);
3809
+ }
3810
+ });
3811
+ let rpcManager;
3812
+ if (isWatchMode && runtime.rpcManager) {
3813
+ rpcManager = runtime.rpcManager;
3814
+ rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
3815
+ const existingWs = rpcManager.currentWebSocket;
3816
+ if (existingWs) rpcManager.reattach(existingWs);
3817
+ } else {
3818
+ rpcManager = new ContainerRpcManager(wss, createRpcMethods(), rejectAllPendingHeadedReloads);
3819
+ if (isWatchMode) runtime.rpcManager = rpcManager;
3820
+ }
3821
+ if (isNewPage) {
3822
+ const pagePath = '/';
3823
+ const containerPort = runtime.containerServer.port;
3824
+ await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
3825
+ waitUntil: 'load'
3826
+ });
3827
+ logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`));
3828
+ }
3829
+ enqueueHeadedReload = async (file, testNamePattern)=>headedReloadQueue.enqueue(async ()=>{
3830
+ if (fatalErrorRef.current) return;
3831
+ await reloadTestFileAndWait(file, testNamePattern);
3832
+ });
3833
+ let testTime = 0;
3834
+ if (currentTestFiles.length > 0) {
3835
+ const testStart = Date.now();
3836
+ try {
3837
+ await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
3838
+ for (const file of currentTestFiles){
3839
+ await enqueueHeadedReload(file);
3840
+ if (fatalErrorRef.current) break;
3841
+ }
3842
+ } catch (error) {
3843
+ fatalErrorRef.current = fatalErrorRef.current ?? toError(error);
3844
+ }
3845
+ testTime = Date.now() - testStart;
3846
+ }
3847
+ let watchSession;
3848
+ if (isWatchMode) {
3849
+ const pendingTestNamePatterns = new Map();
3850
+ const runScope = async (testPaths)=>{
3851
+ const cycleScope = claimHeadedCycleScope(testPaths, currentTestFiles, pendingTestNamePatterns);
3852
+ for (const { file, testNamePattern } of cycleScope)await enqueueHeadedReload(file, testNamePattern);
3853
+ };
3854
+ const refreshHostConfig = async ()=>{
3855
+ const refreshedHostOptions = {
3856
+ ...hostOptions,
3857
+ snapshot: {
3858
+ updateSnapshot: context.snapshotManager.options.updateSnapshot
3859
+ }
3860
+ };
3861
+ runtime.setContainerOptions(refreshedHostOptions);
3862
+ await rpcManager.updateHostConfig(refreshedHostOptions);
3863
+ };
3864
+ watchSignals.setDispatchRerun(async ()=>{
3865
+ const [, newProjectEntries] = await Promise.all([
3866
+ refreshHostConfig(),
3867
+ collectProjectEntries()
3868
+ ]);
3869
+ const rerunPlan = planWatchRerun({
3870
+ projectEntries: newProjectEntries,
3871
+ previousTestFiles: watchState.lastTestFiles,
3872
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState)
3873
+ });
3874
+ if (rerunPlan.filesChanged) {
3875
+ const deletedTestPaths = collectDeletedTestPaths(watchState.lastTestFiles, rerunPlan.currentTestFiles);
3876
+ if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
3877
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3878
+ currentTestFiles = rerunPlan.currentTestFiles;
3879
+ await rpcManager.notifyTestFileUpdate(currentTestFiles);
3880
+ if (0 === currentTestFiles.length) {
3881
+ logger.log(color.cyan('No browser test files remain after update.\n'));
3882
+ logWatchReady();
3883
+ return;
3884
+ }
3885
+ await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
3886
+ }
3887
+ if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
3888
+ logger.log(color.cyan(`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`));
3889
+ await watchSignals.signalInvalidation(rerunPlan.normalizedAffectedTestFiles);
3890
+ return;
3891
+ }
3892
+ if (!rerunPlan.filesChanged) logger.log(color.cyan('Tests will be re-executed automatically\n'));
3893
+ logWatchReady();
3894
+ });
3895
+ runUiRequestedRerun = async (file, testNamePattern)=>{
3896
+ await refreshHostConfig();
3897
+ await watchSignals.signalInvalidation([
3898
+ file.testPath
3899
+ ], ()=>{
3900
+ if (void 0 === testNamePattern) pendingTestNamePatterns.delete(normalize(file.testPath));
3901
+ else pendingTestNamePatterns.set(normalize(file.testPath), testNamePattern);
3902
+ });
3903
+ await watchSignals.awaitSignalledCycle();
3904
+ };
3905
+ watchSession = createWatchSession(runScope);
3906
+ }
3907
+ const closeContainerRuntime = isWatchMode ? void 0 : async ()=>{
3908
+ try {
3909
+ await containerPage.close();
3910
+ } catch {}
3911
+ try {
3912
+ await containerContext.close();
3913
+ } catch {}
3914
+ await destroyRuntime();
3915
+ };
3916
+ return {
3917
+ testTime,
3918
+ watchSession,
3919
+ close: closeContainerRuntime
3920
+ };
3921
+ };
3922
+ const DEFAULT_MAX_HEADLESS_WORKERS = 12;
3923
+ const resolveHeadlessWorkerCount = ({ command, maxWorkers, totalTasks, numCpus = browser_getNumCpus() })=>{
3924
+ const base = Math.max(Math.min(DEFAULT_MAX_HEADLESS_WORKERS, numCpus - 1), 1);
3925
+ return resolveWorkerCount({
3926
+ command,
3927
+ maxWorkers,
3928
+ totalTasks,
3929
+ recommended: base,
3930
+ watchRecommended: Math.max(Math.floor(base / 2), 1),
3931
+ numCpus
3932
+ });
3933
+ };
3934
+ const getHeadlessConcurrency = (context, totalTests)=>resolveHeadlessWorkerCount({
3935
+ command: context.command,
3936
+ maxWorkers: context.normalizedConfig.pool.maxWorkers,
3937
+ totalTasks: totalTests
3938
+ });
3939
+ const attachHeadlessRunnerTransport = async (page, handlers)=>{
3940
+ await page.exposeFunction(DISPATCH_MESSAGE_TYPE, handlers.onDispatchMessage);
3941
+ await page.exposeFunction(DISPATCH_RPC_BRIDGE_NAME, handlers.onDispatchRpc);
3942
+ };
3943
+ const createCancelSignal = ()=>{
3944
+ let settled = false;
3945
+ let resolveSignal = ()=>{};
3946
+ const signal = new Promise((resolve)=>{
3947
+ resolveSignal = ()=>{
3948
+ if (!settled) {
3949
+ settled = true;
3950
+ resolve();
3951
+ }
3952
+ };
3953
+ });
3954
+ return {
3955
+ signal,
3956
+ resolve: resolveSignal
3957
+ };
3958
+ };
3959
+ const createRunSession = (token)=>{
3960
+ const { signal, resolve } = createCancelSignal();
3961
+ return {
3962
+ token,
3963
+ cancelled: false,
3964
+ cancelSignal: signal,
3965
+ signalCancel: resolve
3966
+ };
3967
+ };
3968
+ class RunSessionLifecycle {
3969
+ currentToken = 0;
3970
+ active = null;
3971
+ get activeSession() {
3972
+ return this.active;
3973
+ }
3974
+ get activeToken() {
3975
+ return this.currentToken;
3976
+ }
3977
+ createSession(factory) {
3978
+ const session = factory(++this.currentToken);
3979
+ this.active = session;
3980
+ return session;
3981
+ }
3982
+ isTokenActive(token) {
3983
+ return token === this.currentToken;
3984
+ }
3985
+ isTokenStale(token) {
3986
+ return !this.isTokenActive(token);
3987
+ }
3988
+ invalidateActiveToken() {
3989
+ this.currentToken += 1;
3990
+ return this.currentToken;
3991
+ }
3992
+ clearIfActive(session) {
3993
+ if (this.active === session) this.active = null;
3994
+ }
3995
+ async cancel(session, options) {
3996
+ const waitForDone = options?.waitForDone ?? true;
3997
+ if (!session.cancelled) {
3998
+ session.cancelled = true;
3999
+ session.signalCancel();
4000
+ await options?.onCancel?.(session);
4001
+ }
4002
+ if (waitForDone) await session.done?.catch(()=>{});
4003
+ }
4004
+ async cancelActive(options) {
4005
+ if (!this.active) return;
4006
+ await this.cancel(this.active, options);
4007
+ }
4008
+ }
4009
+ class RunnerSessionRegistry {
4010
+ nextId = 0;
4011
+ sessionsById = new Map();
4012
+ sessionIdByTestFile = new Map();
4013
+ register(input) {
4014
+ const id = input.id ?? `runner-session-${++this.nextId}`;
4015
+ const createdAt = input.createdAt ?? Date.now();
4016
+ const record = {
4017
+ ...input,
4018
+ id,
4019
+ createdAt
4020
+ };
4021
+ this.sessionsById.set(id, record);
4022
+ this.sessionIdByTestFile.set(record.testFile, id);
4023
+ return record;
4024
+ }
4025
+ getById(id) {
4026
+ return this.sessionsById.get(id);
4027
+ }
4028
+ getByTestFile(testFile) {
4029
+ const id = this.sessionIdByTestFile.get(testFile);
4030
+ if (!id) return;
4031
+ return this.sessionsById.get(id);
4032
+ }
4033
+ list() {
4034
+ return Array.from(this.sessionsById.values());
4035
+ }
4036
+ listByRunToken(runToken) {
4037
+ return this.list().filter((session)=>session.runToken === runToken);
4038
+ }
4039
+ deleteById(id) {
4040
+ const record = this.sessionsById.get(id);
4041
+ if (!record) return false;
4042
+ this.sessionsById.delete(id);
4043
+ if (this.sessionIdByTestFile.get(record.testFile) === id) this.sessionIdByTestFile.delete(record.testFile);
4044
+ return true;
4045
+ }
4046
+ deleteByTestFile(testFile) {
4047
+ const id = this.sessionIdByTestFile.get(testFile);
4048
+ if (!id) return false;
4049
+ return this.deleteById(id);
4050
+ }
4051
+ clear() {
4052
+ this.sessionsById.clear();
4053
+ this.sessionIdByTestFile.clear();
4054
+ }
4055
+ }
4056
+ const createHeadlessScheduler = async ({ context, browser, browserLaunchOptions, projectServers, allTestFiles, projectRuntimeConfigs, hostOptions, watchState, isWatchMode, createDispatchRouter, handlers: { handleFatal, handleTestFileComplete }, watchSignals, setDispatchPageResolver, createWatchSession, collectProjectEntries, logWatchReady, destroyRuntime })=>{
4057
+ const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
4058
+ const runLifecycle = new RunSessionLifecycle();
4059
+ const sessionRegistry = new RunnerSessionRegistry();
4060
+ setDispatchPageResolver((target)=>({
4061
+ runnerPage: target?.sessionId ? sessionRegistry.getById(target.sessionId)?.page : void 0
4062
+ }));
4063
+ let dispatchRequestCounter = 0;
4064
+ const nextDispatchRequestId = (namespace)=>`${namespace}-${++dispatchRequestCounter}`;
4065
+ const closeContextSafely = async (browserContext)=>{
4066
+ try {
4067
+ await browserContext.close();
4068
+ } catch {}
4069
+ };
4070
+ const cancelRun = async (run, waitForDone = true)=>{
4071
+ await runLifecycle.cancel(run, {
4072
+ waitForDone,
4073
+ onCancel: async (session)=>{
4074
+ await Promise.all(Array.from(session.contexts).map((browserContext)=>closeContextSafely(browserContext)));
4287
4075
  }
4288
- },
4289
- async saveSnapshotFile (filepath, content) {
4290
- const dir = dirname(filepath);
4291
- await promises.mkdir(dir, {
4292
- recursive: true
4293
- });
4294
- await promises.writeFile(filepath, content, 'utf-8');
4295
- },
4296
- async removeSnapshotFile (filepath) {
4297
- try {
4298
- await promises.unlink(filepath);
4299
- } catch {}
4076
+ });
4077
+ };
4078
+ const dispatchRouter = createDispatchRouter({
4079
+ isRunTokenStale: (runToken)=>runLifecycle.isTokenStale(runToken),
4080
+ onStale: (request)=>{
4081
+ if ("runner" === request.namespace) logger.debug(`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`);
4300
4082
  }
4083
+ });
4084
+ const dispatchRunnerMessage = async (run, file, sessionId, message)=>{
4085
+ const response = await dispatchRouter.dispatch({
4086
+ requestId: nextDispatchRequestId("runner"),
4087
+ runToken: run.token,
4088
+ namespace: "runner",
4089
+ method: message.type,
4090
+ args: 'payload' in message ? message.payload : void 0,
4091
+ target: {
4092
+ sessionId,
4093
+ testFile: file.testPath,
4094
+ projectName: file.projectName
4095
+ }
4096
+ });
4097
+ if (response.stale) return;
4098
+ if (response.error) throw new Error(response.error);
4301
4099
  };
4302
- const handleTestFileStart = async (payload)=>{
4303
- projectNameByTestPath.set(payload.testPath, payload.projectName);
4304
- if (phaseTrackers) {
4305
- const tracker = new PhaseTracker({
4306
- trace: {
4307
- testPath: payload.testPath,
4308
- project: payload.projectName
4100
+ const runSingleFile = async (run, file)=>{
4101
+ if (run.cancelled || runLifecycle.isTokenStale(run.token)) return;
4102
+ const viewport = viewportByProject.get(file.projectName);
4103
+ const browserContext = await browser.newContext({
4104
+ providerOptions: browserLaunchOptions.providerOptions,
4105
+ viewport: viewport ?? null
4106
+ });
4107
+ run.contexts.add(browserContext);
4108
+ let page = null;
4109
+ let sessionId = null;
4110
+ let settled = false;
4111
+ let resolveDone = null;
4112
+ const markDone = ()=>{
4113
+ if (!settled) {
4114
+ settled = true;
4115
+ resolveDone?.();
4116
+ }
4117
+ };
4118
+ const donePromise = new Promise((resolve)=>{
4119
+ resolveDone = resolve;
4120
+ });
4121
+ const crashDeferred = createDeferredPromise();
4122
+ const onPageDead = (reason)=>{
4123
+ if (settled || run.cancelled || !runLifecycle.isTokenActive(run.token)) return;
4124
+ settled = true;
4125
+ crashDeferred.resolve(reason);
4126
+ };
4127
+ try {
4128
+ page = await browserContext.newPage();
4129
+ page.on('crash', ()=>onPageDead(`Browser page crashed while running ${file.testPath}.`));
4130
+ page.on('close', ()=>onPageDead(`Browser page closed unexpectedly while running ${file.testPath}.`));
4131
+ const session = sessionRegistry.register({
4132
+ testFile: file.testPath,
4133
+ projectName: file.projectName,
4134
+ runToken: run.token,
4135
+ mode: 'headless-page',
4136
+ context: browserContext,
4137
+ page
4138
+ });
4139
+ sessionId = session.id;
4140
+ await attachHeadlessRunnerTransport(page, {
4141
+ onDispatchMessage: async (message)=>{
4142
+ try {
4143
+ await dispatchRunnerMessage(run, file, session.id, message);
4144
+ if ('file-complete' === message.type || 'complete' === message.type) markDone();
4145
+ else if ('fatal' === message.type) {
4146
+ markDone();
4147
+ await cancelRun(run, false);
4148
+ }
4149
+ } catch (error) {
4150
+ const formatted = toError(error);
4151
+ await handleFatal({
4152
+ message: formatted.message,
4153
+ stack: formatted.stack
4154
+ });
4155
+ markDone();
4156
+ await cancelRun(run, false);
4157
+ }
4309
4158
  },
4310
- pid: nextBrowserFilePid++
4159
+ onDispatchRpc: async (request)=>dispatchRouter.dispatch({
4160
+ ...request,
4161
+ runToken: run.token,
4162
+ target: {
4163
+ sessionId: session.id,
4164
+ testFile: file.testPath,
4165
+ projectName: file.projectName,
4166
+ ...request.target
4167
+ }
4168
+ })
4311
4169
  });
4312
- tracker.transition('prepare');
4313
- phaseTrackers.set(payload.testPath, tracker);
4170
+ const inlineOptions = {
4171
+ ...hostOptions,
4172
+ snapshot: {
4173
+ updateSnapshot: context.snapshotManager.options.updateSnapshot
4174
+ },
4175
+ testFile: file.testPath,
4176
+ runId: `${run.token}:${session.id}`
4177
+ };
4178
+ const serializedOptions = serializeForInlineScript(inlineOptions);
4179
+ await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
4180
+ const projectServer = projectServers.get(file.projectName);
4181
+ if (!projectServer) throw new Error(`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`);
4182
+ await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
4183
+ waitUntil: 'load'
4184
+ });
4185
+ const state = await Promise.race([
4186
+ donePromise.then(()=>({
4187
+ type: 'done'
4188
+ })),
4189
+ crashDeferred.promise.then((reason)=>({
4190
+ type: 'crash',
4191
+ reason
4192
+ })),
4193
+ run.cancelSignal.then(()=>({
4194
+ type: 'cancelled'
4195
+ }))
4196
+ ]);
4197
+ if ('cancelled' === state.type) return;
4198
+ if ('crash' === state.type && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4199
+ await handleFatal({
4200
+ message: state.reason
4201
+ });
4202
+ await cancelRun(run, false);
4203
+ }
4204
+ } catch (error) {
4205
+ if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4206
+ const formatted = toError(error);
4207
+ await handleFatal({
4208
+ message: formatted.message,
4209
+ stack: formatted.stack
4210
+ });
4211
+ await cancelRun(run, false);
4212
+ }
4213
+ } finally{
4214
+ const abandoned = run.cancelled || runLifecycle.isTokenStale(run.token);
4215
+ const teardown = async ()=>{
4216
+ if (page) try {
4217
+ await page.close();
4218
+ } catch {}
4219
+ await closeContextSafely(browserContext);
4220
+ };
4221
+ if (sessionId) sessionRegistry.deleteById(sessionId);
4222
+ run.contexts.delete(browserContext);
4223
+ if (abandoned) teardown();
4224
+ else await teardown();
4314
4225
  }
4315
- await sinkForProjectName(payload.projectName).onTestFileStart({
4316
- testId: getFileTaskId(payload.testPath),
4317
- testPath: payload.testPath,
4318
- tests: []
4319
- });
4320
- };
4321
- const handleTestFileReady = async (payload)=>{
4322
- phaseTrackers?.get(payload.testPath)?.transition('tests');
4323
- await sinkForTestPath(payload.testPath).onTestFileReady(payload);
4324
- };
4325
- const handleTestSuiteStart = async (payload)=>{
4326
- phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
4327
- await sinkForTestPath(payload.testPath).onTestSuiteStart(payload);
4328
4226
  };
4329
- const handleTestSuiteResult = async (payload)=>{
4330
- phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
4331
- await sinkForTestPath(payload.testPath).onTestSuiteResult(payload);
4332
- if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4333
- taskId: payload.testId,
4334
- status: payload.status,
4335
- taskParentNames: payload.parentNames,
4336
- taskType: 'suite',
4337
- testPath: payload.testPath
4227
+ const makeSkippedFileResult = (file)=>({
4228
+ testId: getFileTaskId(file.testPath),
4229
+ status: 'skip',
4230
+ name: '',
4231
+ testPath: file.testPath,
4232
+ project: file.projectName,
4233
+ results: []
4338
4234
  });
4235
+ const runFilesWithPool = async (files)=>{
4236
+ if (0 === files.length) return;
4237
+ const previous = runLifecycle.activeSession;
4238
+ if (previous) await cancelRun(previous);
4239
+ const run = runLifecycle.createSession((token)=>({
4240
+ ...createRunSession(token),
4241
+ contexts: new Set()
4242
+ }));
4243
+ const queue = [
4244
+ ...files
4245
+ ];
4246
+ const concurrency = getHeadlessConcurrency(context, queue.length);
4247
+ const bail = context.normalizedConfig.bail;
4248
+ const worker = async ()=>{
4249
+ while(queue.length > 0 && !run.cancelled && runLifecycle.isTokenActive(run.token)){
4250
+ if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
4251
+ let skipped = queue.shift();
4252
+ while(skipped){
4253
+ await handleTestFileComplete(makeSkippedFileResult(skipped));
4254
+ skipped = queue.shift();
4255
+ }
4256
+ return;
4257
+ }
4258
+ const next = queue.shift();
4259
+ if (!next) return;
4260
+ await runSingleFile(run, next);
4261
+ }
4262
+ };
4263
+ run.done = Promise.all(Array.from({
4264
+ length: Math.min(queue.length, Math.max(concurrency, 1))
4265
+ }, ()=>worker())).then(()=>{});
4266
+ await run.done;
4267
+ runLifecycle.clearIfActive(run);
4339
4268
  };
4340
- const handleTestCaseStart = async (payload)=>{
4341
- phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
4342
- sinkForTestPath(payload.testPath).onTestCaseStart(payload);
4343
- };
4344
- const handleTestCaseResult = async (payload)=>{
4345
- caseResults.push(payload);
4346
- phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
4347
- await sinkForTestPath(payload.testPath).onTestCaseResult(payload);
4348
- if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4349
- taskId: payload.testId,
4350
- status: payload.status,
4351
- taskParentNames: payload.parentNames,
4352
- taskType: 'case',
4353
- testPath: payload.testPath
4269
+ const testStart = Date.now();
4270
+ await runFilesWithPool(allTestFiles);
4271
+ const testTime = Date.now() - testStart;
4272
+ let watchSession;
4273
+ if (isWatchMode) {
4274
+ const runScope = async (testPaths)=>{
4275
+ const pathSet = new Set(testPaths.map((testPath)=>normalize(testPath)));
4276
+ await runFilesWithPool(watchState.lastTestFiles.filter((file)=>pathSet.has(file.testPath)));
4277
+ };
4278
+ watchSignals.setInterrupt(async ()=>{
4279
+ const active = runLifecycle.activeSession;
4280
+ if (!active || active.cancelled) return;
4281
+ runLifecycle.invalidateActiveToken();
4282
+ await runLifecycle.cancel(active, {
4283
+ waitForDone: false
4284
+ });
4354
4285
  });
4355
- };
4356
- const handleTestFileComplete = async (payload)=>{
4357
- reporterResults.push(payload);
4358
- context.updateReporterResultState([
4359
- payload
4360
- ], payload.results);
4361
- if (phaseTrackers) {
4362
- const tracker = phaseTrackers.get(payload.testPath);
4363
- if (tracker) {
4364
- tracker.end();
4365
- const events = tracker.getTraceEvents();
4366
- if (events) onTraceEvents?.(events);
4367
- phaseTrackers.delete(payload.testPath);
4286
+ watchSignals.setDispatchRerun(async ()=>{
4287
+ const newProjectEntries = await collectProjectEntries();
4288
+ const rerunPlan = planWatchRerun({
4289
+ projectEntries: newProjectEntries,
4290
+ previousTestFiles: watchState.lastTestFiles,
4291
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState)
4292
+ });
4293
+ if (rerunPlan.filesChanged) {
4294
+ const deletedTestPaths = collectDeletedTestPaths(watchState.lastTestFiles, rerunPlan.currentTestFiles);
4295
+ if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
4296
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
4297
+ if (0 === rerunPlan.currentTestFiles.length) {
4298
+ logger.log(color.cyan('No browser test files remain after update.\n'));
4299
+ await watchSignals.signalInvalidation([]);
4300
+ return;
4301
+ }
4302
+ logger.log(color.cyan(`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`));
4303
+ await watchSignals.signalInvalidation([
4304
+ ...new Set(rerunPlan.currentTestFiles.map((file)=>file.testPath))
4305
+ ]);
4306
+ return;
4368
4307
  }
4369
- }
4370
- if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4371
- taskId: payload.testId,
4372
- status: payload.status,
4373
- taskParentNames: payload.parentNames,
4374
- taskType: 'file',
4375
- testPath: payload.testPath
4308
+ if (0 === rerunPlan.affectedTestFiles.length) {
4309
+ logger.log(color.cyan('No affected browser test files detected, skipping re-run.\n'));
4310
+ logWatchReady();
4311
+ return;
4312
+ }
4313
+ logger.log(color.cyan(`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`));
4314
+ await watchSignals.signalInvalidation([
4315
+ ...new Set(rerunPlan.affectedTestFiles.map((file)=>file.testPath))
4316
+ ]);
4376
4317
  });
4377
- await sinkForTestPath(payload.testPath).onTestFileResult(payload);
4378
- if (isWatchMode && 'fail' === payload.status) ensureProcessExitCode(1);
4318
+ watchSession = createWatchSession(runScope);
4319
+ }
4320
+ const closeHeadlessRuntime = isWatchMode ? void 0 : async ()=>{
4321
+ sessionRegistry.clear();
4322
+ await destroyRuntime();
4323
+ };
4324
+ return {
4325
+ testTime,
4326
+ watchSession,
4327
+ close: closeHeadlessRuntime
4328
+ };
4329
+ };
4330
+ const isRecord = (value)=>'object' == typeof value && null !== value;
4331
+ const readString = (value, key, label)=>{
4332
+ const result = value[key];
4333
+ if ('string' != typeof result) throw new Error(`Invalid browser RPC request: ${label} must be a string`);
4334
+ return result;
4335
+ };
4336
+ const readUnknownArray = (value, key, label)=>{
4337
+ const result = value[key];
4338
+ if (!Array.isArray(result)) throw new Error(`Invalid browser RPC request: ${label} must be an array`);
4339
+ return result;
4340
+ };
4341
+ const parseBrowserLocatorIR = (value, label)=>{
4342
+ if (!isRecord(value)) throw new Error(`Invalid browser RPC request: ${label} must be an object`);
4343
+ const steps = value.steps;
4344
+ if (!Array.isArray(steps)) throw new Error(`Invalid browser RPC request: ${label}.steps must be an array`);
4345
+ return value;
4346
+ };
4347
+ const validateBrowserRpcRequest = (payload)=>{
4348
+ if (!isRecord(payload)) throw new Error('Invalid browser RPC request: payload must be an object');
4349
+ const kind = readString(payload, 'kind', 'kind');
4350
+ if ('locator' !== kind && 'expect' !== kind && 'config' !== kind) throw new Error(`Invalid browser RPC request: unsupported kind ${JSON.stringify(kind)}`);
4351
+ const request = {
4352
+ id: readString(payload, 'id', 'id'),
4353
+ testPath: readString(payload, 'testPath', 'testPath'),
4354
+ runId: readString(payload, 'runId', 'runId'),
4355
+ kind,
4356
+ locator: parseBrowserLocatorIR(payload.locator, 'locator'),
4357
+ method: readString(payload, 'method', 'method'),
4358
+ args: readUnknownArray(payload, 'args', 'args')
4359
+ };
4360
+ const isNot = payload.isNot;
4361
+ if (void 0 !== isNot) {
4362
+ if ('boolean' != typeof isNot) throw new Error('Invalid browser RPC request: isNot must be a boolean');
4363
+ request.isNot = isNot;
4364
+ }
4365
+ const timeout = payload.timeout;
4366
+ if (void 0 !== timeout) {
4367
+ if ('number' != typeof timeout) throw new Error('Invalid browser RPC request: timeout must be a number');
4368
+ request.timeout = timeout;
4369
+ }
4370
+ return request;
4371
+ };
4372
+ const normalizeJavaScriptUrl = (value, options)=>{
4373
+ try {
4374
+ const url = options?.origin ? new URL(value, options.origin) : new URL(value);
4375
+ if ('http:' !== url.protocol && 'https:' !== url.protocol) return null;
4376
+ url.search = '';
4377
+ url.hash = '';
4378
+ return url.toString();
4379
+ } catch {
4380
+ return null;
4381
+ }
4382
+ };
4383
+ const resolveInlineSourceMap = (code)=>{
4384
+ const converter = convert_source_map.fromSource(code);
4385
+ if (!converter) return null;
4386
+ return converter.toObject();
4387
+ };
4388
+ const fetchSourceMap = async (jsUrl, fetcher)=>{
4389
+ const jsResponse = await fetcher(jsUrl);
4390
+ if (!jsResponse.ok) return null;
4391
+ const code = await jsResponse.text();
4392
+ const inlineMap = resolveInlineSourceMap(code);
4393
+ if (inlineMap) return inlineMap;
4394
+ const mapResponse = await fetcher(`${jsUrl}.map`);
4395
+ if (!mapResponse.ok) return null;
4396
+ return await mapResponse.json();
4397
+ };
4398
+ const loadSourceMapWithCache = async ({ jsUrl, cache, force = false, origin, fetcher = fetch })=>{
4399
+ const normalizedUrl = normalizeJavaScriptUrl(jsUrl, {
4400
+ origin
4401
+ });
4402
+ if (!normalizedUrl) return null;
4403
+ if (!force && cache.has(normalizedUrl)) return cache.get(normalizedUrl) ?? null;
4404
+ try {
4405
+ const sourceMap = await fetchSourceMap(normalizedUrl, fetcher);
4406
+ cache.set(normalizedUrl, sourceMap);
4407
+ return sourceMap;
4408
+ } catch {
4409
+ cache.set(normalizedUrl, null);
4410
+ return null;
4411
+ }
4412
+ };
4413
+ const watchContext = {
4414
+ runtime: null,
4415
+ cleanupRegistered: false,
4416
+ cleanupPromise: null
4417
+ };
4418
+ const runWatchRuntimeTeardown = (state, destroy)=>{
4419
+ if (state.cleanupPromise) return state.cleanupPromise;
4420
+ state.cleanupPromise = (async ()=>{
4421
+ if (!state.runtime) return;
4422
+ await destroy(state.runtime);
4423
+ state.runtime = null;
4424
+ })();
4425
+ return state.cleanupPromise.finally(()=>{
4426
+ state.cleanupPromise = null;
4427
+ });
4428
+ };
4429
+ const cleanupWatchRuntime = ()=>runWatchRuntimeTeardown(watchContext, destroyBrowserRuntime);
4430
+ const registerWatchCleanup = (embedded)=>{
4431
+ if (watchContext.cleanupRegistered) return;
4432
+ watchContext.cleanupRegistered = true;
4433
+ if (embedded) return;
4434
+ for (const signal of FATAL_SIGNALS)process.once(signal, ()=>{
4435
+ cleanupWatchRuntime();
4436
+ });
4437
+ process.once('exit', ()=>{
4438
+ cleanupWatchRuntime();
4439
+ });
4440
+ };
4441
+ const createWatchSignals = (onInvalidate)=>{
4442
+ let dispatchRerun;
4443
+ let interruptInFlightRun;
4444
+ let signalledCycle;
4445
+ return {
4446
+ setDispatchRerun (fn) {
4447
+ dispatchRerun = fn;
4448
+ },
4449
+ async runDispatchRerun () {
4450
+ await dispatchRerun?.();
4451
+ },
4452
+ setInterrupt (fn) {
4453
+ interruptInFlightRun = fn;
4454
+ },
4455
+ async signalInvalidation (fileFilters, claimScope) {
4456
+ await interruptInFlightRun?.();
4457
+ claimScope?.();
4458
+ signalledCycle = Promise.resolve(onInvalidate?.({
4459
+ isFirstBuild: false,
4460
+ fileFilters
4461
+ })).catch((error)=>{
4462
+ logger.error(color.red('Browser Mode watch cycle failed:'), error);
4463
+ });
4464
+ },
4465
+ async awaitSignalledCycle () {
4466
+ await signalledCycle;
4467
+ }
4379
4468
  };
4380
- const handleLog = async (payload)=>{
4381
- const log = {
4382
- content: payload.content,
4383
- name: getPrettyConsoleName(payload.level),
4384
- taskId: payload.taskId,
4385
- taskName: payload.taskName,
4386
- taskParentNames: payload.taskParentNames,
4387
- taskType: payload.taskType,
4388
- testPath: payload.testPath,
4389
- type: payload.type,
4390
- trace: payload.trace
4469
+ };
4470
+ let nextBrowserFilePid = 1000000000;
4471
+ const resolveEmptyLaunchExitCode = (current, { allowEmptyRun, isWatchMode, passWithNoTests })=>{
4472
+ if (allowEmptyRun || !isWatchMode || passWithNoTests) return current;
4473
+ return void 0 === current || 0 === current ? 1 : current;
4474
+ };
4475
+ const getMaxTestTimeoutForRpc = (projects)=>Math.max(...projects.map((p)=>p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT));
4476
+ const resolveProviderForTestPath = ({ testPath, browserProjects })=>{
4477
+ const normalizedTestPath = normalize(testPath);
4478
+ const sortedProjects = [
4479
+ ...browserProjects
4480
+ ].sort((a, b)=>b.rootPath.length - a.rootPath.length);
4481
+ for (const project of sortedProjects)if (normalizedTestPath.startsWith(project.rootPath)) return project.provider;
4482
+ throw new Error(`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. Known project roots: ${JSON.stringify(sortedProjects.map((p)=>p.rootPath))}`);
4483
+ };
4484
+ const runBrowserController = async (context, options)=>{
4485
+ const { allowEmptyRun = false, filesOnly = false, onTraceEvents, env, onInvalidate } = options ?? {};
4486
+ const buildStart = Date.now();
4487
+ const isWatchMode = 'watch' === context.command;
4488
+ const phaseTrackers = onTraceEvents ? new Map() : void 0;
4489
+ const trackerKey = (project, testPath)=>`${project}\u0000${testPath}`;
4490
+ const browserProjects = options?.projects ?? getBrowserProjects(context);
4491
+ const useHeadlessDirect = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
4492
+ const browserSourceMapCache = new Map();
4493
+ const isHttpLikeFile = (file)=>/^https?:\/\//.test(file);
4494
+ const resolveBrowserSourcemap = async (sourcePath)=>{
4495
+ if (!isHttpLikeFile(sourcePath)) return {
4496
+ handled: false,
4497
+ sourcemap: null
4498
+ };
4499
+ const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
4500
+ if (!normalizedUrl) return {
4501
+ handled: true,
4502
+ sourcemap: null
4503
+ };
4504
+ if (browserSourceMapCache.has(normalizedUrl)) return {
4505
+ handled: true,
4506
+ sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null
4507
+ };
4508
+ return {
4509
+ handled: true,
4510
+ sourcemap: await loadSourceMapWithCache({
4511
+ jsUrl: normalizedUrl,
4512
+ cache: browserSourceMapCache
4513
+ })
4391
4514
  };
4392
- silentConsoleController.onConsoleLog(log);
4393
4515
  };
4394
- const handleFatal = async (payload)=>{
4395
- const error = new Error(payload.message);
4396
- error.stack = payload.stack;
4397
- fatalError = error;
4398
- if (isWatchMode) ensureProcessExitCode(1);
4516
+ const getBrowserSourcemap = async (sourcePath)=>{
4517
+ const result = await resolveBrowserSourcemap(sourcePath);
4518
+ return result.handled ? result.sourcemap : null;
4399
4519
  };
4400
- const runSnapshotRpc = async (request)=>{
4401
- switch(request.method){
4402
- case 'resolveSnapshotPath':
4403
- return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
4404
- case 'readSnapshotFile':
4405
- return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
4406
- case 'saveSnapshotFile':
4407
- return snapshotRpcMethods.saveSnapshotFile(request.args.filepath, request.args.content);
4408
- case 'removeSnapshotFile':
4409
- return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
4410
- default:
4411
- {
4412
- const _exhaustive = request;
4413
- return _exhaustive;
4414
- }
4520
+ const buildErrorResult = (error, close)=>{
4521
+ const elapsed = Math.max(0, Date.now() - buildStart);
4522
+ return {
4523
+ results: [],
4524
+ testResults: [],
4525
+ duration: {
4526
+ totalTime: elapsed,
4527
+ buildTime: elapsed,
4528
+ testTime: 0
4529
+ },
4530
+ hasFailure: true,
4531
+ unhandledErrors: [
4532
+ error
4533
+ ],
4534
+ getSourcemap: getBrowserSourcemap,
4535
+ resolveSourcemap: resolveBrowserSourcemap,
4536
+ close
4537
+ };
4538
+ };
4539
+ const failWithError = async (error, cleanup)=>{
4540
+ const normalizedError = toError(error);
4541
+ if (cleanup && !isWatchMode) return buildErrorResult(normalizedError, cleanup);
4542
+ try {
4543
+ return buildErrorResult(normalizedError);
4544
+ } finally{
4545
+ await cleanup?.();
4415
4546
  }
4416
4547
  };
4417
- const createDispatchRouter = (options)=>createHostDispatchRouter({
4418
- routerOptions: options,
4419
- runnerCallbacks: {
4420
- onTestFileStart: handleTestFileStart,
4421
- onTestFileReady: handleTestFileReady,
4422
- onTestSuiteStart: handleTestSuiteStart,
4423
- onTestSuiteResult: handleTestSuiteResult,
4424
- onTestCaseStart: handleTestCaseStart,
4425
- onTestCaseResult: handleTestCaseResult,
4426
- onTestFileComplete: handleTestFileComplete,
4427
- onLog: handleLog,
4428
- onFatal: handleFatal
4548
+ const coverageConfig = browserProjects.find((project)=>project.normalizedConfig.coverage?.enabled)?.normalizedConfig.coverage;
4549
+ const coverageProvider = coverageConfig?.enabled ? await createCoverageProvider(coverageConfig, context.rootPath) : null;
4550
+ const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
4551
+ let containerDevServer;
4552
+ let containerDistPath;
4553
+ if (!useHeadlessDirect) {
4554
+ if (containerDevServerEnv) try {
4555
+ containerDevServer = new URL(containerDevServerEnv).toString();
4556
+ logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
4557
+ } catch (error) {
4558
+ const originalError = toError(error);
4559
+ originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
4560
+ return failWithError(originalError);
4561
+ }
4562
+ if (!containerDevServer) try {
4563
+ containerDistPath = resolveContainerDist();
4564
+ } catch (error) {
4565
+ return failWithError(error);
4566
+ }
4567
+ }
4568
+ let projectEntries = await resolveProjectEntries(context, options?.shardedEntries, browserProjects);
4569
+ let totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
4570
+ const shouldInitializeEmptyBrowserHooks = 0 === totalTests && hasUserRstestConfigPlugins(browserProjects);
4571
+ const createEmptyRunResult = ()=>{
4572
+ const elapsed = Math.max(0, Date.now() - buildStart);
4573
+ return {
4574
+ results: [],
4575
+ testResults: [],
4576
+ duration: {
4577
+ totalTime: elapsed,
4578
+ buildTime: elapsed,
4579
+ testTime: 0
4429
4580
  },
4430
- runSnapshotRpc,
4431
- extensionHandlers: runtime.dispatchHandlers,
4432
- onDuplicateNamespace: (namespace)=>{
4433
- logger.debug(`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`);
4434
- }
4435
- });
4436
- if (useHeadlessDirect) {
4437
- const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
4438
- const runLifecycle = new RunSessionLifecycle();
4439
- const sessionRegistry = new RunnerSessionRegistry();
4440
- getHeadlessRunnerPageBySessionId = (sessionId)=>sessionRegistry.getById(sessionId)?.page;
4441
- let dispatchRequestCounter = 0;
4442
- const nextDispatchRequestId = (namespace)=>`${namespace}-${++dispatchRequestCounter}`;
4443
- const closeContextSafely = async (browserContext)=>{
4444
- try {
4445
- await browserContext.close();
4446
- } catch {}
4447
- };
4448
- const cancelRun = async (run, waitForDone = true)=>{
4449
- await runLifecycle.cancel(run, {
4450
- waitForDone,
4451
- onCancel: async (session)=>{
4452
- await Promise.all(Array.from(session.contexts).map((browserContext)=>closeContextSafely(browserContext)));
4453
- }
4454
- });
4581
+ hasFailure: false,
4582
+ getSourcemap: getBrowserSourcemap,
4583
+ resolveSourcemap: resolveBrowserSourcemap
4455
4584
  };
4456
- const dispatchRouter = createDispatchRouter({
4457
- isRunTokenStale: (runToken)=>runLifecycle.isTokenStale(runToken),
4458
- onStale: (request)=>{
4459
- if ("runner" === request.namespace) logger.debug(`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`);
4460
- }
4585
+ };
4586
+ const writeEmptyLaunchExitCode = ()=>{
4587
+ process.exitCode = resolveEmptyLaunchExitCode(process.exitCode, {
4588
+ allowEmptyRun,
4589
+ isWatchMode,
4590
+ passWithNoTests: context.normalizedConfig.passWithNoTests
4461
4591
  });
4462
- const dispatchRunnerMessage = async (run, file, sessionId, message)=>{
4463
- const response = await dispatchRouter.dispatch({
4464
- requestId: nextDispatchRequestId("runner"),
4465
- runToken: run.token,
4466
- namespace: "runner",
4467
- method: message.type,
4468
- args: 'payload' in message ? message.payload : void 0,
4469
- target: {
4470
- sessionId,
4471
- testFile: file.testPath,
4472
- projectName: file.projectName
4473
- }
4474
- });
4475
- if (response.stale) return;
4476
- if (response.error) throw new Error(response.error);
4477
- };
4478
- const runSingleFile = async (run, file)=>{
4479
- if (run.cancelled || runLifecycle.isTokenStale(run.token)) return;
4480
- const viewport = viewportByProject.get(file.projectName);
4481
- const browserContext = await browser.newContext({
4482
- providerOptions: browserLaunchOptions.providerOptions,
4483
- viewport: viewport ?? null
4484
- });
4485
- run.contexts.add(browserContext);
4486
- let page = null;
4487
- let sessionId = null;
4488
- let settled = false;
4489
- let resolveDone = null;
4490
- const markDone = ()=>{
4491
- if (!settled) {
4492
- settled = true;
4493
- resolveDone?.();
4494
- }
4495
- };
4496
- const donePromise = new Promise((resolve)=>{
4497
- resolveDone = resolve;
4592
+ };
4593
+ if (0 === totalTests && !shouldInitializeEmptyBrowserHooks) {
4594
+ writeEmptyLaunchExitCode();
4595
+ return allowEmptyRun ? createEmptyRunResult() : void 0;
4596
+ }
4597
+ const enableCliShortcuts = isWatchMode && isTTY('stdin');
4598
+ const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
4599
+ const tempDir = isWatchMode && watchContext.runtime ? watchContext.runtime.tempDir : isWatchMode ? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch') : join(context.rootPath, browserTempOutputRoot, 'browser', Date.now().toString());
4600
+ let runtime = isWatchMode ? watchContext.runtime : null;
4601
+ const watchSignals = createWatchSignals(onInvalidate);
4602
+ if (!runtime) {
4603
+ try {
4604
+ runtime = await createBrowserRuntime({
4605
+ context,
4606
+ projectEntries,
4607
+ browserProjects,
4608
+ shardedEntries: options?.shardedEntries,
4609
+ freezeShardedEntries: options?.freezeShardedEntries,
4610
+ tempDir,
4611
+ isWatchMode,
4612
+ onTriggerRerun: isWatchMode ? async ()=>{
4613
+ await watchSignals.runDispatchRerun();
4614
+ } : void 0,
4615
+ containerDistPath,
4616
+ containerDevServer,
4617
+ skipProviderLaunch: filesOnly,
4618
+ appliedModifyRstestConfigEnvironments: options?.appliedModifyRstestConfigEnvironments
4498
4619
  });
4499
- const crashDeferred = createDeferredPromise();
4500
- const onPageDead = (reason)=>{
4501
- if (settled || run.cancelled || !runLifecycle.isTokenActive(run.token)) return;
4502
- settled = true;
4503
- crashDeferred.resolve(reason);
4504
- };
4505
- try {
4506
- page = await browserContext.newPage();
4507
- page.on('crash', ()=>onPageDead(`Browser page crashed while running ${file.testPath}.`));
4508
- page.on('close', ()=>onPageDead(`Browser page closed unexpectedly while running ${file.testPath}.`));
4509
- const session = sessionRegistry.register({
4510
- testFile: file.testPath,
4511
- projectName: file.projectName,
4512
- runToken: run.token,
4513
- mode: 'headless-page',
4514
- context: browserContext,
4515
- page
4516
- });
4517
- sessionId = session.id;
4518
- await attachHeadlessRunnerTransport(page, {
4519
- onDispatchMessage: async (message)=>{
4520
- try {
4521
- await dispatchRunnerMessage(run, file, session.id, message);
4522
- if ('file-complete' === message.type || 'complete' === message.type) markDone();
4523
- else if ('fatal' === message.type) {
4524
- markDone();
4525
- await cancelRun(run, false);
4526
- }
4527
- } catch (error) {
4528
- const formatted = toError(error);
4529
- await handleFatal({
4530
- message: formatted.message,
4531
- stack: formatted.stack
4532
- });
4533
- markDone();
4534
- await cancelRun(run, false);
4535
- }
4536
- },
4537
- onDispatchRpc: async (request)=>dispatchRouter.dispatch({
4538
- ...request,
4539
- runToken: run.token,
4540
- target: {
4541
- sessionId: session.id,
4542
- testFile: file.testPath,
4543
- projectName: file.projectName,
4544
- ...request.target
4545
- }
4546
- })
4547
- });
4548
- const inlineOptions = {
4549
- ...hostOptions,
4550
- snapshot: {
4551
- updateSnapshot: context.snapshotManager.options.updateSnapshot
4552
- },
4553
- testFile: file.testPath,
4554
- runId: `${run.token}:${session.id}`
4555
- };
4556
- const serializedOptions = serializeForInlineScript(inlineOptions);
4557
- await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
4558
- const projectServer = runtime.projectServers.get(file.projectName);
4559
- if (!projectServer) throw new Error(`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`);
4560
- await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
4561
- waitUntil: 'load'
4562
- });
4563
- const state = await Promise.race([
4564
- donePromise.then(()=>({
4565
- type: 'done'
4566
- })),
4567
- crashDeferred.promise.then((reason)=>({
4568
- type: 'crash',
4569
- reason
4570
- })),
4571
- run.cancelSignal.then(()=>({
4572
- type: 'cancelled'
4573
- }))
4574
- ]);
4575
- if ('cancelled' === state.type) return;
4576
- if ('crash' === state.type && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4577
- await handleFatal({
4578
- message: state.reason
4579
- });
4580
- await cancelRun(run, false);
4581
- }
4582
- } catch (error) {
4583
- if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4584
- const formatted = toError(error);
4585
- await handleFatal({
4586
- message: formatted.message,
4587
- stack: formatted.stack
4588
- });
4589
- await cancelRun(run, false);
4590
- }
4591
- } finally{
4592
- if (page) try {
4593
- await page.close();
4594
- } catch {}
4595
- if (sessionId) sessionRegistry.deleteById(sessionId);
4596
- run.contexts.delete(browserContext);
4597
- await closeContextSafely(browserContext);
4598
- }
4599
- };
4600
- const makeSkippedFileResult = (file)=>({
4601
- testId: getFileTaskId(file.testPath),
4602
- status: 'skip',
4603
- name: '',
4604
- testPath: file.testPath,
4605
- project: file.projectName,
4606
- results: []
4620
+ } catch (error) {
4621
+ return failWithError(error, async ()=>{
4622
+ await promises.rm(tempDir, {
4623
+ recursive: true,
4624
+ force: true
4625
+ }).catch(()=>{});
4607
4626
  });
4608
- const runFilesWithPool = async (files)=>{
4609
- if (0 === files.length) return;
4610
- const previous = runLifecycle.activeSession;
4611
- if (previous) await cancelRun(previous);
4612
- const run = runLifecycle.createSession((token)=>({
4613
- ...createRunSession(token),
4614
- contexts: new Set()
4615
- }));
4616
- const queue = [
4617
- ...files
4618
- ];
4619
- const concurrency = getHeadlessConcurrency(context, queue.length);
4620
- const bail = context.normalizedConfig.bail;
4621
- const worker = async ()=>{
4622
- while(queue.length > 0 && !run.cancelled && runLifecycle.isTokenActive(run.token)){
4623
- if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
4624
- let skipped = queue.shift();
4625
- while(skipped){
4626
- await handleTestFileComplete(makeSkippedFileResult(skipped));
4627
- skipped = queue.shift();
4628
- }
4629
- return;
4630
- }
4631
- const next = queue.shift();
4632
- if (!next) return;
4633
- await runSingleFile(run, next);
4634
- }
4635
- };
4636
- run.done = Promise.all(Array.from({
4637
- length: Math.min(queue.length, Math.max(concurrency, 1))
4638
- }, ()=>worker())).then(()=>{});
4639
- await run.done;
4640
- runLifecycle.clearIfActive(run);
4641
- };
4642
- const latestRerunScheduler = createHeadlessLatestRerunScheduler({
4643
- getActiveRun: ()=>runLifecycle.activeSession,
4644
- isRunCancelled: (run)=>run.cancelled,
4645
- invalidateActiveRun: ()=>{
4646
- runLifecycle.invalidateActiveToken();
4647
- },
4648
- interruptActiveRun: async (run)=>{
4649
- await cancelRun(run, false);
4627
+ }
4628
+ if (isWatchMode && !filesOnly) {
4629
+ watchContext.runtime = runtime;
4630
+ registerWatchCleanup(context.embedded);
4631
+ }
4632
+ }
4633
+ const watchState = runtime.watchState;
4634
+ if (isWatchMode) watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
4635
+ const seedPendingRerun = (testPaths)=>{
4636
+ const wanted = testPaths ? new Set(testPaths.map((testPath)=>normalize(testPath))) : null;
4637
+ let seeded = 0;
4638
+ for (const file of watchState.lastTestFiles){
4639
+ if (wanted && !wanted.has(file.testPath)) continue;
4640
+ const pending = watchState.pendingAffectedTestFiles.get(file.projectName) ?? new Set();
4641
+ pending.add(file.testPath);
4642
+ watchState.pendingAffectedTestFiles.set(file.projectName, pending);
4643
+ seeded += 1;
4644
+ }
4645
+ return seeded;
4646
+ };
4647
+ const buildRerunOutcome = ({ rerunTestPaths, testTime, unhandledErrors })=>{
4648
+ const rerunPathSet = new Set(rerunTestPaths);
4649
+ const rerunResults = context.reporterResults.results.filter((result)=>rerunPathSet.has(result.testPath));
4650
+ const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
4651
+ return {
4652
+ results: rerunResults,
4653
+ testResults: context.reporterResults.testResults.filter((result)=>rerunPathSet.has(result.testPath)),
4654
+ errors: unhandledErrors ?? [],
4655
+ testPaths: rerunTestPaths,
4656
+ duration: {
4657
+ buildTime: drainPendingBuildTime(watchState),
4658
+ testTime
4650
4659
  },
4651
- runFiles: async (files)=>{
4652
- prepareWatchRerunState(context);
4653
- await notifyTestRunStart();
4660
+ coverage: coverageMap?.files().length ? {
4661
+ map: coverageMap.toJSON()
4662
+ } : void 0,
4663
+ resolveSourcemap: resolveBrowserSourcemap
4664
+ };
4665
+ };
4666
+ const createWatchSession = (execute)=>({
4667
+ runCycle: async (testPaths)=>{
4654
4668
  const rerunStartTime = Date.now();
4655
- const fatalErrorBeforeRun = fatalError;
4669
+ const fatalErrorBeforeRun = fatalErrorRef.current;
4656
4670
  let rerunError;
4657
4671
  try {
4658
- await runFilesWithPool(files);
4672
+ await execute(testPaths);
4659
4673
  } catch (error) {
4660
4674
  rerunError = toError(error);
4661
- throw error;
4662
- } finally{
4663
- const testTime = Math.max(0, Date.now() - rerunStartTime);
4664
- const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
4665
- await finalizeWatchRerun({
4666
- rerunTestPaths: files.map((file)=>file.testPath),
4667
- testTime,
4668
- unhandledErrors: rerunError ? [
4669
- rerunError
4670
- ] : rerunFatalError ? [
4671
- rerunFatalError
4672
- ] : void 0
4673
- });
4674
- logWatchReadyMessage(context, enableCliShortcuts);
4675
4675
  }
4676
- },
4677
- onError: async (error)=>{
4678
- const formatted = toError(error);
4679
- await handleFatal({
4680
- message: formatted.message,
4681
- stack: formatted.stack
4676
+ const rerunFatalError = fatalErrorRef.current && fatalErrorRef.current !== fatalErrorBeforeRun ? fatalErrorRef.current : void 0;
4677
+ return buildRerunOutcome({
4678
+ rerunTestPaths: testPaths,
4679
+ testTime: Math.max(0, Date.now() - rerunStartTime),
4680
+ unhandledErrors: rerunError ? [
4681
+ rerunError
4682
+ ] : rerunFatalError ? [
4683
+ rerunFatalError
4684
+ ] : void 0
4682
4685
  });
4683
4686
  },
4684
- onInterrupt: (run)=>{
4685
- logger.debug(`[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`);
4687
+ requestRerun: async (testPaths)=>{
4688
+ const seeded = seedPendingRerun(testPaths);
4689
+ if (testPaths && 0 === seeded) return;
4690
+ await watchSignals.runDispatchRerun();
4691
+ await watchSignals.awaitSignalledCycle();
4686
4692
  }
4687
4693
  });
4688
- awaitHeadlessRerunIdle = ()=>latestRerunScheduler.whenIdle();
4689
- if (0 === allTestFiles.length) {
4690
- const duration = {
4691
- totalTime: buildTime,
4692
- buildTime,
4693
- testTime: 0
4694
- };
4695
- const result = {
4696
- results: reporterResults,
4697
- testResults: caseResults,
4698
- duration,
4699
- hasFailure: false,
4700
- getSourcemap: getBrowserSourcemap,
4701
- resolveSourcemap: resolveBrowserSourcemap,
4702
- close: isWatchMode ? void 0 : async ()=>{
4703
- sessionRegistry.clear();
4704
- await destroyBrowserRuntime(runtime);
4705
- },
4706
- watch: watchHandles
4707
- };
4708
- if (isWatchMode) await notifyTestRunEnd({
4709
- duration
4710
- });
4711
- if (isWatchMode) {
4712
- triggerRerun = async ()=>{
4713
- const newProjectEntries = await collectProjectEntries(context);
4714
- const rerunPlan = planWatchRerun({
4715
- projectEntries: newProjectEntries,
4716
- previousTestFiles: watchState.lastTestFiles,
4717
- affectedTestFiles: drainPendingAffectedTestFiles(watchState)
4718
- });
4719
- if (rerunPlan.filesChanged) {
4720
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
4721
- if (0 === rerunPlan.currentTestFiles.length) {
4722
- logger.log(color.cyan('No browser test files remain after update.\n'));
4723
- logWatchReadyMessage(context, enableCliShortcuts);
4724
- return;
4725
- }
4726
- logger.log(color.cyan(`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`));
4727
- await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
4728
- return;
4729
- }
4730
- logWatchReadyMessage(context, enableCliShortcuts);
4731
- };
4732
- watchState.hooksEnabled = true;
4733
- logWatchReadyMessage(context, enableCliShortcuts);
4734
- }
4735
- return result;
4736
- }
4737
- const testStart = Date.now();
4738
- await runFilesWithPool(allTestFiles);
4739
- const testTime = Date.now() - testStart;
4740
- if (isWatchMode) triggerRerun = async ()=>{
4741
- const newProjectEntries = await collectProjectEntries(context);
4742
- const rerunPlan = planWatchRerun({
4743
- projectEntries: newProjectEntries,
4744
- previousTestFiles: watchState.lastTestFiles,
4745
- affectedTestFiles: drainPendingAffectedTestFiles(watchState)
4746
- });
4747
- if (rerunPlan.filesChanged) {
4748
- const deletedTestPaths = collectDeletedTestPaths(watchState.lastTestFiles, rerunPlan.currentTestFiles);
4749
- if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
4750
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
4751
- if (0 === rerunPlan.currentTestFiles.length) {
4752
- await latestRerunScheduler.enqueueLatest([]);
4753
- logger.log(color.cyan('No browser test files remain after update.\n'));
4754
- logWatchReadyMessage(context, enableCliShortcuts);
4755
- return;
4756
- }
4757
- logger.log(color.cyan(`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`));
4758
- await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
4759
- return;
4760
- }
4761
- if (0 === rerunPlan.affectedTestFiles.length) {
4762
- logger.log(color.cyan('No affected browser test files detected, skipping re-run.\n'));
4763
- logWatchReadyMessage(context, enableCliShortcuts);
4764
- return;
4765
- }
4766
- logger.log(color.cyan(`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`));
4767
- await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
4768
- };
4769
- const closeHeadlessRuntime = isWatchMode ? void 0 : async ()=>{
4770
- sessionRegistry.clear();
4771
- await destroyBrowserRuntime(runtime);
4772
- };
4773
- if (fatalError) return failWithError(fatalError, closeHeadlessRuntime);
4774
- const duration = {
4775
- totalTime: buildTime + testTime,
4694
+ projectEntries = runtime.projectEntries;
4695
+ totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
4696
+ const buildTime = Date.now() - buildStart;
4697
+ if (filesOnly) return {
4698
+ results: [],
4699
+ testResults: [],
4700
+ duration: {
4701
+ totalTime: buildTime,
4776
4702
  buildTime,
4777
- testTime
4778
- };
4779
- context.updateReporterResultState(reporterResults, caseResults);
4780
- const isFailure = reporterResults.some((result)=>'fail' === result.status);
4781
- if (isWatchMode && isFailure) ensureProcessExitCode(1);
4782
- const result = {
4783
- results: reporterResults,
4784
- testResults: caseResults,
4785
- duration,
4786
- hasFailure: isFailure,
4787
- getSourcemap: getBrowserSourcemap,
4788
- resolveSourcemap: resolveBrowserSourcemap,
4789
- close: closeHeadlessRuntime,
4790
- watch: watchHandles
4791
- };
4792
- if (isWatchMode) try {
4793
- await notifyTestRunEnd({
4794
- duration
4795
- });
4796
- } finally{
4797
- await closeHeadlessRuntime?.();
4798
- }
4799
- if (isWatchMode && triggerRerun) {
4800
- watchState.hooksEnabled = true;
4801
- logWatchReadyMessage(context, enableCliShortcuts);
4802
- }
4803
- return result;
4804
- }
4805
- let currentTestFiles = allTestFiles;
4806
- const RUNNER_FRAMES_READY_TIMEOUT_MS = 30000;
4807
- let currentRunnerFramesSignature = null;
4808
- const runnerFramesWaiters = new Map();
4809
- const createTestFilesSignature = (testFiles)=>JSON.stringify(testFiles.map((testFile)=>normalize(testFile)));
4810
- const markRunnerFramesReady = (testFiles)=>{
4811
- const signature = createTestFilesSignature(testFiles);
4812
- currentRunnerFramesSignature = signature;
4813
- const waiters = runnerFramesWaiters.get(signature);
4814
- if (!waiters) return;
4815
- runnerFramesWaiters.delete(signature);
4816
- for (const waiter of waiters)waiter();
4703
+ testTime: 0
4704
+ },
4705
+ hasFailure: false,
4706
+ getSourcemap: getBrowserSourcemap,
4707
+ resolveSourcemap: resolveBrowserSourcemap,
4708
+ close: ()=>destroyBrowserRuntime(runtime)
4817
4709
  };
4818
- const waitForRunnerFramesReady = async (testFiles)=>{
4819
- const signature = createTestFilesSignature(testFiles);
4820
- if (currentRunnerFramesSignature === signature) return;
4821
- await new Promise((resolve, reject)=>{
4822
- const waiters = runnerFramesWaiters.get(signature) ?? new Set();
4823
- const cleanup = ()=>{
4824
- const currentWaiters = runnerFramesWaiters.get(signature);
4825
- if (!currentWaiters) return;
4826
- currentWaiters.delete(onReady);
4827
- if (0 === currentWaiters.size) runnerFramesWaiters.delete(signature);
4828
- };
4829
- const onReady = ()=>{
4830
- if (timeoutId) clearTimeout(timeoutId);
4831
- cleanup();
4832
- resolve();
4833
- };
4834
- const timeoutId = setTimeout(()=>{
4835
- cleanup();
4836
- reject(new Error(`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`));
4837
- }, RUNNER_FRAMES_READY_TIMEOUT_MS);
4838
- waiters.add(onReady);
4839
- runnerFramesWaiters.set(signature, waiters);
4840
- if (currentRunnerFramesSignature === signature) onReady();
4841
- });
4710
+ if (0 === totalTests) {
4711
+ writeEmptyLaunchExitCode();
4712
+ await destroyBrowserRuntime(runtime);
4713
+ return allowEmptyRun ? createEmptyRunResult() : void 0;
4714
+ }
4715
+ const { browser, browserLaunchOptions, wsPort } = runtime;
4716
+ const allTestFiles = projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
4717
+ testPath: normalize(testPath),
4718
+ projectName: entry.project.name
4719
+ })));
4720
+ const projectRuntimeConfigs = browserProjects.map((project)=>({
4721
+ name: project.name,
4722
+ environmentName: project.environmentName,
4723
+ projectRoot: normalize(project.rootPath),
4724
+ runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
4725
+ envMode: 'static',
4726
+ envOverlay: env
4727
+ })),
4728
+ viewport: project.normalizedConfig.browser.viewport
4729
+ }));
4730
+ const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
4731
+ const projectRunnerUrls = Object.fromEntries([
4732
+ ...runtime.projectServers
4733
+ ].map(([name, server])=>[
4734
+ name,
4735
+ `http://localhost:${server.port}`
4736
+ ]));
4737
+ const hostOptions = {
4738
+ rootPath: normalize(context.rootPath),
4739
+ projects: projectRuntimeConfigs,
4740
+ snapshot: {
4741
+ updateSnapshot: options?.updateSnapshot ?? context.snapshotManager.options.updateSnapshot
4742
+ },
4743
+ runnerUrl: `http://localhost:${runtime.containerServer.port}`,
4744
+ projectRunnerUrls,
4745
+ wsPort,
4746
+ debug: isDebug(),
4747
+ rpcTimeout: maxTestTimeoutForRpc
4842
4748
  };
4843
- const getTestFileInfo = (testFile)=>{
4844
- const normalizedTestFile = normalize(testFile);
4845
- const fileInfo = currentTestFiles.find((file)=>file.testPath === normalizedTestFile);
4846
- if (!fileInfo) throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
4847
- return fileInfo;
4749
+ const browserProviderProjects = browserProjects.map((project)=>({
4750
+ rootPath: normalize(project.rootPath),
4751
+ provider: project.normalizedConfig.browser.provider
4752
+ }));
4753
+ const implementationByProvider = new Map();
4754
+ for (const browserProject of browserProviderProjects)if (!implementationByProvider.has(browserProject.provider)) implementationByProvider.set(browserProject.provider, getBrowserProviderImplementation(browserProject.provider));
4755
+ let resolveDispatchPages = ()=>({});
4756
+ const setDispatchPageResolver = (resolver)=>{
4757
+ resolveDispatchPages = resolver;
4848
4758
  };
4849
- let containerContext;
4850
- let containerPage;
4851
- let isNewPage = false;
4852
- if (isWatchMode && runtime.containerPage && runtime.containerContext) {
4853
- containerContext = runtime.containerContext;
4854
- containerPage = runtime.containerPage;
4855
- logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
4856
- } else {
4857
- isNewPage = true;
4858
- containerContext = await browser.newContext({
4859
- providerOptions: browserLaunchOptions.providerOptions,
4860
- viewport: null
4861
- });
4862
- containerPage = await containerContext.newPage();
4863
- containerPage.on('popup', async (popup)=>{
4864
- await popup.close().catch(()=>{});
4865
- });
4866
- containerContext.on('page', async (page)=>{
4867
- if (page !== containerPage) await page.close().catch(()=>{});
4759
+ const dispatchBrowserRpcRequest = async ({ request, target })=>{
4760
+ const timeoutFallbackMs = maxTestTimeoutForRpc;
4761
+ const provider = resolveProviderForTestPath({
4762
+ testPath: request.testPath,
4763
+ browserProjects: browserProviderProjects
4868
4764
  });
4869
- if (isWatchMode) {
4870
- runtime.containerPage = containerPage;
4871
- runtime.containerContext = containerContext;
4765
+ const implementation = implementationByProvider.get(provider);
4766
+ if (!implementation) throw new Error(`Browser provider implementation not found: ${provider}`);
4767
+ const { runnerPage, containerPage } = resolveDispatchPages(target);
4768
+ if (target?.sessionId && !runnerPage) throw new Error(`Runner page session not found for browser dispatch: ${target.sessionId}`);
4769
+ if (!runnerPage && !containerPage) throw new Error('Browser container page is not initialized');
4770
+ try {
4771
+ return await implementation.dispatchRpc({
4772
+ containerPage: runnerPage ? void 0 : containerPage,
4773
+ runnerPage,
4774
+ request,
4775
+ timeoutFallbackMs
4776
+ });
4777
+ } catch (error) {
4778
+ if (error instanceof Error) throw error.message;
4779
+ throw String(error);
4872
4780
  }
4873
- containerPage.on('console', (msg)=>{
4874
- const text = msg.text();
4875
- if (text.startsWith('[Container]') || text.startsWith('[Runner]')) logger.log(color.gray(`[Browser Console] ${text}`));
4781
+ };
4782
+ runtime.dispatchHandlers.set(DISPATCH_NAMESPACE_BROWSER, async (dispatchRequest)=>{
4783
+ const request = validateBrowserRpcRequest(dispatchRequest.args);
4784
+ return dispatchBrowserRpcRequest({
4785
+ request,
4786
+ target: dispatchRequest.target
4876
4787
  });
4877
- }
4878
- activeContainerPage = containerPage;
4879
- const dispatchRouter = createDispatchRouter();
4880
- const headedReloadQueue = createHeadedSerialTaskQueue();
4881
- const pendingHeadedReloads = new Map();
4882
- let enqueueHeadedReload = async (_file, _testNamePattern)=>{
4883
- throw new Error('Headed reload queue is not initialized');
4788
+ });
4789
+ runtime.setContainerOptions(hostOptions);
4790
+ const reporterResults = [];
4791
+ const caseResults = [];
4792
+ const fatalErrorRef = {
4793
+ current: null
4884
4794
  };
4885
- const rejectPendingHeadedReload = (testPath, error, runId)=>{
4886
- const pending = pendingHeadedReloads.get(testPath);
4887
- if (!pending) return;
4888
- if (runId && pending.runId !== runId) return;
4889
- pendingHeadedReloads.delete(testPath);
4890
- pending.deferred.reject(error);
4795
+ const runnerSinks = new Map(browserProjects.map((project)=>[
4796
+ project.name,
4797
+ createRunnerEventSink(context, project.normalizedConfig)
4798
+ ]));
4799
+ const sinkForProjectName = (projectName)=>{
4800
+ const sink = runnerSinks.get(projectName);
4801
+ if (!sink) throw new Error(`No runner event sink for project "${projectName}"`);
4802
+ return sink;
4891
4803
  };
4892
- const rejectAllPendingHeadedReloads = (error)=>{
4893
- for (const [testPath, pending] of pendingHeadedReloads){
4894
- pendingHeadedReloads.delete(testPath);
4895
- pending.deferred.reject(error);
4804
+ const silentConsoleController = createSilentConsoleController({
4805
+ runtimeConfig: {
4806
+ silent: context.normalizedConfig.silent,
4807
+ disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept
4808
+ },
4809
+ emitInterceptedLog: (log)=>sinkForProjectName(log.project).onConsoleLog(log),
4810
+ writeOriginalLog: ()=>{}
4811
+ });
4812
+ const snapshotRpcMethods = {
4813
+ async resolveSnapshotPath (testPath) {
4814
+ return resolveSnapshotPathDefault(testPath, context.normalizedConfig.resolveSnapshotPath);
4815
+ },
4816
+ async readSnapshotFile (filepath) {
4817
+ try {
4818
+ return await promises.readFile(filepath, 'utf-8');
4819
+ } catch {
4820
+ return null;
4821
+ }
4822
+ },
4823
+ async saveSnapshotFile (filepath, content) {
4824
+ const dir = dirname(filepath);
4825
+ await promises.mkdir(dir, {
4826
+ recursive: true
4827
+ });
4828
+ await promises.writeFile(filepath, content, 'utf-8');
4829
+ },
4830
+ async removeSnapshotFile (filepath) {
4831
+ try {
4832
+ await promises.unlink(filepath);
4833
+ } catch {}
4896
4834
  }
4897
4835
  };
4898
- const registerPendingHeadedReload = (testPath, runId)=>{
4899
- const previousPending = pendingHeadedReloads.get(testPath);
4900
- if (previousPending) {
4901
- previousPending.deferred.reject(new Error(`Reload for "${testPath}" was superseded by a newer request.`));
4902
- pendingHeadedReloads.delete(testPath);
4836
+ const handleTestFileStart = async (payload)=>{
4837
+ if (phaseTrackers) {
4838
+ const tracker = new PhaseTracker({
4839
+ trace: {
4840
+ testPath: payload.testPath,
4841
+ project: payload.projectName
4842
+ },
4843
+ pid: nextBrowserFilePid++
4844
+ });
4845
+ tracker.transition('prepare');
4846
+ phaseTrackers.set(trackerKey(payload.projectName, payload.testPath), tracker);
4903
4847
  }
4904
- const deferred = createDeferredPromise();
4905
- pendingHeadedReloads.set(testPath, {
4906
- runId,
4907
- deferred
4848
+ await sinkForProjectName(payload.projectName).onTestFileStart({
4849
+ testId: getFileTaskId(payload.testPath),
4850
+ testPath: payload.testPath,
4851
+ project: payload.projectName,
4852
+ tests: []
4908
4853
  });
4909
- return deferred.promise;
4910
4854
  };
4911
- const resolvePendingHeadedReload = (testPath, runId)=>{
4912
- const pending = pendingHeadedReloads.get(testPath);
4913
- if (!pending) return;
4914
- if (runId && pending.runId !== runId) return void logger.debug(`[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`);
4915
- pendingHeadedReloads.delete(testPath);
4916
- pending.deferred.resolve();
4855
+ const handleTestFileReady = async (payload)=>{
4856
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.transition('tests');
4857
+ await sinkForProjectName(payload.project).onTestFileReady(payload);
4917
4858
  };
4918
- const reloadTestFileAndWait = async (file, testNamePattern)=>{
4919
- let reloadAck;
4920
- try {
4921
- reloadAck = await rpcManager.reloadTestFile(file.testPath, testNamePattern);
4922
- await registerPendingHeadedReload(file.testPath, reloadAck.runId);
4923
- } catch (error) {
4924
- if (reloadAck?.runId) rejectPendingHeadedReload(file.testPath, toError(error), reloadAck.runId);
4925
- throw error;
4926
- }
4859
+ const handleTestSuiteStart = async (payload)=>{
4860
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordSuiteStart(payload);
4861
+ await sinkForProjectName(payload.project).onTestSuiteStart(payload);
4927
4862
  };
4928
- const createRpcMethods = ()=>({
4929
- async rerunTest (testFile, testNamePattern) {
4930
- const projectName = context.normalizedConfig.name || 'project';
4931
- const relativePath = relative(context.rootPath, testFile);
4932
- const displayPath = `<${projectName}>/${relativePath}`;
4933
- logger.log(color.cyan(`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`));
4934
- await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
4935
- },
4936
- async getTestFiles () {
4937
- return currentTestFiles;
4938
- },
4939
- async onRunnerFramesReady (testFiles) {
4940
- markRunnerFramesReady(testFiles);
4941
- },
4942
- async onTestFileStart (payload) {
4943
- await handleTestFileStart(payload);
4944
- },
4945
- async onTestCaseResult (payload) {
4946
- await handleTestCaseResult(payload);
4947
- },
4948
- async onTestFileComplete (payload) {
4949
- try {
4950
- await handleTestFileComplete(payload);
4951
- resolvePendingHeadedReload(payload.testPath, payload.runId);
4952
- } catch (error) {
4953
- rejectPendingHeadedReload(payload.testPath, toError(error), payload.runId);
4954
- throw error;
4955
- }
4956
- },
4957
- async onLog (payload) {
4958
- await handleLog(payload);
4959
- },
4960
- async onFatal (payload) {
4961
- const error = new Error(payload.message);
4962
- error.stack = payload.stack;
4963
- rejectAllPendingHeadedReloads(error);
4964
- await handleFatal(payload);
4965
- },
4966
- async dispatch (request) {
4967
- return dispatchRouter.dispatch(request);
4968
- }
4969
- });
4970
- let rpcManager;
4971
- if (isWatchMode && runtime.rpcManager) {
4972
- rpcManager = runtime.rpcManager;
4973
- rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
4974
- const existingWs = rpcManager.currentWebSocket;
4975
- if (existingWs) rpcManager.reattach(existingWs);
4976
- } else {
4977
- rpcManager = new ContainerRpcManager(wss, createRpcMethods(), rejectAllPendingHeadedReloads);
4978
- if (isWatchMode) runtime.rpcManager = rpcManager;
4979
- }
4980
- if (isNewPage) {
4981
- const pagePath = '/';
4982
- const containerPort = runtime.containerServer.port;
4983
- await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
4984
- waitUntil: 'load'
4863
+ const handleTestSuiteResult = async (payload)=>{
4864
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordSuiteResult(payload);
4865
+ await sinkForProjectName(payload.project).onTestSuiteResult(payload);
4866
+ if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4867
+ taskId: payload.testId,
4868
+ status: payload.status,
4869
+ taskParentNames: payload.parentNames,
4870
+ taskType: 'suite',
4871
+ testPath: payload.testPath
4985
4872
  });
4986
- logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`));
4987
- }
4988
- enqueueHeadedReload = async (file, testNamePattern)=>headedReloadQueue.enqueue(async ()=>{
4989
- if (fatalError) return;
4990
- await reloadTestFileAndWait(file, testNamePattern);
4873
+ };
4874
+ const handleTestCaseStart = async (payload)=>{
4875
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordCaseStart(payload);
4876
+ sinkForProjectName(payload.project).onTestCaseStart(payload);
4877
+ };
4878
+ const handleTestCaseResult = async (payload)=>{
4879
+ caseResults.push(payload);
4880
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordCaseResult(payload);
4881
+ await sinkForProjectName(payload.project).onTestCaseResult(payload);
4882
+ if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4883
+ taskId: payload.testId,
4884
+ status: payload.status,
4885
+ taskParentNames: payload.parentNames,
4886
+ taskType: 'case',
4887
+ testPath: payload.testPath
4991
4888
  });
4992
- let testTime = 0;
4993
- if (currentTestFiles.length > 0) {
4994
- const testStart = Date.now();
4995
- try {
4996
- await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
4997
- for (const file of currentTestFiles){
4998
- await enqueueHeadedReload(file);
4999
- if (fatalError) break;
4889
+ };
4890
+ const handleTestFileComplete = async (payload)=>{
4891
+ reporterResults.push(payload);
4892
+ context.updateReporterResultState([
4893
+ payload
4894
+ ], payload.results);
4895
+ if (phaseTrackers) {
4896
+ const key = trackerKey(payload.project, payload.testPath);
4897
+ const tracker = phaseTrackers.get(key);
4898
+ if (tracker) {
4899
+ tracker.end();
4900
+ const events = tracker.getTraceEvents();
4901
+ if (events) onTraceEvents?.(events);
4902
+ phaseTrackers.delete(key);
5000
4903
  }
5001
- } catch (error) {
5002
- fatalError = fatalError ?? toError(error);
5003
- if (isWatchMode) ensureProcessExitCode(1);
5004
4904
  }
5005
- testTime = Date.now() - testStart;
5006
- }
5007
- if (isWatchMode) triggerRerun = async ()=>{
5008
- const refreshedHostOptions = {
5009
- ...hostOptions,
5010
- snapshot: {
5011
- updateSnapshot: context.snapshotManager.options.updateSnapshot
5012
- }
5013
- };
5014
- runtime.setContainerOptions(refreshedHostOptions);
5015
- const [, newProjectEntries] = await Promise.all([
5016
- rpcManager.updateHostConfig(refreshedHostOptions),
5017
- collectProjectEntries(context)
5018
- ]);
5019
- const rerunPlan = planWatchRerun({
5020
- projectEntries: newProjectEntries,
5021
- previousTestFiles: watchState.lastTestFiles,
5022
- affectedTestFiles: drainPendingAffectedTestFiles(watchState)
4905
+ if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4906
+ taskId: payload.testId,
4907
+ status: payload.status,
4908
+ taskParentNames: payload.parentNames,
4909
+ taskType: 'file',
4910
+ testPath: payload.testPath
5023
4911
  });
5024
- if (rerunPlan.filesChanged) {
5025
- const deletedTestPaths = collectDeletedTestPaths(watchState.lastTestFiles, rerunPlan.currentTestFiles);
5026
- if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
5027
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
5028
- currentTestFiles = rerunPlan.currentTestFiles;
5029
- await rpcManager.notifyTestFileUpdate(currentTestFiles);
5030
- if (0 === currentTestFiles.length) {
5031
- logger.log(color.cyan('No browser test files remain after update.\n'));
5032
- logWatchReadyMessage(context, enableCliShortcuts);
5033
- return;
5034
- }
5035
- await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
5036
- }
5037
- if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
5038
- logger.log(color.cyan(`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`));
5039
- prepareWatchRerunState(context);
5040
- await notifyTestRunStart();
5041
- const rerunStartTime = Date.now();
5042
- const fatalErrorBeforeRun = fatalError;
5043
- let rerunError;
5044
- try {
5045
- for (const testFile of rerunPlan.normalizedAffectedTestFiles)await enqueueHeadedReload(getTestFileInfo(testFile));
5046
- } catch (error) {
5047
- rerunError = toError(error);
5048
- throw error;
5049
- } finally{
5050
- const testTime = Math.max(0, Date.now() - rerunStartTime);
5051
- const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
5052
- await finalizeWatchRerun({
5053
- rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
5054
- testTime,
5055
- unhandledErrors: rerunError ? [
5056
- rerunError
5057
- ] : rerunFatalError ? [
5058
- rerunFatalError
5059
- ] : void 0
5060
- });
5061
- logWatchReadyMessage(context, enableCliShortcuts);
5062
- }
5063
- } else if (rerunPlan.filesChanged) logWatchReadyMessage(context, enableCliShortcuts);
5064
- else {
5065
- logger.log(color.cyan('Tests will be re-executed automatically\n'));
5066
- logWatchReadyMessage(context, enableCliShortcuts);
5067
- }
4912
+ await sinkForProjectName(payload.project).onTestFileResult(payload);
5068
4913
  };
5069
- const closeContainerRuntime = isWatchMode ? void 0 : async ()=>{
5070
- try {
5071
- await containerPage.close();
5072
- } catch {}
5073
- try {
5074
- await containerContext.close();
5075
- } catch {}
5076
- await destroyBrowserRuntime(runtime);
4914
+ const handleLog = async (payload)=>{
4915
+ const log = {
4916
+ content: payload.content,
4917
+ name: getPrettyConsoleName(payload.level),
4918
+ taskId: payload.taskId,
4919
+ taskName: payload.taskName,
4920
+ taskParentNames: payload.taskParentNames,
4921
+ taskType: payload.taskType,
4922
+ testPath: payload.testPath,
4923
+ project: payload.projectName,
4924
+ type: payload.type,
4925
+ trace: payload.trace
4926
+ };
4927
+ silentConsoleController.onConsoleLog(log);
4928
+ };
4929
+ const handleFatal = async (payload)=>{
4930
+ const error = new Error(payload.message);
4931
+ error.stack = payload.stack;
4932
+ fatalErrorRef.current = error;
4933
+ };
4934
+ const runSnapshotRpc = async (request)=>{
4935
+ switch(request.method){
4936
+ case 'resolveSnapshotPath':
4937
+ return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
4938
+ case 'readSnapshotFile':
4939
+ return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
4940
+ case 'saveSnapshotFile':
4941
+ return snapshotRpcMethods.saveSnapshotFile(request.args.filepath, request.args.content);
4942
+ case 'removeSnapshotFile':
4943
+ return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
4944
+ default:
4945
+ {
4946
+ const _exhaustive = request;
4947
+ return _exhaustive;
4948
+ }
4949
+ }
5077
4950
  };
5078
- if (fatalError) return failWithError(fatalError, closeContainerRuntime);
5079
- const duration = {
5080
- totalTime: buildTime + testTime,
5081
- buildTime,
5082
- testTime
4951
+ const createDispatchRouter = (options)=>createHostDispatchRouter({
4952
+ routerOptions: options,
4953
+ runnerCallbacks: {
4954
+ onTestFileStart: handleTestFileStart,
4955
+ onTestFileReady: handleTestFileReady,
4956
+ onTestSuiteStart: handleTestSuiteStart,
4957
+ onTestSuiteResult: handleTestSuiteResult,
4958
+ onTestCaseStart: handleTestCaseStart,
4959
+ onTestCaseResult: handleTestCaseResult,
4960
+ onTestFileComplete: handleTestFileComplete,
4961
+ onLog: handleLog,
4962
+ onFatal: handleFatal
4963
+ },
4964
+ runSnapshotRpc,
4965
+ extensionHandlers: runtime.dispatchHandlers,
4966
+ onDuplicateNamespace: (namespace)=>{
4967
+ logger.debug(`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`);
4968
+ }
4969
+ });
4970
+ const schedulerDeps = {
4971
+ context,
4972
+ allTestFiles,
4973
+ hostOptions,
4974
+ isWatchMode,
4975
+ createDispatchRouter,
4976
+ fatalErrorRef,
4977
+ watchSignals,
4978
+ setDispatchPageResolver,
4979
+ createWatchSession,
4980
+ collectProjectEntries: ()=>browserRsbuild_collectProjectEntries(context),
4981
+ logWatchReady: ()=>logWatchReadyMessage(context, enableCliShortcuts),
4982
+ destroyRuntime: ()=>destroyBrowserRuntime(runtime)
5083
4983
  };
4984
+ const { testTime, watchSession, close } = useHeadlessDirect ? await createHeadlessScheduler({
4985
+ ...schedulerDeps,
4986
+ browser,
4987
+ browserLaunchOptions,
4988
+ projectServers: runtime.projectServers,
4989
+ projectRuntimeConfigs,
4990
+ watchState,
4991
+ handlers: {
4992
+ handleFatal,
4993
+ handleTestFileComplete
4994
+ }
4995
+ }) : await createHeadedScheduler({
4996
+ ...schedulerDeps,
4997
+ runtime,
4998
+ handlers: {
4999
+ handleTestFileStart,
5000
+ handleTestCaseResult,
5001
+ handleTestFileComplete,
5002
+ handleLog,
5003
+ handleFatal
5004
+ }
5005
+ });
5006
+ if (fatalErrorRef.current) return failWithError(fatalErrorRef.current, close);
5084
5007
  context.updateReporterResultState(reporterResults, caseResults);
5085
- const isFailure = reporterResults.some((result)=>'fail' === result.status);
5086
- if (isWatchMode && isFailure) ensureProcessExitCode(1);
5087
- const result = {
5008
+ watchState.hooksEnabled = isWatchMode;
5009
+ return {
5088
5010
  results: reporterResults,
5089
5011
  testResults: caseResults,
5090
- duration,
5091
- hasFailure: isFailure,
5012
+ duration: {
5013
+ totalTime: buildTime + testTime,
5014
+ buildTime,
5015
+ testTime
5016
+ },
5017
+ hasFailure: reporterResults.some((result)=>'fail' === result.status),
5092
5018
  getSourcemap: getBrowserSourcemap,
5093
5019
  resolveSourcemap: resolveBrowserSourcemap,
5094
- close: closeContainerRuntime,
5095
- watch: watchHandles
5020
+ close,
5021
+ watchSession
5096
5022
  };
5097
- if (isWatchMode) try {
5098
- await notifyTestRunEnd({
5099
- duration
5100
- });
5101
- } finally{
5102
- await closeContainerRuntime?.();
5103
- }
5104
- if (isWatchMode && triggerRerun) {
5105
- watchState.hooksEnabled = true;
5106
- logWatchReadyMessage(context, enableCliShortcuts);
5107
- }
5108
- return result;
5109
5023
  };
5110
5024
  const listBrowserTests = async (context, options)=>{
5111
5025
  const browserProjects = options?.projects ?? getBrowserProjects(context);
@@ -5164,7 +5078,8 @@ const listBrowserTests = async (context, options)=>{
5164
5078
  environmentName: project.environmentName,
5165
5079
  projectRoot: normalize(project.rootPath),
5166
5080
  runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
5167
- envMode: 'static'
5081
+ envMode: 'static',
5082
+ envOverlay: options?.env
5168
5083
  })),
5169
5084
  viewport: project.normalizedConfig.browser.viewport
5170
5085
  }));
@@ -5297,8 +5212,11 @@ const emptyOutcome = ()=>({
5297
5212
  });
5298
5213
  async function createBrowserExecutor(context, options) {
5299
5214
  const { projects, coverageProvider, shardedEntries, freezeShardedEntries, filesOnly, allowEmptyRun, appliedModifyRstestConfigEnvironments } = options;
5215
+ const isWatchMode = 'watch' === context.command;
5300
5216
  let deferredClose;
5301
5217
  let inFlightCycle;
5218
+ let invalidationCallback;
5219
+ let watchSession;
5302
5220
  const foldOutcome = (result)=>{
5303
5221
  if (!result) return emptyOutcome();
5304
5222
  const map = buildBrowserCoverageMap(result.results, coverageProvider);
@@ -5322,6 +5240,15 @@ async function createBrowserExecutor(context, options) {
5322
5240
  projects,
5323
5241
  async init () {},
5324
5242
  async runCycle (opts) {
5243
+ if (watchSession) {
5244
+ const cycle = watchSession.runCycle(opts.fileFilters ?? []);
5245
+ inFlightCycle = cycle;
5246
+ try {
5247
+ return await cycle;
5248
+ } finally{
5249
+ inFlightCycle = void 0;
5250
+ }
5251
+ }
5325
5252
  const cycle = runBrowserController(context, {
5326
5253
  projects,
5327
5254
  shardedEntries,
@@ -5330,24 +5257,37 @@ async function createBrowserExecutor(context, options) {
5330
5257
  appliedModifyRstestConfigEnvironments,
5331
5258
  onTraceEvents: opts.onTraceEvents,
5332
5259
  env: opts.env,
5333
- updateSnapshot: opts.updateSnapshot
5260
+ updateSnapshot: opts.updateSnapshot,
5261
+ onInvalidate: isWatchMode ? (hint)=>invalidationCallback?.(hint) : void 0
5334
5262
  });
5335
5263
  inFlightCycle = cycle;
5336
5264
  try {
5337
5265
  const result = await cycle;
5338
5266
  deferredClose = result?.close;
5267
+ watchSession = result?.watchSession;
5339
5268
  return foldOutcome(result);
5340
5269
  } finally{
5341
5270
  inFlightCycle = void 0;
5342
5271
  }
5343
5272
  },
5344
- async collect () {
5273
+ onInvalidate (cb) {
5274
+ invalidationCallback = cb;
5275
+ },
5276
+ hasWatchSession () {
5277
+ return void 0 !== watchSession;
5278
+ },
5279
+ async requestRerun (testPaths) {
5280
+ if (!watchSession) return void logger.log(color.yellow('\nBrowser Mode has no live watch session, so this rerun skipped it.'));
5281
+ await watchSession.requestRerun(testPaths);
5282
+ },
5283
+ async collect (opts) {
5345
5284
  const pending = listBrowserTests(context, {
5346
5285
  projects,
5347
5286
  shardedEntries,
5348
5287
  freezeShardedEntries,
5349
5288
  filesOnly,
5350
- appliedModifyRstestConfigEnvironments
5289
+ appliedModifyRstestConfigEnvironments,
5290
+ env: opts.env
5351
5291
  });
5352
5292
  inFlightCycle = pending;
5353
5293
  try {
@@ -5362,6 +5302,11 @@ async function createBrowserExecutor(context, options) {
5362
5302
  },
5363
5303
  async close () {
5364
5304
  if (inFlightCycle) await inFlightCycle.catch(()=>void 0);
5305
+ if (isWatchMode) {
5306
+ watchSession = void 0;
5307
+ await cleanupWatchRuntime();
5308
+ return;
5309
+ }
5365
5310
  const close = deferredClose;
5366
5311
  deferredClose = void 0;
5367
5312
  await close?.();