@rstest/browser 0.11.5 → 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.
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;
@@ -3731,7 +3126,7 @@ const createBrowserRuntime = async ({ context, projectEntries: initialProjectEnt
3731
3126
  if (watchState.hooksEnabled) watchState.pendingBuildTimeMs = Math.max(watchState.pendingBuildTimeMs, Date.now() - compileStart);
3732
3127
  }
3733
3128
  if (stats) {
3734
- const [projectEntry] = await collectProjectEntries(context, [
3129
+ const [projectEntry] = await browserRsbuild_collectProjectEntries(context, [
3735
3130
  project
3736
3131
  ]);
3737
3132
  const entryTestFiles = new Set(collectWatchTestFiles(projectEntry ? [
@@ -3909,1155 +3304,1722 @@ async function resolveProjectEntries(context, shardedEntries, browserProjects) {
3909
3304
  }
3910
3305
  return projectEntries;
3911
3306
  }
3912
- return collectProjectEntries(context, browserProjects);
3307
+ return browserRsbuild_collectProjectEntries(context, browserProjects);
3913
3308
  }
3914
- const runBrowserController = async (context, options)=>{
3915
- const { allowEmptyRun = false, filesOnly = false, onTraceEvents, env } = options ?? {};
3916
- const buildStart = Date.now();
3917
- const isWatchMode = 'watch' === context.command;
3918
- const phaseTrackers = onTraceEvents ? new Map() : void 0;
3919
- const trackerKey = (project, testPath)=>`${project}\u0000${testPath}`;
3920
- const browserProjects = options?.projects ?? getBrowserProjects(context);
3921
- const useHeadlessDirect = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
3922
- const browserSourceMapCache = new Map();
3923
- const isHttpLikeFile = (file)=>/^https?:\/\//.test(file);
3924
- const resolveBrowserSourcemap = async (sourcePath)=>{
3925
- if (!isHttpLikeFile(sourcePath)) return {
3926
- handled: false,
3927
- sourcemap: null
3928
- };
3929
- const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
3930
- if (!normalizedUrl) return {
3931
- handled: true,
3932
- sourcemap: null
3933
- };
3934
- if (browserSourceMapCache.has(normalizedUrl)) return {
3935
- handled: true,
3936
- sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null
3937
- };
3938
- return {
3939
- handled: true,
3940
- sourcemap: await loadSourceMapWithCache({
3941
- jsUrl: normalizedUrl,
3942
- cache: browserSourceMapCache
3943
- })
3944
- };
3945
- };
3946
- const getBrowserSourcemap = async (sourcePath)=>{
3947
- const result = await resolveBrowserSourcemap(sourcePath);
3948
- 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
3949
3321
  };
3950
- const buildErrorResult = async (error, close)=>{
3951
- const elapsed = Math.max(0, Date.now() - buildStart);
3952
- const errorResult = {
3953
- results: [],
3954
- testResults: [],
3955
- duration: {
3956
- totalTime: elapsed,
3957
- buildTime: elapsed,
3958
- testTime: 0
3959
- },
3960
- hasFailure: true,
3961
- unhandledErrors: [
3962
- error
3963
- ],
3964
- getSourcemap: getBrowserSourcemap,
3965
- resolveSourcemap: resolveBrowserSourcemap,
3966
- 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
3967
3347
  };
3968
- if (isWatchMode) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
3969
- results: [],
3970
- testResults: [],
3971
- duration: errorResult.duration,
3972
- snapshotSummary: context.snapshotManager.summary,
3973
- getSourcemap: getBrowserSourcemap,
3974
- unhandledErrors: errorResult.unhandledErrors
3975
- });
3976
- return errorResult;
3977
- };
3978
- const toError = (error)=>error instanceof Error ? error : new Error(String(error));
3979
- const failWithError = async (error, cleanup)=>{
3980
- if (isWatchMode) ensureProcessExitCode(1);
3981
- const normalizedError = toError(error);
3982
- if (cleanup && !isWatchMode) return buildErrorResult(normalizedError, cleanup);
3983
- try {
3984
- 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;
3985
3353
  } finally{
3986
- await cleanup?.();
3987
- }
3988
- };
3989
- const collectDeletedTestPaths = (previous, current)=>{
3990
- const currentPathSet = new Set(current.map((file)=>file.testPath));
3991
- return previous.map((file)=>file.testPath).filter((testPath)=>!currentPathSet.has(testPath));
3992
- };
3993
- const notifyTestRunStart = async ()=>{
3994
- if (!isWatchMode) return;
3995
- for (const reporter of context.reporters)await reporter.onTestRunStart?.();
3996
- };
3997
- const coverageConfig = browserProjects.find((project)=>project.normalizedConfig.coverage?.enabled)?.normalizedConfig.coverage;
3998
- const coverageProvider = coverageConfig?.enabled ? await createCoverageProvider(coverageConfig, context.rootPath) : null;
3999
- const notifyTestRunEnd = async ({ duration, coverage })=>{
4000
- if (!isWatchMode) return;
4001
- for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
4002
- results: context.reporterResults.results,
4003
- coverage,
4004
- testResults: context.reporterResults.testResults,
4005
- duration,
4006
- snapshotSummary: context.snapshotManager.summary,
4007
- getSourcemap: getBrowserSourcemap
4008
- });
4009
- };
4010
- const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
4011
- let containerDevServer;
4012
- let containerDistPath;
4013
- if (!useHeadlessDirect) {
4014
- if (containerDevServerEnv) try {
4015
- containerDevServer = new URL(containerDevServerEnv).toString();
4016
- logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
4017
- } catch (error) {
4018
- const originalError = toError(error);
4019
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
4020
- return failWithError(originalError);
4021
- }
4022
- if (!containerDevServer) try {
4023
- containerDistPath = resolveContainerDist();
4024
- } catch (error) {
4025
- return failWithError(error);
3354
+ _promiseInit = void 0;
4026
3355
  }
4027
- }
4028
- let projectEntries = await resolveProjectEntries(context, options?.shardedEntries, browserProjects);
4029
- let totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
4030
- const shouldInitializeEmptyBrowserHooks = 0 === totalTests && hasUserRstestConfigPlugins(browserProjects);
4031
- const createEmptyRunResult = ()=>{
4032
- const elapsed = Math.max(0, Date.now() - buildStart);
4033
- return {
4034
- results: [],
4035
- testResults: [],
4036
- duration: {
4037
- totalTime: elapsed,
4038
- buildTime: elapsed,
4039
- testTime: 0
4040
- },
4041
- hasFailure: false,
4042
- getSourcemap: getBrowserSourcemap,
4043
- resolveSourcemap: resolveBrowserSourcemap
4044
- };
4045
- };
4046
- const reportEmptyTestSet = ()=>{
4047
- const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
4048
- if (isWatchMode || !allowEmptyRun) {
4049
- const message = getNoTestFilesMessage({
4050
- context,
4051
- code,
4052
- 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
4053
3377
  });
4054
- if (0 === code) logger.log(color.yellow(message));
4055
- else logger.error(color.red(message));
4056
- if (context.relatedFilters?.length) logger.log(color.gray('related: '), context.relatedFilters.join(color.gray(', ')));
4057
- else if (context.fileFilters?.length) logger.log(color.gray('filter: '), context.fileFilters.join(color.gray(', ')));
3378
+ await send(newReq);
3379
+ return promise;
4058
3380
  }
4059
- if (isWatchMode && 0 !== code && !allowEmptyRun) ensureProcessExitCode(code);
4060
- };
4061
- if (0 === totalTests && !shouldInitializeEmptyBrowserHooks) {
4062
- reportEmptyTestSet();
4063
- return allowEmptyRun ? createEmptyRunResult() : void 0;
4064
- }
4065
- if (!filesOnly) await notifyTestRunStart();
4066
- const enableCliShortcuts = isWatchMode && isTTY('stdin');
4067
- const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
4068
- const tempDir = isWatchMode && watchContext.runtime ? watchContext.runtime.tempDir : isWatchMode ? join(context.rootPath, browserTempOutputRoot, 'browser', 'watch') : join(context.rootPath, browserTempOutputRoot, 'browser', Date.now().toString());
4069
- let runtime = isWatchMode ? watchContext.runtime : null;
4070
- let triggerRerun;
4071
- let awaitHeadlessRerunIdle;
4072
- if (!runtime) {
4073
3381
  try {
4074
- runtime = await createBrowserRuntime({
4075
- context,
4076
- projectEntries,
4077
- browserProjects,
4078
- shardedEntries: options?.shardedEntries,
4079
- freezeShardedEntries: options?.freezeShardedEntries,
4080
- tempDir,
4081
- isWatchMode,
4082
- onTriggerRerun: isWatchMode ? async ()=>{
4083
- await triggerRerun?.();
4084
- } : void 0,
4085
- containerDistPath,
4086
- containerDevServer,
4087
- skipProviderLaunch: filesOnly,
4088
- appliedModifyRstestConfigEnvironments: options?.appliedModifyRstestConfigEnvironments
4089
- });
4090
- } catch (error) {
4091
- return failWithError(error, async ()=>{
4092
- await promises.rm(tempDir, {
4093
- recursive: true,
4094
- force: true
4095
- }).catch(()=>{});
4096
- });
4097
- }
4098
- if (isWatchMode) {
4099
- watchContext.runtime = runtime;
4100
- 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);
4101
3390
  }
3391
+ return promise;
4102
3392
  }
4103
- const watchState = runtime.watchState;
4104
- if (isWatchMode) watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
4105
- const seedPendingRerun = (testPaths)=>{
4106
- const wanted = testPaths ? new Set(testPaths.map((testPath)=>normalize(testPath))) : null;
4107
- let seeded = 0;
4108
- for (const file of watchState.lastTestFiles){
4109
- if (wanted && !wanted.has(file.testPath)) continue;
4110
- const pending = watchState.pendingAffectedTestFiles.get(file.projectName) ?? new Set();
4111
- pending.add(file.testPath);
4112
- watchState.pendingAffectedTestFiles.set(file.projectName, pending);
4113
- seeded += 1;
4114
- }
4115
- return seeded;
4116
- };
4117
- const watchHandles = isWatchMode ? {
4118
- rerun: async (testPaths)=>{
4119
- const seeded = seedPendingRerun(testPaths);
4120
- if (testPaths && 0 === seeded) return;
4121
- await triggerRerun?.();
4122
- 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;
4123
3401
  },
4124
- close: cleanupWatchRuntime
4125
- } : void 0;
4126
- const finalizeWatchRerun = async ({ rerunTestPaths, testTime, unhandledErrors })=>{
4127
- const rerunPathSet = new Set(rerunTestPaths);
4128
- const rerunResults = context.reporterResults.results.filter((result)=>rerunPathSet.has(result.testPath));
4129
- let rerunCoverage;
4130
- const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
4131
- if (coverageMap && coverageMap.files().length > 0) rerunCoverage = coverageMap.toJSON();
4132
- const outcome = {
4133
- results: rerunResults,
4134
- testResults: context.reporterResults.testResults.filter((result)=>rerunPathSet.has(result.testPath)),
4135
- errors: unhandledErrors ?? [],
4136
- testPaths: rerunTestPaths,
4137
- duration: {
4138
- buildTime: drainPendingBuildTime(watchState),
4139
- testTime
4140
- },
4141
- coverage: rerunCoverage ? {
4142
- map: rerunCoverage
4143
- } : void 0,
4144
- resolveSourcemap: resolveBrowserSourcemap
4145
- };
4146
- await finalizeRunCycle(context, {
4147
- outcomes: [
4148
- outcome
4149
- ],
4150
- mode: 'on-demand',
4151
- isWatchMode: true,
4152
- coverageProvider,
4153
- reportOnFailure: coverageConfig?.reportOnFailure ?? false
4154
- });
4155
- };
4156
- projectEntries = runtime.projectEntries;
4157
- totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
4158
- const buildTime = Date.now() - buildStart;
4159
- if (filesOnly) return {
4160
- results: [],
4161
- testResults: [],
4162
- duration: {
4163
- totalTime: buildTime,
4164
- buildTime,
4165
- testTime: 0
3402
+ get $meta () {
3403
+ return options.meta;
4166
3404
  },
4167
- hasFailure: false,
4168
- getSourcemap: getBrowserSourcemap,
4169
- resolveSourcemap: resolveBrowserSourcemap,
4170
- close: ()=>destroyBrowserRuntime(runtime)
3405
+ $close,
3406
+ $functions
4171
3407
  };
4172
- if (0 === totalTests) {
4173
- reportEmptyTestSet();
4174
- await destroyBrowserRuntime(runtime);
4175
- 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);
4176
3434
  }
4177
- const { browser, browserLaunchOptions, wsPort, wss } = runtime;
4178
- const allTestFiles = projectEntries.flatMap((entry)=>entry.testFiles.map((testPath)=>({
4179
- testPath: normalize(testPath),
4180
- projectName: entry.project.name
4181
- })));
4182
- const projectRuntimeConfigs = browserProjects.map((project)=>({
4183
- name: project.name,
4184
- environmentName: project.environmentName,
4185
- projectRoot: normalize(project.rootPath),
4186
- runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
4187
- envMode: 'static',
4188
- envOverlay: env
4189
- })),
4190
- viewport: project.normalizedConfig.browser.viewport
4191
- }));
4192
- const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
4193
- const projectRunnerUrls = Object.fromEntries([
4194
- ...runtime.projectServers
4195
- ].map(([name, server])=>[
4196
- name,
4197
- `http://localhost:${server.port}`
4198
- ]));
4199
- const hostOptions = {
4200
- rootPath: normalize(context.rootPath),
4201
- projects: projectRuntimeConfigs,
4202
- snapshot: {
4203
- updateSnapshot: options?.updateSnapshot ?? context.snapshotManager.options.updateSnapshot
4204
- },
4205
- runnerUrl: `http://localhost:${runtime.containerServer.port}`,
4206
- projectRunnerUrls,
4207
- wsPort,
4208
- debug: isDebug(),
4209
- rpcTimeout: maxTestTimeoutForRpc
4210
- };
4211
- const browserProviderProjects = browserProjects.map((project)=>({
4212
- rootPath: normalize(project.rootPath),
4213
- provider: project.normalizedConfig.browser.provider
4214
- }));
4215
- const implementationByProvider = new Map();
4216
- for (const browserProject of browserProviderProjects)if (!implementationByProvider.has(browserProject.provider)) implementationByProvider.set(browserProject.provider, getBrowserProviderImplementation(browserProject.provider));
4217
- let activeContainerPage = null;
4218
- let getHeadlessRunnerPageBySessionId;
4219
- const dispatchBrowserRpcRequest = async ({ request, target })=>{
4220
- const timeoutFallbackMs = maxTestTimeoutForRpc;
4221
- const provider = resolveProviderForTestPath({
4222
- testPath: request.testPath,
4223
- 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
+ });
4224
3442
  });
4225
- const implementation = implementationByProvider.get(provider);
4226
- if (!implementation) throw new Error(`Browser provider implementation not found: ${provider}`);
4227
- const runnerPage = target?.sessionId ? getHeadlessRunnerPageBySessionId?.(target.sessionId) : void 0;
4228
- if (target?.sessionId && !runnerPage) throw new Error(`Runner page session not found for browser dispatch: ${target.sessionId}`);
4229
- 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;
4230
3448
  try {
4231
- return await implementation.dispatchRpc({
4232
- containerPage: runnerPage ? void 0 : activeContainerPage ?? void 0,
4233
- runnerPage,
4234
- request,
4235
- timeoutFallbackMs
4236
- });
4237
- } catch (error) {
4238
- if (error instanceof Error) throw error.message;
4239
- throw String(error);
3449
+ msg = deserialize(data);
3450
+ } catch (e) {
3451
+ if (options.onGeneralError?.call(rpc, e) !== true) throw e;
3452
+ return;
4240
3453
  }
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
+ }
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;
3613
+ });
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
3625
+ ]));
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();
3653
+ };
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();
4241
3915
  };
4242
- runtime.dispatchHandlers.set(DISPATCH_NAMESPACE_BROWSER, async (dispatchRequest)=>{
4243
- const request = validateBrowserRpcRequest(dispatchRequest.args);
4244
- return dispatchBrowserRpcRequest({
4245
- request,
4246
- target: dispatchRequest.target
4247
- });
4248
- });
4249
- runtime.setContainerOptions(hostOptions);
4250
- const reporterResults = [];
4251
- const caseResults = [];
4252
- let fatalError = null;
4253
- const runnerSinks = new Map(browserProjects.map((project)=>[
4254
- project.name,
4255
- createRunnerEventSink(context, project.normalizedConfig)
4256
- ]));
4257
- const sinkForProjectName = (projectName)=>{
4258
- const sink = runnerSinks.get(projectName);
4259
- if (!sink) throw new Error(`No runner event sink for project "${projectName}"`);
4260
- return sink;
4261
- };
4262
- const silentConsoleController = createSilentConsoleController({
4263
- runtimeConfig: {
4264
- silent: context.normalizedConfig.silent,
4265
- disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept
4266
- },
4267
- emitInterceptedLog: (log)=>sinkForProjectName(log.project).onConsoleLog(log),
4268
- writeOriginalLog: ()=>{}
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
4269
3932
  });
4270
- const snapshotRpcMethods = {
4271
- async resolveSnapshotPath (testPath) {
4272
- return resolveSnapshotPathDefault(testPath, context.normalizedConfig.resolveSnapshotPath);
4273
- },
4274
- async readSnapshotFile (filepath) {
4275
- try {
4276
- return await promises.readFile(filepath, 'utf-8');
4277
- } catch {
4278
- return null;
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();
4279
3951
  }
4280
- },
4281
- async saveSnapshotFile (filepath, content) {
4282
- const dir = dirname(filepath);
4283
- await promises.mkdir(dir, {
4284
- recursive: true
4285
- });
4286
- await promises.writeFile(filepath, content, 'utf-8');
4287
- },
4288
- async removeSnapshotFile (filepath) {
4289
- try {
4290
- await promises.unlink(filepath);
4291
- } catch {}
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)));
4075
+ }
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'}`);
4292
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);
4293
4099
  };
4294
- const handleTestFileStart = async (payload)=>{
4295
- if (phaseTrackers) {
4296
- const tracker = new PhaseTracker({
4297
- trace: {
4298
- testPath: payload.testPath,
4299
- 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
+ }
4300
4158
  },
4301
- 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
+ })
4302
4169
  });
4303
- tracker.transition('prepare');
4304
- phaseTrackers.set(trackerKey(payload.projectName, 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();
4305
4225
  }
4306
- await sinkForProjectName(payload.projectName).onTestFileStart({
4307
- testId: getFileTaskId(payload.testPath),
4308
- testPath: payload.testPath,
4309
- project: payload.projectName,
4310
- tests: []
4226
+ };
4227
+ const makeSkippedFileResult = (file)=>({
4228
+ testId: getFileTaskId(file.testPath),
4229
+ status: 'skip',
4230
+ name: '',
4231
+ testPath: file.testPath,
4232
+ project: file.projectName,
4233
+ results: []
4311
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);
4312
4268
  };
4313
- const handleTestFileReady = async (payload)=>{
4314
- phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.transition('tests');
4315
- await sinkForProjectName(payload.project).onTestFileReady(payload);
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
+ });
4285
+ });
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;
4307
+ }
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
+ ]);
4317
+ });
4318
+ watchSession = createWatchSession(runScope);
4319
+ }
4320
+ const closeHeadlessRuntime = isWatchMode ? void 0 : async ()=>{
4321
+ sessionRegistry.clear();
4322
+ await destroyRuntime();
4316
4323
  };
4317
- const handleTestSuiteStart = async (payload)=>{
4318
- phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordSuiteStart(payload);
4319
- await sinkForProjectName(payload.project).onTestSuiteStart(payload);
4324
+ return {
4325
+ testTime,
4326
+ watchSession,
4327
+ close: closeHeadlessRuntime
4320
4328
  };
4321
- const handleTestSuiteResult = async (payload)=>{
4322
- phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordSuiteResult(payload);
4323
- await sinkForProjectName(payload.project).onTestSuiteResult(payload);
4324
- if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4325
- taskId: payload.testId,
4326
- status: payload.status,
4327
- taskParentNames: payload.parentNames,
4328
- taskType: 'suite',
4329
- testPath: payload.testPath
4330
- });
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')
4331
4359
  };
4332
- const handleTestCaseStart = async (payload)=>{
4333
- phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordCaseStart(payload);
4334
- sinkForProjectName(payload.project).onTestCaseStart(payload);
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
+ }
4335
4468
  };
4336
- const handleTestCaseResult = async (payload)=>{
4337
- caseResults.push(payload);
4338
- phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordCaseResult(payload);
4339
- await sinkForProjectName(payload.project).onTestCaseResult(payload);
4340
- if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4341
- taskId: payload.testId,
4342
- status: payload.status,
4343
- taskParentNames: payload.parentNames,
4344
- taskType: 'case',
4345
- testPath: payload.testPath
4346
- });
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
+ })
4514
+ };
4347
4515
  };
4348
- const handleTestFileComplete = async (payload)=>{
4349
- reporterResults.push(payload);
4350
- context.updateReporterResultState([
4351
- payload
4352
- ], payload.results);
4353
- if (phaseTrackers) {
4354
- const key = trackerKey(payload.project, payload.testPath);
4355
- const tracker = phaseTrackers.get(key);
4356
- if (tracker) {
4357
- tracker.end();
4358
- const events = tracker.getTraceEvents();
4359
- if (events) onTraceEvents?.(events);
4360
- phaseTrackers.delete(key);
4361
- }
4362
- }
4363
- if ('passed-only' === context.normalizedConfig.silent) silentConsoleController.flushBufferedLogsForTask({
4364
- taskId: payload.testId,
4365
- status: payload.status,
4366
- taskParentNames: payload.parentNames,
4367
- taskType: 'file',
4368
- testPath: payload.testPath
4369
- });
4370
- await sinkForProjectName(payload.project).onTestFileResult(payload);
4371
- if (isWatchMode && 'fail' === payload.status) ensureProcessExitCode(1);
4516
+ const getBrowserSourcemap = async (sourcePath)=>{
4517
+ const result = await resolveBrowserSourcemap(sourcePath);
4518
+ return result.handled ? result.sourcemap : null;
4372
4519
  };
4373
- const handleLog = async (payload)=>{
4374
- const log = {
4375
- content: payload.content,
4376
- name: getPrettyConsoleName(payload.level),
4377
- taskId: payload.taskId,
4378
- taskName: payload.taskName,
4379
- taskParentNames: payload.taskParentNames,
4380
- taskType: payload.taskType,
4381
- testPath: payload.testPath,
4382
- project: payload.projectName,
4383
- type: payload.type,
4384
- trace: payload.trace
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
4385
4537
  };
4386
- silentConsoleController.onConsoleLog(log);
4387
4538
  };
4388
- const handleFatal = async (payload)=>{
4389
- const error = new Error(payload.message);
4390
- error.stack = payload.stack;
4391
- fatalError = error;
4392
- if (isWatchMode) ensureProcessExitCode(1);
4393
- };
4394
- const runSnapshotRpc = async (request)=>{
4395
- switch(request.method){
4396
- case 'resolveSnapshotPath':
4397
- return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
4398
- case 'readSnapshotFile':
4399
- return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
4400
- case 'saveSnapshotFile':
4401
- return snapshotRpcMethods.saveSnapshotFile(request.args.filepath, request.args.content);
4402
- case 'removeSnapshotFile':
4403
- return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
4404
- default:
4405
- {
4406
- const _exhaustive = request;
4407
- return _exhaustive;
4408
- }
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?.();
4409
4546
  }
4410
4547
  };
4411
- const createDispatchRouter = (options)=>createHostDispatchRouter({
4412
- routerOptions: options,
4413
- runnerCallbacks: {
4414
- onTestFileStart: handleTestFileStart,
4415
- onTestFileReady: handleTestFileReady,
4416
- onTestSuiteStart: handleTestSuiteStart,
4417
- onTestSuiteResult: handleTestSuiteResult,
4418
- onTestCaseStart: handleTestCaseStart,
4419
- onTestCaseResult: handleTestCaseResult,
4420
- onTestFileComplete: handleTestFileComplete,
4421
- onLog: handleLog,
4422
- 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
4423
4580
  },
4424
- runSnapshotRpc,
4425
- extensionHandlers: runtime.dispatchHandlers,
4426
- onDuplicateNamespace: (namespace)=>{
4427
- logger.debug(`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`);
4428
- }
4429
- });
4430
- if (useHeadlessDirect) {
4431
- const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
4432
- const runLifecycle = new RunSessionLifecycle();
4433
- const sessionRegistry = new RunnerSessionRegistry();
4434
- getHeadlessRunnerPageBySessionId = (sessionId)=>sessionRegistry.getById(sessionId)?.page;
4435
- let dispatchRequestCounter = 0;
4436
- const nextDispatchRequestId = (namespace)=>`${namespace}-${++dispatchRequestCounter}`;
4437
- const closeContextSafely = async (browserContext)=>{
4438
- try {
4439
- await browserContext.close();
4440
- } catch {}
4441
- };
4442
- const cancelRun = async (run, waitForDone = true)=>{
4443
- await runLifecycle.cancel(run, {
4444
- waitForDone,
4445
- onCancel: async (session)=>{
4446
- await Promise.all(Array.from(session.contexts).map((browserContext)=>closeContextSafely(browserContext)));
4447
- }
4448
- });
4581
+ hasFailure: false,
4582
+ getSourcemap: getBrowserSourcemap,
4583
+ resolveSourcemap: resolveBrowserSourcemap
4449
4584
  };
4450
- const dispatchRouter = createDispatchRouter({
4451
- isRunTokenStale: (runToken)=>runLifecycle.isTokenStale(runToken),
4452
- onStale: (request)=>{
4453
- if ("runner" === request.namespace) logger.debug(`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`);
4454
- }
4585
+ };
4586
+ const writeEmptyLaunchExitCode = ()=>{
4587
+ process.exitCode = resolveEmptyLaunchExitCode(process.exitCode, {
4588
+ allowEmptyRun,
4589
+ isWatchMode,
4590
+ passWithNoTests: context.normalizedConfig.passWithNoTests
4455
4591
  });
4456
- const dispatchRunnerMessage = async (run, file, sessionId, message)=>{
4457
- const response = await dispatchRouter.dispatch({
4458
- requestId: nextDispatchRequestId("runner"),
4459
- runToken: run.token,
4460
- namespace: "runner",
4461
- method: message.type,
4462
- args: 'payload' in message ? message.payload : void 0,
4463
- target: {
4464
- sessionId,
4465
- testFile: file.testPath,
4466
- projectName: file.projectName
4467
- }
4468
- });
4469
- if (response.stale) return;
4470
- if (response.error) throw new Error(response.error);
4471
- };
4472
- const runSingleFile = async (run, file)=>{
4473
- if (run.cancelled || runLifecycle.isTokenStale(run.token)) return;
4474
- const viewport = viewportByProject.get(file.projectName);
4475
- const browserContext = await browser.newContext({
4476
- providerOptions: browserLaunchOptions.providerOptions,
4477
- viewport: viewport ?? null
4478
- });
4479
- run.contexts.add(browserContext);
4480
- let page = null;
4481
- let sessionId = null;
4482
- let settled = false;
4483
- let resolveDone = null;
4484
- const markDone = ()=>{
4485
- if (!settled) {
4486
- settled = true;
4487
- resolveDone?.();
4488
- }
4489
- };
4490
- const donePromise = new Promise((resolve)=>{
4491
- 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
4492
4619
  });
4493
- const crashDeferred = createDeferredPromise();
4494
- const onPageDead = (reason)=>{
4495
- if (settled || run.cancelled || !runLifecycle.isTokenActive(run.token)) return;
4496
- settled = true;
4497
- crashDeferred.resolve(reason);
4498
- };
4499
- try {
4500
- page = await browserContext.newPage();
4501
- page.on('crash', ()=>onPageDead(`Browser page crashed while running ${file.testPath}.`));
4502
- page.on('close', ()=>onPageDead(`Browser page closed unexpectedly while running ${file.testPath}.`));
4503
- const session = sessionRegistry.register({
4504
- testFile: file.testPath,
4505
- projectName: file.projectName,
4506
- runToken: run.token,
4507
- mode: 'headless-page',
4508
- context: browserContext,
4509
- page
4510
- });
4511
- sessionId = session.id;
4512
- await attachHeadlessRunnerTransport(page, {
4513
- onDispatchMessage: async (message)=>{
4514
- try {
4515
- await dispatchRunnerMessage(run, file, session.id, message);
4516
- if ('file-complete' === message.type || 'complete' === message.type) markDone();
4517
- else if ('fatal' === message.type) {
4518
- markDone();
4519
- await cancelRun(run, false);
4520
- }
4521
- } catch (error) {
4522
- const formatted = toError(error);
4523
- await handleFatal({
4524
- message: formatted.message,
4525
- stack: formatted.stack
4526
- });
4527
- markDone();
4528
- await cancelRun(run, false);
4529
- }
4530
- },
4531
- onDispatchRpc: async (request)=>dispatchRouter.dispatch({
4532
- ...request,
4533
- runToken: run.token,
4534
- target: {
4535
- sessionId: session.id,
4536
- testFile: file.testPath,
4537
- projectName: file.projectName,
4538
- ...request.target
4539
- }
4540
- })
4541
- });
4542
- const inlineOptions = {
4543
- ...hostOptions,
4544
- snapshot: {
4545
- updateSnapshot: context.snapshotManager.options.updateSnapshot
4546
- },
4547
- testFile: file.testPath,
4548
- runId: `${run.token}:${session.id}`
4549
- };
4550
- const serializedOptions = serializeForInlineScript(inlineOptions);
4551
- await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
4552
- const projectServer = runtime.projectServers.get(file.projectName);
4553
- if (!projectServer) throw new Error(`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`);
4554
- await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
4555
- waitUntil: 'load'
4556
- });
4557
- const state = await Promise.race([
4558
- donePromise.then(()=>({
4559
- type: 'done'
4560
- })),
4561
- crashDeferred.promise.then((reason)=>({
4562
- type: 'crash',
4563
- reason
4564
- })),
4565
- run.cancelSignal.then(()=>({
4566
- type: 'cancelled'
4567
- }))
4568
- ]);
4569
- if ('cancelled' === state.type) return;
4570
- if ('crash' === state.type && runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4571
- await handleFatal({
4572
- message: state.reason
4573
- });
4574
- await cancelRun(run, false);
4575
- }
4576
- } catch (error) {
4577
- if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
4578
- const formatted = toError(error);
4579
- await handleFatal({
4580
- message: formatted.message,
4581
- stack: formatted.stack
4582
- });
4583
- await cancelRun(run, false);
4584
- }
4585
- } finally{
4586
- if (page) try {
4587
- await page.close();
4588
- } catch {}
4589
- if (sessionId) sessionRegistry.deleteById(sessionId);
4590
- run.contexts.delete(browserContext);
4591
- await closeContextSafely(browserContext);
4592
- }
4593
- };
4594
- const makeSkippedFileResult = (file)=>({
4595
- testId: getFileTaskId(file.testPath),
4596
- status: 'skip',
4597
- name: '',
4598
- testPath: file.testPath,
4599
- project: file.projectName,
4600
- results: []
4620
+ } catch (error) {
4621
+ return failWithError(error, async ()=>{
4622
+ await promises.rm(tempDir, {
4623
+ recursive: true,
4624
+ force: true
4625
+ }).catch(()=>{});
4601
4626
  });
4602
- const runFilesWithPool = async (files)=>{
4603
- if (0 === files.length) return;
4604
- const previous = runLifecycle.activeSession;
4605
- if (previous) await cancelRun(previous);
4606
- const run = runLifecycle.createSession((token)=>({
4607
- ...createRunSession(token),
4608
- contexts: new Set()
4609
- }));
4610
- const queue = [
4611
- ...files
4612
- ];
4613
- const concurrency = getHeadlessConcurrency(context, queue.length);
4614
- const bail = context.normalizedConfig.bail;
4615
- const worker = async ()=>{
4616
- while(queue.length > 0 && !run.cancelled && runLifecycle.isTokenActive(run.token)){
4617
- if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
4618
- let skipped = queue.shift();
4619
- while(skipped){
4620
- await handleTestFileComplete(makeSkippedFileResult(skipped));
4621
- skipped = queue.shift();
4622
- }
4623
- return;
4624
- }
4625
- const next = queue.shift();
4626
- if (!next) return;
4627
- await runSingleFile(run, next);
4628
- }
4629
- };
4630
- run.done = Promise.all(Array.from({
4631
- length: Math.min(queue.length, Math.max(concurrency, 1))
4632
- }, ()=>worker())).then(()=>{});
4633
- await run.done;
4634
- runLifecycle.clearIfActive(run);
4635
- };
4636
- const latestRerunScheduler = createHeadlessLatestRerunScheduler({
4637
- getActiveRun: ()=>runLifecycle.activeSession,
4638
- isRunCancelled: (run)=>run.cancelled,
4639
- invalidateActiveRun: ()=>{
4640
- runLifecycle.invalidateActiveToken();
4641
- },
4642
- interruptActiveRun: async (run)=>{
4643
- 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
4644
4659
  },
4645
- runFiles: async (files)=>{
4646
- prepareWatchRerunState(context);
4647
- 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)=>{
4648
4668
  const rerunStartTime = Date.now();
4649
- const fatalErrorBeforeRun = fatalError;
4669
+ const fatalErrorBeforeRun = fatalErrorRef.current;
4650
4670
  let rerunError;
4651
4671
  try {
4652
- await runFilesWithPool(files);
4672
+ await execute(testPaths);
4653
4673
  } catch (error) {
4654
4674
  rerunError = toError(error);
4655
- throw error;
4656
- } finally{
4657
- const testTime = Math.max(0, Date.now() - rerunStartTime);
4658
- const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
4659
- await finalizeWatchRerun({
4660
- rerunTestPaths: files.map((file)=>file.testPath),
4661
- testTime,
4662
- unhandledErrors: rerunError ? [
4663
- rerunError
4664
- ] : rerunFatalError ? [
4665
- rerunFatalError
4666
- ] : void 0
4667
- });
4668
- logWatchReadyMessage(context, enableCliShortcuts);
4669
- }
4670
- },
4671
- onError: async (error)=>{
4672
- const formatted = toError(error);
4673
- await handleFatal({
4674
- message: formatted.message,
4675
- stack: formatted.stack
4675
+ }
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
4676
4685
  });
4677
4686
  },
4678
- onInterrupt: (run)=>{
4679
- 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();
4680
4692
  }
4681
4693
  });
4682
- awaitHeadlessRerunIdle = ()=>latestRerunScheduler.whenIdle();
4683
- const testStart = Date.now();
4684
- await runFilesWithPool(allTestFiles);
4685
- const testTime = Date.now() - testStart;
4686
- if (isWatchMode) triggerRerun = async ()=>{
4687
- const newProjectEntries = await collectProjectEntries(context);
4688
- const rerunPlan = planWatchRerun({
4689
- projectEntries: newProjectEntries,
4690
- previousTestFiles: watchState.lastTestFiles,
4691
- affectedTestFiles: drainPendingAffectedTestFiles(watchState)
4692
- });
4693
- if (rerunPlan.filesChanged) {
4694
- const deletedTestPaths = collectDeletedTestPaths(watchState.lastTestFiles, rerunPlan.currentTestFiles);
4695
- if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
4696
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
4697
- if (0 === rerunPlan.currentTestFiles.length) {
4698
- await latestRerunScheduler.enqueueLatest([]);
4699
- logger.log(color.cyan('No browser test files remain after update.\n'));
4700
- logWatchReadyMessage(context, enableCliShortcuts);
4701
- return;
4702
- }
4703
- logger.log(color.cyan(`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`));
4704
- await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
4705
- return;
4706
- }
4707
- if (0 === rerunPlan.affectedTestFiles.length) {
4708
- logger.log(color.cyan('No affected browser test files detected, skipping re-run.\n'));
4709
- logWatchReadyMessage(context, enableCliShortcuts);
4710
- return;
4711
- }
4712
- logger.log(color.cyan(`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`));
4713
- await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
4714
- };
4715
- const closeHeadlessRuntime = isWatchMode ? void 0 : async ()=>{
4716
- sessionRegistry.clear();
4717
- await destroyBrowserRuntime(runtime);
4718
- };
4719
- if (fatalError) return failWithError(fatalError, closeHeadlessRuntime);
4720
- const duration = {
4721
- 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,
4722
4702
  buildTime,
4723
- testTime
4724
- };
4725
- context.updateReporterResultState(reporterResults, caseResults);
4726
- const isFailure = reporterResults.some((result)=>'fail' === result.status);
4727
- if (isWatchMode && isFailure) ensureProcessExitCode(1);
4728
- const cycleCoverageMap = isWatchMode && coverageProvider ? buildBrowserCoverageMap(reporterResults, coverageProvider) : void 0;
4729
- const result = {
4730
- results: reporterResults,
4731
- testResults: caseResults,
4732
- duration,
4733
- hasFailure: isFailure,
4734
- getSourcemap: getBrowserSourcemap,
4735
- resolveSourcemap: resolveBrowserSourcemap,
4736
- close: closeHeadlessRuntime,
4737
- coverage: cycleCoverageMap,
4738
- watch: watchHandles
4739
- };
4740
- if (isWatchMode) try {
4741
- await notifyTestRunEnd({
4742
- duration,
4743
- coverage: cycleCoverageMap?.files().length ? cycleCoverageMap.toJSON() : void 0
4703
+ testTime: 0
4704
+ },
4705
+ hasFailure: false,
4706
+ getSourcemap: getBrowserSourcemap,
4707
+ resolveSourcemap: resolveBrowserSourcemap,
4708
+ close: ()=>destroyBrowserRuntime(runtime)
4709
+ };
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
4748
+ };
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;
4758
+ };
4759
+ const dispatchBrowserRpcRequest = async ({ request, target })=>{
4760
+ const timeoutFallbackMs = maxTestTimeoutForRpc;
4761
+ const provider = resolveProviderForTestPath({
4762
+ testPath: request.testPath,
4763
+ browserProjects: browserProviderProjects
4764
+ });
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
4744
4776
  });
4745
- } finally{
4746
- await closeHeadlessRuntime?.();
4747
- }
4748
- if (isWatchMode && triggerRerun) {
4749
- watchState.hooksEnabled = true;
4750
- logWatchReadyMessage(context, enableCliShortcuts);
4777
+ } catch (error) {
4778
+ if (error instanceof Error) throw error.message;
4779
+ throw String(error);
4751
4780
  }
4752
- return result;
4753
- }
4754
- let currentTestFiles = allTestFiles;
4755
- const RUNNER_FRAMES_READY_TIMEOUT_MS = 30000;
4756
- let currentRunnerFramesSignature = null;
4757
- const runnerFramesWaiters = new Map();
4758
- const createTestFilesSignature = (testFiles)=>JSON.stringify(testFiles.map((testFile)=>normalize(testFile)));
4759
- const markRunnerFramesReady = (testFiles)=>{
4760
- const signature = createTestFilesSignature(testFiles);
4761
- currentRunnerFramesSignature = signature;
4762
- const waiters = runnerFramesWaiters.get(signature);
4763
- if (!waiters) return;
4764
- runnerFramesWaiters.delete(signature);
4765
- for (const waiter of waiters)waiter();
4766
4781
  };
4767
- const waitForRunnerFramesReady = async (testFiles)=>{
4768
- const signature = createTestFilesSignature(testFiles);
4769
- if (currentRunnerFramesSignature === signature) return;
4770
- await new Promise((resolve, reject)=>{
4771
- const waiters = runnerFramesWaiters.get(signature) ?? new Set();
4772
- const cleanup = ()=>{
4773
- const currentWaiters = runnerFramesWaiters.get(signature);
4774
- if (!currentWaiters) return;
4775
- currentWaiters.delete(onReady);
4776
- if (0 === currentWaiters.size) runnerFramesWaiters.delete(signature);
4777
- };
4778
- const onReady = ()=>{
4779
- if (timeoutId) clearTimeout(timeoutId);
4780
- cleanup();
4781
- resolve();
4782
- };
4783
- const timeoutId = setTimeout(()=>{
4784
- cleanup();
4785
- reject(new Error(`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`));
4786
- }, RUNNER_FRAMES_READY_TIMEOUT_MS);
4787
- waiters.add(onReady);
4788
- runnerFramesWaiters.set(signature, waiters);
4789
- if (currentRunnerFramesSignature === signature) onReady();
4782
+ runtime.dispatchHandlers.set(DISPATCH_NAMESPACE_BROWSER, async (dispatchRequest)=>{
4783
+ const request = validateBrowserRpcRequest(dispatchRequest.args);
4784
+ return dispatchBrowserRpcRequest({
4785
+ request,
4786
+ target: dispatchRequest.target
4790
4787
  });
4788
+ });
4789
+ runtime.setContainerOptions(hostOptions);
4790
+ const reporterResults = [];
4791
+ const caseResults = [];
4792
+ const fatalErrorRef = {
4793
+ current: null
4791
4794
  };
4792
- const getTestFileInfo = (testFile)=>{
4793
- const normalizedTestFile = normalize(testFile);
4794
- const fileInfo = currentTestFiles.find((file)=>file.testPath === normalizedTestFile);
4795
- if (!fileInfo) throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
4796
- return fileInfo;
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;
4797
4803
  };
4798
- let containerContext;
4799
- let containerPage;
4800
- let isNewPage = false;
4801
- if (isWatchMode && runtime.containerPage && runtime.containerContext) {
4802
- containerContext = runtime.containerContext;
4803
- containerPage = runtime.containerPage;
4804
- logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
4805
- } else {
4806
- isNewPage = true;
4807
- containerContext = await browser.newContext({
4808
- providerOptions: browserLaunchOptions.providerOptions,
4809
- viewport: null
4810
- });
4811
- containerPage = await containerContext.newPage();
4812
- containerPage.on('popup', async (popup)=>{
4813
- await popup.close().catch(()=>{});
4814
- });
4815
- containerContext.on('page', async (page)=>{
4816
- if (page !== containerPage) await page.close().catch(()=>{});
4817
- });
4818
- if (isWatchMode) {
4819
- runtime.containerPage = containerPage;
4820
- runtime.containerContext = containerContext;
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 {}
4821
4834
  }
4822
- containerPage.on('console', (msg)=>{
4823
- const text = msg.text();
4824
- if (text.startsWith('[Container]') || text.startsWith('[Runner]')) logger.log(color.gray(`[Browser Console] ${text}`));
4835
+ };
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);
4847
+ }
4848
+ await sinkForProjectName(payload.projectName).onTestFileStart({
4849
+ testId: getFileTaskId(payload.testPath),
4850
+ testPath: payload.testPath,
4851
+ project: payload.projectName,
4852
+ tests: []
4853
+ });
4854
+ };
4855
+ const handleTestFileReady = async (payload)=>{
4856
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.transition('tests');
4857
+ await sinkForProjectName(payload.project).onTestFileReady(payload);
4858
+ };
4859
+ const handleTestSuiteStart = async (payload)=>{
4860
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordSuiteStart(payload);
4861
+ await sinkForProjectName(payload.project).onTestSuiteStart(payload);
4862
+ };
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
4825
4872
  });
4826
- }
4827
- activeContainerPage = containerPage;
4828
- const dispatchRouter = createDispatchRouter();
4829
- const headedReloadQueue = createHeadedSerialTaskQueue();
4830
- const pendingHeadedReloads = new Map();
4831
- let enqueueHeadedReload = async (_file, _testNamePattern)=>{
4832
- throw new Error('Headed reload queue is not initialized');
4833
4873
  };
4834
- const rejectPendingHeadedReload = (testPath, error, runId)=>{
4835
- const pending = pendingHeadedReloads.get(testPath);
4836
- if (!pending) return;
4837
- if (runId && pending.runId !== runId) return;
4838
- pendingHeadedReloads.delete(testPath);
4839
- pending.deferred.reject(error);
4874
+ const handleTestCaseStart = async (payload)=>{
4875
+ phaseTrackers?.get(trackerKey(payload.project, payload.testPath))?.recordCaseStart(payload);
4876
+ sinkForProjectName(payload.project).onTestCaseStart(payload);
4840
4877
  };
4841
- const rejectAllPendingHeadedReloads = (error)=>{
4842
- for (const [testPath, pending] of pendingHeadedReloads){
4843
- pendingHeadedReloads.delete(testPath);
4844
- pending.deferred.reject(error);
4845
- }
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
4888
+ });
4846
4889
  };
4847
- const registerPendingHeadedReload = (testPath, runId)=>{
4848
- const previousPending = pendingHeadedReloads.get(testPath);
4849
- if (previousPending) {
4850
- previousPending.deferred.reject(new Error(`Reload for "${testPath}" was superseded by a newer request.`));
4851
- pendingHeadedReloads.delete(testPath);
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);
4903
+ }
4852
4904
  }
4853
- const deferred = createDeferredPromise();
4854
- pendingHeadedReloads.set(testPath, {
4855
- runId,
4856
- deferred
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
4857
4911
  });
4858
- return deferred.promise;
4912
+ await sinkForProjectName(payload.project).onTestFileResult(payload);
4859
4913
  };
4860
- const resolvePendingHeadedReload = (testPath, runId)=>{
4861
- const pending = pendingHeadedReloads.get(testPath);
4862
- if (!pending) return;
4863
- if (runId && pending.runId !== runId) return void logger.debug(`[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`);
4864
- pendingHeadedReloads.delete(testPath);
4865
- pending.deferred.resolve();
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);
4866
4928
  };
4867
- const reloadTestFileAndWait = async (file, testNamePattern)=>{
4868
- let reloadAck;
4869
- try {
4870
- reloadAck = await rpcManager.reloadTestFile(file.testPath, testNamePattern);
4871
- await registerPendingHeadedReload(file.testPath, reloadAck.runId);
4872
- } catch (error) {
4873
- if (reloadAck?.runId) rejectPendingHeadedReload(file.testPath, toError(error), reloadAck.runId);
4874
- throw error;
4875
- }
4929
+ const handleFatal = async (payload)=>{
4930
+ const error = new Error(payload.message);
4931
+ error.stack = payload.stack;
4932
+ fatalErrorRef.current = error;
4876
4933
  };
4877
- const createRpcMethods = ()=>({
4878
- async rerunTest (testFile, testNamePattern) {
4879
- const projectName = context.normalizedConfig.name || 'project';
4880
- const relativePath = relative(context.rootPath, testFile);
4881
- const displayPath = `<${projectName}>/${relativePath}`;
4882
- logger.log(color.cyan(`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`));
4883
- await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
4884
- },
4885
- async getTestFiles () {
4886
- return currentTestFiles;
4887
- },
4888
- async onRunnerFramesReady (testFiles) {
4889
- markRunnerFramesReady(testFiles);
4890
- },
4891
- async onTestFileStart (payload) {
4892
- await handleTestFileStart(payload);
4893
- },
4894
- async onTestCaseResult (payload) {
4895
- await handleTestCaseResult(payload);
4896
- },
4897
- async onTestFileComplete (payload) {
4898
- try {
4899
- await handleTestFileComplete(payload);
4900
- resolvePendingHeadedReload(payload.testPath, payload.runId);
4901
- } catch (error) {
4902
- rejectPendingHeadedReload(payload.testPath, toError(error), payload.runId);
4903
- throw error;
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;
4904
4948
  }
4905
- },
4906
- async onLog (payload) {
4907
- await handleLog(payload);
4908
- },
4909
- async onFatal (payload) {
4910
- const error = new Error(payload.message);
4911
- error.stack = payload.stack;
4912
- rejectAllPendingHeadedReloads(error);
4913
- await handleFatal(payload);
4914
- },
4915
- async dispatch (request) {
4916
- return dispatchRouter.dispatch(request);
4917
- }
4918
- });
4919
- let rpcManager;
4920
- if (isWatchMode && runtime.rpcManager) {
4921
- rpcManager = runtime.rpcManager;
4922
- rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
4923
- const existingWs = rpcManager.currentWebSocket;
4924
- if (existingWs) rpcManager.reattach(existingWs);
4925
- } else {
4926
- rpcManager = new ContainerRpcManager(wss, createRpcMethods(), rejectAllPendingHeadedReloads);
4927
- if (isWatchMode) runtime.rpcManager = rpcManager;
4928
- }
4929
- if (isNewPage) {
4930
- const pagePath = '/';
4931
- const containerPort = runtime.containerServer.port;
4932
- await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
4933
- waitUntil: 'load'
4934
- });
4935
- logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`));
4936
- }
4937
- enqueueHeadedReload = async (file, testNamePattern)=>headedReloadQueue.enqueue(async ()=>{
4938
- if (fatalError) return;
4939
- await reloadTestFileAndWait(file, testNamePattern);
4940
- });
4941
- let testTime = 0;
4942
- if (currentTestFiles.length > 0) {
4943
- const testStart = Date.now();
4944
- try {
4945
- await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
4946
- for (const file of currentTestFiles){
4947
- await enqueueHeadedReload(file);
4948
- if (fatalError) break;
4949
- }
4950
- } catch (error) {
4951
- fatalError = fatalError ?? toError(error);
4952
- if (isWatchMode) ensureProcessExitCode(1);
4953
4949
  }
4954
- testTime = Date.now() - testStart;
4955
- }
4956
- if (isWatchMode) triggerRerun = async ()=>{
4957
- const refreshedHostOptions = {
4958
- ...hostOptions,
4959
- snapshot: {
4960
- updateSnapshot: context.snapshotManager.options.updateSnapshot
4950
+ };
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`);
4961
4968
  }
4962
- };
4963
- runtime.setContainerOptions(refreshedHostOptions);
4964
- const [, newProjectEntries] = await Promise.all([
4965
- rpcManager.updateHostConfig(refreshedHostOptions),
4966
- collectProjectEntries(context)
4967
- ]);
4968
- const rerunPlan = planWatchRerun({
4969
- projectEntries: newProjectEntries,
4970
- previousTestFiles: watchState.lastTestFiles,
4971
- affectedTestFiles: drainPendingAffectedTestFiles(watchState)
4972
4969
  });
4973
- if (rerunPlan.filesChanged) {
4974
- const deletedTestPaths = collectDeletedTestPaths(watchState.lastTestFiles, rerunPlan.currentTestFiles);
4975
- if (deletedTestPaths.length > 0) context.updateReporterResultState([], [], deletedTestPaths);
4976
- watchState.lastTestFiles = rerunPlan.currentTestFiles;
4977
- currentTestFiles = rerunPlan.currentTestFiles;
4978
- await rpcManager.notifyTestFileUpdate(currentTestFiles);
4979
- if (0 === currentTestFiles.length) {
4980
- logger.log(color.cyan('No browser test files remain after update.\n'));
4981
- logWatchReadyMessage(context, enableCliShortcuts);
4982
- return;
4983
- }
4984
- await waitForRunnerFramesReady(currentTestFiles.map((file)=>file.testPath));
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)
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
4985
4994
  }
4986
- if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
4987
- logger.log(color.cyan(`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`));
4988
- prepareWatchRerunState(context);
4989
- await notifyTestRunStart();
4990
- const rerunStartTime = Date.now();
4991
- const fatalErrorBeforeRun = fatalError;
4992
- let rerunError;
4993
- try {
4994
- for (const testFile of rerunPlan.normalizedAffectedTestFiles)await enqueueHeadedReload(getTestFileInfo(testFile));
4995
- } catch (error) {
4996
- rerunError = toError(error);
4997
- throw error;
4998
- } finally{
4999
- const testTime = Math.max(0, Date.now() - rerunStartTime);
5000
- const rerunFatalError = fatalError && fatalError !== fatalErrorBeforeRun ? fatalError : void 0;
5001
- await finalizeWatchRerun({
5002
- rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
5003
- testTime,
5004
- unhandledErrors: rerunError ? [
5005
- rerunError
5006
- ] : rerunFatalError ? [
5007
- rerunFatalError
5008
- ] : void 0
5009
- });
5010
- logWatchReadyMessage(context, enableCliShortcuts);
5011
- }
5012
- } else if (rerunPlan.filesChanged) logWatchReadyMessage(context, enableCliShortcuts);
5013
- else {
5014
- logger.log(color.cyan('Tests will be re-executed automatically\n'));
5015
- logWatchReadyMessage(context, enableCliShortcuts);
4995
+ }) : await createHeadedScheduler({
4996
+ ...schedulerDeps,
4997
+ runtime,
4998
+ handlers: {
4999
+ handleTestFileStart,
5000
+ handleTestCaseResult,
5001
+ handleTestFileComplete,
5002
+ handleLog,
5003
+ handleFatal
5016
5004
  }
5017
- };
5018
- const closeContainerRuntime = isWatchMode ? void 0 : async ()=>{
5019
- try {
5020
- await containerPage.close();
5021
- } catch {}
5022
- try {
5023
- await containerContext.close();
5024
- } catch {}
5025
- await destroyBrowserRuntime(runtime);
5026
- };
5027
- if (fatalError) return failWithError(fatalError, closeContainerRuntime);
5028
- const duration = {
5029
- totalTime: buildTime + testTime,
5030
- buildTime,
5031
- testTime
5032
- };
5005
+ });
5006
+ if (fatalErrorRef.current) return failWithError(fatalErrorRef.current, close);
5033
5007
  context.updateReporterResultState(reporterResults, caseResults);
5034
- const isFailure = reporterResults.some((result)=>'fail' === result.status);
5035
- if (isWatchMode && isFailure) ensureProcessExitCode(1);
5036
- const cycleCoverageMap = isWatchMode && coverageProvider ? buildBrowserCoverageMap(reporterResults, coverageProvider) : void 0;
5037
- const result = {
5008
+ watchState.hooksEnabled = isWatchMode;
5009
+ return {
5038
5010
  results: reporterResults,
5039
5011
  testResults: caseResults,
5040
- duration,
5041
- hasFailure: isFailure,
5012
+ duration: {
5013
+ totalTime: buildTime + testTime,
5014
+ buildTime,
5015
+ testTime
5016
+ },
5017
+ hasFailure: reporterResults.some((result)=>'fail' === result.status),
5042
5018
  getSourcemap: getBrowserSourcemap,
5043
5019
  resolveSourcemap: resolveBrowserSourcemap,
5044
- close: closeContainerRuntime,
5045
- coverage: cycleCoverageMap,
5046
- watch: watchHandles
5020
+ close,
5021
+ watchSession
5047
5022
  };
5048
- if (isWatchMode) try {
5049
- await notifyTestRunEnd({
5050
- duration,
5051
- coverage: cycleCoverageMap?.files().length ? cycleCoverageMap.toJSON() : void 0
5052
- });
5053
- } finally{
5054
- await closeContainerRuntime?.();
5055
- }
5056
- if (isWatchMode && triggerRerun) {
5057
- watchState.hooksEnabled = true;
5058
- logWatchReadyMessage(context, enableCliShortcuts);
5059
- }
5060
- return result;
5061
5023
  };
5062
5024
  const listBrowserTests = async (context, options)=>{
5063
5025
  const browserProjects = options?.projects ?? getBrowserProjects(context);
@@ -5116,7 +5078,8 @@ const listBrowserTests = async (context, options)=>{
5116
5078
  environmentName: project.environmentName,
5117
5079
  projectRoot: normalize(project.rootPath),
5118
5080
  runtimeConfig: serializableConfig(projectRuntimeConfig(project, {
5119
- envMode: 'static'
5081
+ envMode: 'static',
5082
+ envOverlay: options?.env
5120
5083
  })),
5121
5084
  viewport: project.normalizedConfig.browser.viewport
5122
5085
  }));
@@ -5249,8 +5212,11 @@ const emptyOutcome = ()=>({
5249
5212
  });
5250
5213
  async function createBrowserExecutor(context, options) {
5251
5214
  const { projects, coverageProvider, shardedEntries, freezeShardedEntries, filesOnly, allowEmptyRun, appliedModifyRstestConfigEnvironments } = options;
5215
+ const isWatchMode = 'watch' === context.command;
5252
5216
  let deferredClose;
5253
5217
  let inFlightCycle;
5218
+ let invalidationCallback;
5219
+ let watchSession;
5254
5220
  const foldOutcome = (result)=>{
5255
5221
  if (!result) return emptyOutcome();
5256
5222
  const map = buildBrowserCoverageMap(result.results, coverageProvider);
@@ -5274,6 +5240,15 @@ async function createBrowserExecutor(context, options) {
5274
5240
  projects,
5275
5241
  async init () {},
5276
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
+ }
5277
5252
  const cycle = runBrowserController(context, {
5278
5253
  projects,
5279
5254
  shardedEntries,
@@ -5282,24 +5257,37 @@ async function createBrowserExecutor(context, options) {
5282
5257
  appliedModifyRstestConfigEnvironments,
5283
5258
  onTraceEvents: opts.onTraceEvents,
5284
5259
  env: opts.env,
5285
- updateSnapshot: opts.updateSnapshot
5260
+ updateSnapshot: opts.updateSnapshot,
5261
+ onInvalidate: isWatchMode ? (hint)=>invalidationCallback?.(hint) : void 0
5286
5262
  });
5287
5263
  inFlightCycle = cycle;
5288
5264
  try {
5289
5265
  const result = await cycle;
5290
5266
  deferredClose = result?.close;
5267
+ watchSession = result?.watchSession;
5291
5268
  return foldOutcome(result);
5292
5269
  } finally{
5293
5270
  inFlightCycle = void 0;
5294
5271
  }
5295
5272
  },
5296
- 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) {
5297
5284
  const pending = listBrowserTests(context, {
5298
5285
  projects,
5299
5286
  shardedEntries,
5300
5287
  freezeShardedEntries,
5301
5288
  filesOnly,
5302
- appliedModifyRstestConfigEnvironments
5289
+ appliedModifyRstestConfigEnvironments,
5290
+ env: opts.env
5303
5291
  });
5304
5292
  inFlightCycle = pending;
5305
5293
  try {
@@ -5314,6 +5302,11 @@ async function createBrowserExecutor(context, options) {
5314
5302
  },
5315
5303
  async close () {
5316
5304
  if (inFlightCycle) await inFlightCycle.catch(()=>void 0);
5305
+ if (isWatchMode) {
5306
+ watchSession = void 0;
5307
+ await cleanupWatchRuntime();
5308
+ return;
5309
+ }
5317
5310
  const close = deferredClose;
5318
5311
  deferredClose = void 0;
5319
5312
  await close?.();