doc-detective 4.38.0 → 4.41.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/agents/adapters/claude-code.js +1 -2
  4. package/dist/agents/adapters/claude-code.js.map +1 -1
  5. package/dist/agents/adapters/codex.d.ts.map +1 -1
  6. package/dist/agents/adapters/codex.js +3 -4
  7. package/dist/agents/adapters/codex.js.map +1 -1
  8. package/dist/agents/adapters/copilot-cli.d.ts.map +1 -1
  9. package/dist/agents/adapters/copilot-cli.js +1 -2
  10. package/dist/agents/adapters/copilot-cli.js.map +1 -1
  11. package/dist/agents/adapters/gemini-cli.d.ts.map +1 -1
  12. package/dist/agents/adapters/gemini-cli.js +1 -2
  13. package/dist/agents/adapters/gemini-cli.js.map +1 -1
  14. package/dist/agents/adapters/opencode.d.ts.map +1 -1
  15. package/dist/agents/adapters/opencode.js +1 -2
  16. package/dist/agents/adapters/opencode.js.map +1 -1
  17. package/dist/agents/adapters/qwen-code.d.ts.map +1 -1
  18. package/dist/agents/adapters/qwen-code.js +1 -2
  19. package/dist/agents/adapters/qwen-code.js.map +1 -1
  20. package/dist/agents/fetcher.d.ts.map +1 -1
  21. package/dist/agents/fetcher.js +1 -2
  22. package/dist/agents/fetcher.js.map +1 -1
  23. package/dist/common/src/ajv.d.ts +30 -0
  24. package/dist/common/src/ajv.d.ts.map +1 -0
  25. package/dist/common/src/ajv.js +131 -0
  26. package/dist/common/src/ajv.js.map +1 -0
  27. package/dist/common/src/detectTests.d.ts +2 -18
  28. package/dist/common/src/detectTests.d.ts.map +1 -1
  29. package/dist/common/src/detectTests.js +8 -77
  30. package/dist/common/src/detectTests.js.map +1 -1
  31. package/dist/common/src/schemas/referenced.d.ts +12 -0
  32. package/dist/common/src/schemas/referenced.d.ts.map +1 -0
  33. package/dist/common/src/schemas/referenced.js +10 -0
  34. package/dist/common/src/schemas/referenced.js.map +1 -0
  35. package/dist/common/src/schemas/schemas.json +2 -2
  36. package/dist/common/src/schemas/schemasReferenced.json +14871 -0
  37. package/dist/common/src/schemas/validators.cjs +509 -0
  38. package/dist/common/src/schemas/validatorsCheck.cjs +2 -0
  39. package/dist/common/src/textUtils.d.ts +25 -0
  40. package/dist/common/src/textUtils.d.ts.map +1 -0
  41. package/dist/common/src/textUtils.js +84 -0
  42. package/dist/common/src/textUtils.js.map +1 -0
  43. package/dist/common/src/types/generated/annotate_v3.d.ts.map +1 -1
  44. package/dist/common/src/types/generated/annotation_v3.d.ts.map +1 -1
  45. package/dist/common/src/types/generated/click_v3.d.ts +153 -0
  46. package/dist/common/src/types/generated/click_v3.d.ts.map +1 -1
  47. package/dist/common/src/types/generated/config_v3.d.ts +13753 -207
  48. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  49. package/dist/common/src/types/generated/dragAndDrop_v3.d.ts +72 -0
  50. package/dist/common/src/types/generated/dragAndDrop_v3.d.ts.map +1 -1
  51. package/dist/common/src/types/generated/find_v3.d.ts +632 -0
  52. package/dist/common/src/types/generated/find_v3.d.ts.map +1 -1
  53. package/dist/common/src/types/generated/goTo_v3.d.ts +31 -0
  54. package/dist/common/src/types/generated/goTo_v3.d.ts.map +1 -1
  55. package/dist/common/src/types/generated/httpRequest_v3.d.ts +181 -0
  56. package/dist/common/src/types/generated/httpRequest_v3.d.ts.map +1 -1
  57. package/dist/common/src/types/generated/loadCookie_v3.d.ts +30 -0
  58. package/dist/common/src/types/generated/loadCookie_v3.d.ts.map +1 -1
  59. package/dist/common/src/types/generated/report_v3.d.ts +28234 -321
  60. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  61. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +42281 -822
  62. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  63. package/dist/common/src/types/generated/saveCookie_v3.d.ts +35 -0
  64. package/dist/common/src/types/generated/saveCookie_v3.d.ts.map +1 -1
  65. package/dist/common/src/types/generated/screenshot_v3.d.ts +45 -0
  66. package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
  67. package/dist/common/src/types/generated/spec_v3.d.ts +28188 -275
  68. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  69. package/dist/common/src/types/generated/startSurface_v3.d.ts +63 -1
  70. package/dist/common/src/types/generated/startSurface_v3.d.ts.map +1 -1
  71. package/dist/common/src/types/generated/step_v3.d.ts +5186 -523
  72. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  73. package/dist/common/src/types/generated/test_v3.d.ts +13268 -3582
  74. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  75. package/dist/common/src/types/generated/type_v3.d.ts +62 -0
  76. package/dist/common/src/types/generated/type_v3.d.ts.map +1 -1
  77. package/dist/common/src/types/generated/waitUntil_v3.d.ts +62 -0
  78. package/dist/common/src/types/generated/waitUntil_v3.d.ts.map +1 -1
  79. package/dist/common/src/validate.d.ts +1 -1
  80. package/dist/common/src/validate.d.ts.map +1 -1
  81. package/dist/common/src/validate.js +29 -75
  82. package/dist/common/src/validate.js.map +1 -1
  83. package/dist/core/appium.d.ts +3 -2
  84. package/dist/core/appium.d.ts.map +1 -1
  85. package/dist/core/appium.js +63 -89
  86. package/dist/core/appium.js.map +1 -1
  87. package/dist/core/appiumReuse.d.ts +43 -0
  88. package/dist/core/appiumReuse.d.ts.map +1 -0
  89. package/dist/core/appiumReuse.js +226 -0
  90. package/dist/core/appiumReuse.js.map +1 -0
  91. package/dist/core/config.d.ts +10 -2
  92. package/dist/core/config.d.ts.map +1 -1
  93. package/dist/core/config.js +37 -7
  94. package/dist/core/config.js.map +1 -1
  95. package/dist/core/detectTests.d.ts.map +1 -1
  96. package/dist/core/detectTests.js +51 -17
  97. package/dist/core/detectTests.js.map +1 -1
  98. package/dist/core/files.d.ts.map +1 -1
  99. package/dist/core/files.js +3 -4
  100. package/dist/core/files.js.map +1 -1
  101. package/dist/core/index.d.ts +2 -2
  102. package/dist/core/index.d.ts.map +1 -1
  103. package/dist/core/index.js +132 -21
  104. package/dist/core/index.js.map +1 -1
  105. package/dist/core/integrations/heretto.d.ts +1 -1
  106. package/dist/core/integrations/heretto.d.ts.map +1 -1
  107. package/dist/core/integrations/heretto.js +7 -6
  108. package/dist/core/integrations/heretto.js.map +1 -1
  109. package/dist/core/lazyYaml.d.ts +2 -0
  110. package/dist/core/lazyYaml.d.ts.map +1 -0
  111. package/dist/core/lazyYaml.js +13 -0
  112. package/dist/core/lazyYaml.js.map +1 -0
  113. package/dist/core/resolveTests.js +1 -1
  114. package/dist/core/resolveTests.js.map +1 -1
  115. package/dist/core/telem.d.ts +1 -1
  116. package/dist/core/telem.d.ts.map +1 -1
  117. package/dist/core/telem.js +2 -11
  118. package/dist/core/telem.js.map +1 -1
  119. package/dist/core/telemLite.d.ts +10 -0
  120. package/dist/core/telemLite.d.ts.map +1 -0
  121. package/dist/core/telemLite.js +101 -0
  122. package/dist/core/telemLite.js.map +1 -0
  123. package/dist/core/tests/androidEmulator.d.ts +67 -4
  124. package/dist/core/tests/androidEmulator.d.ts.map +1 -1
  125. package/dist/core/tests/androidEmulator.js +366 -37
  126. package/dist/core/tests/androidEmulator.js.map +1 -1
  127. package/dist/core/tests/appSurface.d.ts +25 -6
  128. package/dist/core/tests/appSurface.d.ts.map +1 -1
  129. package/dist/core/tests/appSurface.js +88 -52
  130. package/dist/core/tests/appSurface.js.map +1 -1
  131. package/dist/core/tests/browserSessions.d.ts +2 -1
  132. package/dist/core/tests/browserSessions.d.ts.map +1 -1
  133. package/dist/core/tests/browserSessions.js +7 -1
  134. package/dist/core/tests/browserSessions.js.map +1 -1
  135. package/dist/core/tests/checkLink.d.ts +1 -0
  136. package/dist/core/tests/checkLink.d.ts.map +1 -1
  137. package/dist/core/tests/checkLink.js +14 -5
  138. package/dist/core/tests/checkLink.js.map +1 -1
  139. package/dist/core/tests/closeSurface.d.ts.map +1 -1
  140. package/dist/core/tests/closeSurface.js +4 -3
  141. package/dist/core/tests/closeSurface.js.map +1 -1
  142. package/dist/core/tests/findStrategies.d.ts.map +1 -1
  143. package/dist/core/tests/findStrategies.js +153 -116
  144. package/dist/core/tests/findStrategies.js.map +1 -1
  145. package/dist/core/tests/geckoDriver.d.ts +16 -0
  146. package/dist/core/tests/geckoDriver.d.ts.map +1 -0
  147. package/dist/core/tests/geckoDriver.js +127 -0
  148. package/dist/core/tests/geckoDriver.js.map +1 -0
  149. package/dist/core/tests/goTo.d.ts.map +1 -1
  150. package/dist/core/tests/goTo.js +12 -33
  151. package/dist/core/tests/goTo.js.map +1 -1
  152. package/dist/core/tests/httpRequest.d.ts.map +1 -1
  153. package/dist/core/tests/httpRequest.js +4 -3
  154. package/dist/core/tests/httpRequest.js.map +1 -1
  155. package/dist/core/tests/iosSimulator.d.ts +4 -1
  156. package/dist/core/tests/iosSimulator.d.ts.map +1 -1
  157. package/dist/core/tests/iosSimulator.js +49 -1
  158. package/dist/core/tests/iosSimulator.js.map +1 -1
  159. package/dist/core/tests/mobileBrowser.d.ts +2 -1
  160. package/dist/core/tests/mobileBrowser.d.ts.map +1 -1
  161. package/dist/core/tests/mobileBrowser.js +5 -2
  162. package/dist/core/tests/mobileBrowser.js.map +1 -1
  163. package/dist/core/tests/runCode.d.ts +9 -1
  164. package/dist/core/tests/runCode.d.ts.map +1 -1
  165. package/dist/core/tests/runCode.js +58 -2
  166. package/dist/core/tests/runCode.js.map +1 -1
  167. package/dist/core/tests/saveScreenshot.d.ts +18 -2
  168. package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
  169. package/dist/core/tests/saveScreenshot.js +159 -4
  170. package/dist/core/tests/saveScreenshot.js.map +1 -1
  171. package/dist/core/tests/startRecording.d.ts.map +1 -1
  172. package/dist/core/tests/startRecording.js +6 -4
  173. package/dist/core/tests/startRecording.js.map +1 -1
  174. package/dist/core/tests/startSurface.d.ts.map +1 -1
  175. package/dist/core/tests/startSurface.js +8 -2
  176. package/dist/core/tests/startSurface.js.map +1 -1
  177. package/dist/core/tests.d.ts +21 -7
  178. package/dist/core/tests.d.ts.map +1 -1
  179. package/dist/core/tests.js +372 -121
  180. package/dist/core/tests.js.map +1 -1
  181. package/dist/core/utils.d.ts +68 -3
  182. package/dist/core/utils.d.ts.map +1 -1
  183. package/dist/core/utils.js +551 -18
  184. package/dist/core/utils.js.map +1 -1
  185. package/dist/core/verification.d.ts +17 -0
  186. package/dist/core/verification.d.ts.map +1 -0
  187. package/dist/core/verification.js +850 -0
  188. package/dist/core/verification.js.map +1 -0
  189. package/dist/debug/appium.js +2 -2
  190. package/dist/debug/appium.js.map +1 -1
  191. package/dist/debug/findings.js +2 -2
  192. package/dist/debug/findings.js.map +1 -1
  193. package/dist/debug/tools.d.ts +8 -0
  194. package/dist/debug/tools.d.ts.map +1 -1
  195. package/dist/debug/tools.js +60 -22
  196. package/dist/debug/tools.js.map +1 -1
  197. package/dist/hints/context.d.ts.map +1 -1
  198. package/dist/hints/context.js +2 -2
  199. package/dist/hints/context.js.map +1 -1
  200. package/dist/index.cjs +25777 -4
  201. package/dist/reporters/htmlReporter.js +1 -1
  202. package/dist/runtime/androidInstaller.d.ts +45 -0
  203. package/dist/runtime/androidInstaller.d.ts.map +1 -1
  204. package/dist/runtime/androidInstaller.js +88 -5
  205. package/dist/runtime/androidInstaller.js.map +1 -1
  206. package/dist/runtime/browsers.d.ts +87 -7
  207. package/dist/runtime/browsers.d.ts.map +1 -1
  208. package/dist/runtime/browsers.js +308 -41
  209. package/dist/runtime/browsers.js.map +1 -1
  210. package/dist/runtime/cacheDir.d.ts +1 -1
  211. package/dist/runtime/cacheDir.d.ts.map +1 -1
  212. package/dist/runtime/cacheDir.js +5 -2
  213. package/dist/runtime/cacheDir.js.map +1 -1
  214. package/dist/runtime/heavyDeps.d.ts.map +1 -1
  215. package/dist/runtime/heavyDeps.js.map +1 -1
  216. package/dist/runtime/installCommand.d.ts.map +1 -1
  217. package/dist/runtime/installCommand.js +23 -0
  218. package/dist/runtime/installCommand.js.map +1 -1
  219. package/dist/runtime/loader.d.ts +13 -0
  220. package/dist/runtime/loader.d.ts.map +1 -1
  221. package/dist/runtime/loader.js +50 -0
  222. package/dist/runtime/loader.js.map +1 -1
  223. package/dist/runtime/wdaProducts.d.ts +12 -1
  224. package/dist/runtime/wdaProducts.d.ts.map +1 -1
  225. package/dist/runtime/wdaProducts.js +27 -2
  226. package/dist/runtime/wdaProducts.js.map +1 -1
  227. package/dist/runtime/windowsBash.d.ts +6 -1
  228. package/dist/runtime/windowsBash.d.ts.map +1 -1
  229. package/dist/runtime/windowsBash.js +45 -4
  230. package/dist/runtime/windowsBash.js.map +1 -1
  231. package/dist/utils.d.ts.map +1 -1
  232. package/dist/utils.js +19 -5
  233. package/dist/utils.js.map +1 -1
  234. package/package.json +43 -41
@@ -8,15 +8,14 @@ import fs from "node:fs";
8
8
  // optional package on disk — webdriverio is an optionalDependency that npm may
9
9
  // skip on a CI runner, and a hard compile-time type reference would otherwise
10
10
  // turn that skipped install into an intermittent build failure.
11
- import { loadHeavyDep, resolveHeavyDepPath } from "../runtime/loader.js";
11
+ import { loadHeavyDep, prefetchHeavyDep, resolveHeavyDepPath } from "../runtime/loader.js";
12
12
  import { resolveTheme } from "./annotations/model.js";
13
13
  import { annotate, pruneExpired, renderLayer } from "./tests/annotate.js";
14
14
  import { requiredBrowserAssets, ensureBrowserInstalled, } from "../runtime/browsers.js";
15
15
  // Single source of truth for browser/driver-requiring step keys.
16
16
  import { BROWSER_STEP_KEYS as driverActions, startSurfaceDescriptors, stepOpensBrowserSurface, stepTargetsProcessSurface, stepIsSurfacelessInteraction, testHasNonBrowserSurfaceSignal, } from "../runtime/browserStepKeys.js";
17
17
  import os from "node:os";
18
- import { log, logLevelEnabled, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, classifyContextRetry, realizeViewport, isViewportFloored, } from "./utils.js";
19
- import axios from "axios";
18
+ import { log, logLevelEnabled, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, classifyContextRetry, guardBlankDocumentNavigation, applyBrowserSize, isViewportFloored, loadAxios, } from "./utils.js";
20
19
  import { instantiateCursor } from "./tests/moveTo.js";
21
20
  import { goTo } from "./tests/goTo.js";
22
21
  import { findElement } from "./tests/findElement.js";
@@ -37,26 +36,28 @@ import { httpRequest } from "./tests/httpRequest.js";
37
36
  import { clickElement } from "./tests/click.js";
38
37
  import { runCode } from "./tests/runCode.js";
39
38
  import { closeSurface } from "./tests/closeSurface.js";
40
- import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, APP_DRIVER_PLATFORMS, probeIosToolchain, } from "./tests/appSurface.js";
39
+ import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, createAppServerPool, APP_DRIVER_PLATFORMS, probeIosToolchain, } from "./tests/appSurface.js";
41
40
  import { startSurfaceStep } from "./tests/startSurface.js";
42
41
  import { createActiveSurfaceTracker, } from "./tests/activeSurface.js";
43
42
  import { isMobileTargetPlatform } from "./tests/mobilePlatform.js";
43
+ import { GECKODRIVER_EXECUTABLE_ARGS, applyDriverOptions, } from "./tests/geckoDriver.js";
44
44
  import { mobileBrowserGate, buildMobileBrowserCapabilities, CHROMEDRIVER_AUTODOWNLOAD_ARGS, } from "./tests/mobileBrowser.js";
45
45
  import { planWarmTasks, executeWarmTasks, raceBootInitiation, wrapInitiationEffects, RUNTIME_INSTALL_RESOURCE, } from "./warmPhase.js";
46
46
  import { locateManagedWda } from "../runtime/wdaProducts.js";
47
47
  import { getCacheDir } from "../runtime/cacheDir.js";
48
48
  import { detectAndroidSdk } from "../runtime/androidSdk.js";
49
49
  import { hostAbi, listInstalledSystemImages, installAndroid, } from "../runtime/androidInstaller.js";
50
- import { buildAcquireDeviceDeps, checkEmulatorAcceleration, hostHasKvm, planDeviceAcquisition, planAndroidToolchain, normalizeDeviceDescriptor, acquireDevice, createDeviceRegistry, teardownDeviceRegistry, } from "./tests/androidEmulator.js";
51
- import { buildAcquireSimulatorDeps, planSimulatorAcquisition, acquireSimulator, createSimulatorRegistry, teardownSimulatorRegistry, } from "./tests/iosSimulator.js";
50
+ import { buildAcquireDeviceDeps, findUia2Helpers, checkEmulatorAcceleration, hostHasKvm, planDeviceAcquisition, planAndroidToolchain, normalizeDeviceDescriptor, acquireDevice, createDeviceRegistry, teardownDeviceRegistry, shutdownEmulator, } from "./tests/androidEmulator.js";
51
+ import { buildAcquireSimulatorDeps, planSimulatorAcquisition, acquireSimulator, createSimulatorRegistry, teardownSimulatorRegistry, iosMajorOfRuntime, } from "./tests/iosSimulator.js";
52
52
  import { runBrowserScript } from "./tests/runBrowserScript.js";
53
53
  import { dragAndDropElement } from "./tests/dragAndDrop.js";
54
54
  import { createSessionRegistry, registerSession, activeDriver, sweepSessions, } from "./tests/browserSessions.js";
55
55
  import path from "node:path";
56
56
  import { spawn } from "node:child_process";
57
57
  import { randomUUID } from "node:crypto";
58
- import { setAppiumHome } from "./appium.js";
59
- import { contentHash } from "../common/src/detectTests.js";
58
+ import { setAppiumHome, appiumHomeForDriverPath, useDriversArgs, browserPoolDriverPackages, } from "./appium.js";
59
+ import { reuseAppiumEnabled, appiumServerKey, createServerCache, takeServer, handBackServer, probeStatus, listSessions, allowSessionDiscovery, APPIUM_WATCHDOG_IMPORT, } from "./appiumReuse.js";
60
+ import { contentHash } from "../common/src/textUtils.js";
60
61
  import { resolveExpression } from "./expressions.js";
61
62
  import { evaluateCustomAssertions, evaluateGuard, guardReferencesSteps, customAssertionsReferenceSteps, resolveStepRouting, resolveTestRouting, computeRetryDelay, buildConditionContext, } from "./routing.js";
62
63
  import { getEnvironment, getAvailableApps, clearAppCache, resolveConcurrentRunners, } from "./config.js";
@@ -65,8 +66,26 @@ import http from "node:http";
65
66
  import https from "node:https";
66
67
  import { fileURLToPath } from "node:url";
67
68
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
68
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, withChromedriverPort, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, resolveRetryPolicy, runContextWithRetries, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, jobDisplayResources, buildWarmPlanDeps, warmBrowserInstall, prefetchMobileChromedriver, appiumIsReady, };
69
- // exports.driverStart = driverStart;
69
+ // Idle browser-pool Appium servers kept between runs in this process, only
70
+ // when DOC_DETECTIVE_REUSE_APPIUM is set (ADR 01125). Empty otherwise.
71
+ // killTree is a hoisted function declaration, called only when a server dies.
72
+ // Background kill failures log at debug under the last reusing run's config.
73
+ // Used only for that log line, so last-writer-wins across concurrent runs is fine.
74
+ // Only logLevel is read (by log()).
75
+ let lastReuseConfig;
76
+ const appiumServerCache = createServerCache({
77
+ kill: (server) => killTree(server.process?.pid),
78
+ onKillError: (server, err) => log(lastReuseConfig ?? {}, "debug", `Failed to close idle Appium server on port ${server.port}: ${err?.message ?? err}`),
79
+ idleMs: 60000,
80
+ maxIdle: 4,
81
+ });
82
+ // The runner's appiumIsReady call sites poll at this interval. A refused
83
+ // /status on loopback fails in about a millisecond, so a tighter poll costs
84
+ // nothing and trims the lag between Appium coming up and the run noticing
85
+ // (ADR 01124). appiumIsReady's own 250ms default stays for callers that don't
86
+ // pass `pollMs`.
87
+ const APPIUM_READY_POLL_MS = 50;
88
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, withDriverPort, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, resolveRetryPolicy, runContextWithRetries, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, appiumServerCache, jobDisplayResources, buildWarmPlanDeps, warmBrowserInstall, prefetchMobileChromedriver, appiumIsReady, APPIUM_READY_POLL_MS, driverStart, };
70
89
  // Browser names getDriverCapabilities knows how to build caps for. `safari` is
71
90
  // rewritten to `webkit` during context resolution, so both appear here.
72
91
  const KNOWN_BROWSERS = ["firefox", "chrome", "safari", "webkit"];
@@ -212,6 +231,18 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
212
231
  "appium:newCommandTimeout": 600, // 10 minutes
213
232
  browserName: "MozillaFirefox",
214
233
  "wdio:enforceWebDriverClassic": true, // Disable BiDi, use classic mode
234
+ // Pin the geckodriver Doc Detective manages. appium-geckodriver
235
+ // resolves its binary from this capability or `which geckodriver` and
236
+ // nothing else, so without it the managed install in the browsers cache
237
+ // (version-suffixed, off PATH) can never be used — Firefox then only
238
+ // works where some other geckodriver happens to be on PATH, which is
239
+ // why containers fail and GitHub-hosted runners don't. The pool's
240
+ // servers always carry GECKODRIVER_EXECUTABLE_ARGS, which is what makes
241
+ // this insecure-gated capability legal to send. Omitted when no path
242
+ // was resolved, leaving the PATH lookup as the Layer 4 fallback.
243
+ ...(firefox.driver
244
+ ? { "appium:geckodriverExecutable": firefox.driver }
245
+ : {}),
215
246
  "moz:firefoxOptions": {
216
247
  // Reference: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions
217
248
  args,
@@ -309,17 +340,26 @@ function getDriverCapabilities({ runnerDetails, name, options }) {
309
340
  // the other's connection is REFUSED, surfacing later as a mid-session
310
341
  // `ECONNREFUSED 127.0.0.1:9515` when a command proxies to the wrong/dead
311
342
  // chromedriver (ADR 01039). Assigning a unique free port per session removes the
312
- // collision. Gecko/Safari are unaffected — geckodriver auto-selects a free
313
- // `systemPort` from a range, and Safari has no such port — so this only touches
314
- // Chromium caps, leaving every other engine's caps byte-identical. An explicit
315
- // `appium:chromedriverPort` (a caller opting into a fixed port) is preserved.
316
- function withChromedriverPort(capabilities, port) {
317
- if (!capabilities ||
318
- capabilities["appium:automationName"] !== "Chromium" ||
319
- capabilities["appium:chromedriverPort"] !== undefined) {
320
- return capabilities;
321
- }
322
- return { ...capabilities, "appium:chromedriverPort": port };
343
+ // collision. Gecko races the same way, just less often: appium-geckodriver
344
+ // picks the lowest free port in 5200..5300 for `systemPort` but only reserves
345
+ // it once geckodriver binds, so two concurrent Appium servers can both pick
346
+ // 5200 and one proxies into the other's geckodriver session ("Got unexpected
347
+ // session id"; ADR 01105). Safari has no such port, so its caps (and every
348
+ // other engine's) stay byte-identical. An explicit port capability (a caller
349
+ // opting into a fixed port) is preserved.
350
+ const DRIVER_PORT_CAPABILITY = {
351
+ Chromium: "appium:chromedriverPort",
352
+ Gecko: "appium:systemPort",
353
+ };
354
+ // The port capability these caps still need, or undefined when the engine has
355
+ // none or the caller already pinned one.
356
+ function driverPortCapability(capabilities) {
357
+ const key = DRIVER_PORT_CAPABILITY[capabilities?.["appium:automationName"]];
358
+ return key && capabilities[key] === undefined ? key : undefined;
359
+ }
360
+ function withDriverPort(capabilities, port) {
361
+ const key = driverPortCapability(capabilities);
362
+ return key ? { ...capabilities, [key]: port } : capabilities;
323
363
  }
324
364
  function isDriverRequired({ test }) {
325
365
  let driverRequired = false;
@@ -495,6 +535,23 @@ function isBrowserRequired({ test }) {
495
535
  // (the goTo-opener sibling); app/process descriptors don't.
496
536
  stepOpensBrowserSurface(step)));
497
537
  }
538
+ // Whether any context in `specs` could read the available-apps list: it names a
539
+ // browser, targets a mobile platform, or has a step that drives a browser or
540
+ // opens/closes a surface. A deliberate superset of every availableApps reader in
541
+ // this file, so a run where it's false never consults the list and can skip
542
+ // detection (ADR 01133). Falls back to the test's steps only when a context has
543
+ // no steps array; an explicit `steps: []` is a context with nothing to run.
544
+ export function runNeedsAvailableApps(specs) {
545
+ const list = (v) => (Array.isArray(v) ? v : []);
546
+ const stepNeedsApps = (step) => Boolean(step) &&
547
+ typeof step === "object" &&
548
+ (step.startSurface !== undefined ||
549
+ step.closeSurface !== undefined ||
550
+ driverActions.some((key) => step[key] !== undefined));
551
+ return list(specs).some((spec) => list(spec?.tests).some((test) => list(test?.contexts).some((context) => Boolean(context?.browser) ||
552
+ isMobileTargetPlatform(context?.platform) ||
553
+ list(Array.isArray(context?.steps) ? context.steps : test?.steps).some(stepNeedsApps))));
554
+ }
498
555
  // Size the browser Appium server pool: the number of concurrent runner jobs
499
556
  // that will actually create a BROWSER session. App-only jobs are excluded —
500
557
  // they provision their own per-context Appium server (homed where the native
@@ -508,6 +565,12 @@ export function browserJobCount(jobs) {
508
565
  return jobs.filter((job) => isBrowserRequired({ test: job?.context }))
509
566
  .length;
510
567
  }
568
+ // Whether a job's context has a screenshot step: gates the sharp prefetch
569
+ // during Appium's boot (ADR 01135).
570
+ export function jobTakesScreenshot(job) {
571
+ const steps = job?.context?.steps;
572
+ return Array.isArray(steps) && steps.some((s) => s?.screenshot !== undefined);
573
+ }
511
574
  // Evaluate a context's `requires` capability gate. Returns null when the gate
512
575
  // is absent or fully met; otherwise a skip message naming every unmet
513
576
  // requirement, so the context lands as SKIPPED (the same non-failing outcome
@@ -685,7 +748,7 @@ async function androidContextPreflight({ config, context, clog, }) {
685
748
  };
686
749
  }
687
750
  }
688
- const deviceDeps = buildAcquireDeviceDeps(sdk, abi, (m) => clog("debug", m));
751
+ const deviceDeps = buildAcquireDeviceDeps(sdk, abi, (m) => clog("debug", m), uia2HelpersResolver(config));
689
752
  // Device plan: every device the test needs must resolve (reuse an existing
690
753
  // AVD/emulator, or create one from an installed image + Java). A gap SKIPs.
691
754
  const running = await deviceDeps.listRunning();
@@ -913,29 +976,6 @@ function driverSkipDiagnostic({ requestedName, platform, platformMatches, attemp
913
976
  msg += ` A present-but-broken driver (for example a partially downloaded ${driverHint}) can cause this; reinstall the driver or its browser.`;
914
977
  return msg;
915
978
  }
916
- // Realize a context's target viewport and return the size the page actually
917
- // rendered. Prefers viewport emulation (exact size, no window floor) and falls
918
- // back to window resizing, warning if the browser/OS floored the request. The
919
- // `// Confirm viewport size` intent is now realized by realizeViewport's
920
- // read-back.
921
- async function setViewportSize(context, driver, config = {}) {
922
- // Guard on POSITIVE dimensions (not truthiness): the schema doesn't floor
923
- // these, so a 0/negative/NaN value must not enter the resize path — matching
924
- // the startSurface browser descriptor's guard.
925
- const vw = Number(context.browser?.viewport?.width);
926
- const vh = Number(context.browser?.viewport?.height);
927
- if (vw > 0 || vh > 0) {
928
- const requested = {
929
- ...(vw > 0 ? { width: vw } : {}),
930
- ...(vh > 0 ? { height: vh } : {}),
931
- };
932
- // Attribute the warning so a multi-context run can tell which context's
933
- // viewport was floored.
934
- const label = `viewport for ${context.browser?.name ?? "browser"} on ${context.platform ?? "host"}`;
935
- return realizeViewport(driver, requested, config, label);
936
- }
937
- return undefined;
938
- }
939
979
  async function allowUnsafeSteps({ config }) {
940
980
  // If allowUnsafeSteps is set to true, return true
941
981
  if (config.allowUnsafeSteps === true)
@@ -958,6 +998,7 @@ async function allowUnsafeSteps({ config }) {
958
998
  }
959
999
  // Run specifications via API.
960
1000
  async function runViaApi({ resolvedTests, apiKey, config = {} }) {
1001
+ const axios = await loadAxios();
961
1002
  // Apply specFilter / testFilter before sending. Without this the API run
962
1003
  // path silently ignores --test / --spec, since the orchestration server
963
1004
  // sees the full unfiltered payload.
@@ -1126,10 +1167,16 @@ async function runSpecs({ resolvedTests }) {
1126
1167
  specs: [],
1127
1168
  };
1128
1169
  }
1129
- // Get runner details
1170
+ // Get runner details. Browser detection is skipped when no context can use
1171
+ // its result (ADR 01133); APPIUM_HOME is still set, as detection would have.
1172
+ const needsApps = runNeedsAvailableApps(specs);
1173
+ if (!needsApps) {
1174
+ log(config, "debug", "No context in this run uses a browser; skipping browser detection.");
1175
+ setAppiumHome({ cacheDir: config?.cacheDir });
1176
+ }
1130
1177
  const runnerDetails = {
1131
1178
  environment: getEnvironment(),
1132
- availableApps: await getAvailableApps({ config }),
1179
+ availableApps: needsApps ? await getAvailableApps({ config }) : [],
1133
1180
  allowUnsafeSteps: await allowUnsafeSteps({ config }),
1134
1181
  };
1135
1182
  // Set initial shorthand values
@@ -1310,6 +1357,9 @@ async function runSpecs({ resolvedTests }) {
1310
1357
  description: test.description,
1311
1358
  contentPath: test.contentPath,
1312
1359
  detectSteps: test.detectSteps,
1360
+ // Resolved by position via applyBadgeAnchoredTests (ADR 01141), not by name.
1361
+ badge: test.badge,
1362
+ location: test.location,
1313
1363
  contexts: new Array(test.contexts.length),
1314
1364
  };
1315
1365
  specReport.tests.push(testReport);
@@ -1436,15 +1486,16 @@ async function runSpecs({ resolvedTests }) {
1436
1486
  const xvfbProcesses = [];
1437
1487
  const useXvfbDisplays = concurrency.xvfbContexts.length > 0;
1438
1488
  let portToDisplay;
1489
+ // Set only when this run takes and hands back idle servers (ADR 01125).
1490
+ // Xvfb runs never reuse: their displays die with the run.
1491
+ let reuseKey;
1439
1492
  if (browserPoolJobCount > 0) {
1440
1493
  setAppiumHome({ cacheDir: config?.cacheDir });
1441
- // Resolve appium's actual JS entrypoint via `require.resolve` (shim
1442
- // node_modules first, runtime cache second) and invoke it with
1443
- // `node <entry>`. This sidesteps every shell-injection trap at once: no
1444
- // `.cmd` shim, so no Windows-requires-shell:true; no `npx`, so no PATH
1445
- // lookup; no user-controlled paths in a shell-interpreted string. Works
1446
- // for both `--omit=optional` users (appium in cache only) and default
1447
- // installs (appium in shim).
1494
+ // Resolve appium's actual JS entrypoint via `require.resolve` (always
1495
+ // from the runtime cache, the shared APPIUM_HOME — ADR 01111) and invoke
1496
+ // it with `node <entry>`. This sidesteps every shell-injection trap at
1497
+ // once: no `.cmd` shim, so no Windows-requires-shell:true; no `npx`, so no
1498
+ // PATH lookup; no user-controlled paths in a shell-interpreted string.
1448
1499
  const appiumEntry = resolveHeavyDepPath("appium", {
1449
1500
  cacheDir: config?.cacheDir,
1450
1501
  });
@@ -1465,16 +1516,54 @@ async function runSpecs({ resolvedTests }) {
1465
1516
  // await them together so the waits OVERLAP — total ≈ max(readiness)
1466
1517
  // instead of the sum of serial waits.
1467
1518
  const readinessWaits = [];
1519
+ // Load only the desktop browser drivers (ADR 01117): the shared home
1520
+ // also holds the mobile drivers, which roughly doubled startup.
1521
+ const poolDriverPackages = browserPoolDriverPackages(process.platform);
1522
+ const useDrivers = useDriversArgs(process.env.APPIUM_HOME, poolDriverPackages);
1523
+ // Every desktop pool server allows the geckodriver-path insecure
1524
+ // feature, not just the ones a Firefox context lands on: these servers
1525
+ // are shared across contexts and cross-browser fallback can route a
1526
+ // chrome-authored context to Firefox mid-run, so gating the flag on the
1527
+ // run's authored browsers would leave the capability illegal exactly
1528
+ // when the fallback needs it. See GECKODRIVER_EXECUTABLE_ARGS.
1529
+ let serverArgs = [...GECKODRIVER_EXECUTABLE_ARGS, ...useDrivers];
1530
+ if (reuseAppiumEnabled() && !useXvfbDisplays) {
1531
+ // Hand-back must be able to list leftover sessions.
1532
+ serverArgs = allowSessionDiscovery(serverArgs);
1533
+ lastReuseConfig = config;
1534
+ reuseKey = appiumServerKey({
1535
+ entry: appiumEntry,
1536
+ args: serverArgs,
1537
+ env: process.env,
1538
+ appiumHome: process.env.APPIUM_HOME,
1539
+ packages: ["appium", ...poolDriverPackages],
1540
+ });
1541
+ }
1468
1542
  for (let i = 0; i < serverCount; i++) {
1543
+ const reused = reuseKey
1544
+ ? await takeServer(appiumServerCache, reuseKey, { isReady: probeStatus })
1545
+ : undefined;
1546
+ if (reused) {
1547
+ log(config, "debug", `Reusing idle Appium server on port ${reused.port}.`);
1548
+ // A spawn would prefetch webdriverio (ADR 01135); a reused server
1549
+ // skips the spawn, so start the import here instead.
1550
+ prefetchHeavyDep("webdriverio", { cacheDir: config?.cacheDir });
1551
+ appiumServers.push(reused);
1552
+ continue;
1553
+ }
1469
1554
  let display;
1470
1555
  if (useXvfbDisplays) {
1471
1556
  display = xvfbDisplay(i);
1472
1557
  xvfbProcesses.push(await startXvfb(display));
1473
1558
  log(config, "debug", `Started Xvfb on ${display} for recording.`);
1474
1559
  }
1475
- const server = await spawnAppiumServer(appiumEntry, config, display);
1560
+ // A reusable server exits once this process dies (ADR 01125).
1561
+ const server = await spawnAppiumServer(appiumEntry, config, display, undefined, serverArgs, reuseKey ? ["--import", APPIUM_WATCHDOG_IMPORT] : undefined);
1562
+ appiumServerCache.resetPort(server.port);
1476
1563
  appiumServers.push(server);
1477
- const wait = appiumIsReady(server.port);
1564
+ const wait = appiumIsReady(server.port, undefined, {
1565
+ pollMs: APPIUM_READY_POLL_MS,
1566
+ });
1478
1567
  // Attach a no-op catch so that once Promise.all rejects on the FIRST
1479
1568
  // failing server, the other still-pending readiness rejections don't
1480
1569
  // surface as unhandled promise rejections. Promise.all still sees the
@@ -1483,6 +1572,8 @@ async function runSpecs({ resolvedTests }) {
1483
1572
  wait.catch(() => { });
1484
1573
  readinessWaits.push(wait);
1485
1574
  }
1575
+ if (sizingJobs.some(jobTakesScreenshot))
1576
+ prefetchHeavyDep("sharp", { cacheDir: config?.cacheDir });
1486
1577
  await Promise.all(readinessWaits);
1487
1578
  for (const server of appiumServers) {
1488
1579
  log(config, "debug", `Appium is ready on port ${server.port}.`);
@@ -1525,6 +1616,10 @@ async function runSpecs({ resolvedTests }) {
1525
1616
  // Swept in the `finally` below — only simulators Doc Detective booted are
1526
1617
  // shut down (launch-ownership).
1527
1618
  const simulatorRegistry = createSimulatorRegistry();
1619
+ // Run-level pool of iOS app Appium servers (ADR 01115): sequential iOS
1620
+ // contexts reuse one server instead of starting and killing their own.
1621
+ // Drained in the `finally` below.
1622
+ const appServerPool = createAppServerPool();
1528
1623
  // One resource registry per run, shared by the warm phase, every phase's
1529
1624
  // flat pool, AND the routed sequencer — so warm's cache-mutating tasks,
1530
1625
  // flat-pool recordings, and routed-spec recordings all contend on the same
@@ -1572,7 +1667,7 @@ async function runSpecs({ resolvedTests }) {
1572
1667
  signalHandled = true;
1573
1668
  (async () => {
1574
1669
  await killAllRegistered();
1575
- await Promise.all(appiumServers.map((server) => killTree(server.process?.pid)));
1670
+ await Promise.all([...appiumServers, ...appServerPool.drain()].map((server) => killTree(server.process?.pid)));
1576
1671
  for (const xvfb of xvfbProcesses) {
1577
1672
  try {
1578
1673
  xvfb.kill();
@@ -1665,6 +1760,7 @@ async function runSpecs({ resolvedTests }) {
1665
1760
  processRegistry,
1666
1761
  deviceRegistry,
1667
1762
  simulatorRegistry,
1763
+ appServerPool,
1668
1764
  logPrefix: limit > 1 ? `[${job.test.testId}/${job.context.contextId}]` : "",
1669
1765
  });
1670
1766
  }
@@ -1746,6 +1842,7 @@ async function runSpecs({ resolvedTests }) {
1746
1842
  processRegistry,
1747
1843
  deviceRegistry,
1748
1844
  simulatorRegistry,
1845
+ appServerPool,
1749
1846
  platform,
1750
1847
  markAutoRecord,
1751
1848
  limit,
@@ -1806,10 +1903,35 @@ async function runSpecs({ resolvedTests }) {
1806
1903
  // (shells out to `taskkill /T /F` on Windows); firing it without
1807
1904
  // awaiting let the run finish before the browser was really dead,
1808
1905
  // orphaning it.
1809
- await Promise.all(appiumServers.map((server) => {
1906
+ // With reuse on (ADR 01125), a server with no sessions left goes back to
1907
+ // the cache instead; one that still has a session is killed the same way.
1908
+ await Promise.all(appiumServers.map(async (server) => {
1909
+ if (reuseKey) {
1910
+ const outcome = await handBackServer(appiumServerCache, reuseKey, server, {
1911
+ listSessions,
1912
+ });
1913
+ log(config, "debug", outcome === "parked"
1914
+ ? `Keeping idle Appium server on port ${server.port} for reuse.`
1915
+ : `Closed Appium server on port ${server.port}`);
1916
+ return;
1917
+ }
1810
1918
  log(config, "debug", `Closing Appium server on port ${server.port}`);
1811
1919
  return killTree(server.process?.pid);
1812
1920
  }));
1921
+ // Close the pooled iOS app servers (ADR 01115) through the same teardown a
1922
+ // per-context server gets, so the Windows orphan sweep still applies.
1923
+ for (const server of appServerPool.drain()) {
1924
+ log(config, "debug", `Closing app Appium server on port ${server.port}`);
1925
+ // A fresh state with no `release` sends teardown down the kill path.
1926
+ const owner = createAppSessionState();
1927
+ owner.server = { port: server.port, process: server.process };
1928
+ try {
1929
+ await teardownAppSession(owner, (pid) => killTree(pid));
1930
+ }
1931
+ catch (error) {
1932
+ log(config, "debug", `Couldn't close app Appium server: ${error?.message ?? error}`);
1933
+ }
1934
+ }
1813
1935
  // Tear down any Xvfb virtual displays started for recording.
1814
1936
  for (const xvfb of xvfbProcesses) {
1815
1937
  try {
@@ -1827,7 +1949,9 @@ async function runSpecs({ resolvedTests }) {
1827
1949
  if (deviceRegistry.size > 0) {
1828
1950
  await teardownDeviceRegistry(deviceRegistry, async (entry) => {
1829
1951
  log(config, "debug", `Shutting down emulator "${entry.name}" (${entry.udid}).`);
1830
- await killTree(entry.process?.pid);
1952
+ // Graceful, not a tree-kill: a hard kill can leave the AVD's disk
1953
+ // images (and the clean boot snapshot inside them) inconsistent.
1954
+ await shutdownEmulator(entry);
1831
1955
  });
1832
1956
  }
1833
1957
  // Sweep the iOS simulator registry (native app phase A4): shut down only the
@@ -1908,7 +2032,7 @@ async function runSpecs({ resolvedTests }) {
1908
2032
  * NEVER touches the summary — Phase-3 in runSpecs remains the sole tally site;
1909
2033
  * here we only push reports.
1910
2034
  */
1911
- async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, platform, markAutoRecord, limit, resourceRegistry, exclusivityCtx, }) {
2035
+ async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, appServerPool, platform, markAutoRecord, limit, resourceRegistry, exclusivityCtx, }) {
1912
2036
  // Set once a `stop:spec` (or deferred `stop:run`) decision halts the spec; the
1913
2037
  // remaining tests are recorded SKIPPED with the stop reason and not executed.
1914
2038
  let stopRest = false;
@@ -1969,6 +2093,8 @@ async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDe
1969
2093
  description: test.description,
1970
2094
  contentPath: test.contentPath,
1971
2095
  detectSteps: test.detectSteps,
2096
+ badge: test.badge,
2097
+ location: test.location,
1972
2098
  contexts: new Array(test.contexts.length),
1973
2099
  };
1974
2100
  // Stamp the visit number when a backward goToTest re-ran this test (additive
@@ -2072,6 +2198,7 @@ async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDe
2072
2198
  processRegistry,
2073
2199
  deviceRegistry,
2074
2200
  simulatorRegistry,
2201
+ appServerPool,
2075
2202
  logPrefix: limit > 1 ? `[${job.test.testId}/${job.context.contextId}]` : "",
2076
2203
  });
2077
2204
  }
@@ -2363,6 +2490,20 @@ async function warmBrowserInstall({ browserName, config, runnerDetails, installA
2363
2490
  note: `'${browserName}' has no installable assets`,
2364
2491
  };
2365
2492
  }
2493
+ // Resolves the UiAutomator2 driver's helper APKs (and its version) from
2494
+ // wherever the driver is installed, for baking into clean snapshots (ADR
2495
+ // 01108). Lazy: the warm-phase boot can start before the driver install ends.
2496
+ function uia2HelpersResolver(config) {
2497
+ return () => {
2498
+ const entry = resolveHeavyDepPath("appium-uiautomator2-driver", {
2499
+ cacheDir: config?.cacheDir,
2500
+ });
2501
+ const home = entry ? appiumHomeForDriverPath(entry) : null;
2502
+ return home
2503
+ ? findUia2Helpers(path.join(home, "node_modules", "appium-uiautomator2-driver"))
2504
+ : null;
2505
+ };
2506
+ }
2366
2507
  /**
2367
2508
  * Light per-run Android environment probe for warm tasks: SDK + emulator
2368
2509
  * binary + acceleration (or a running emulator). Null means "not ready" —
@@ -2379,7 +2520,7 @@ async function resolveAndroidWarmEnv(config) {
2379
2520
  const sdk = detectAndroidSdk({ cacheDir: config?.cacheDir });
2380
2521
  if (!sdk?.emulator)
2381
2522
  return null;
2382
- const deviceDeps = buildAcquireDeviceDeps(sdk, abi, (m) => log(config, "debug", m));
2523
+ const deviceDeps = buildAcquireDeviceDeps(sdk, abi, (m) => log(config, "debug", m), uia2HelpersResolver(config));
2383
2524
  const running = await deviceDeps.listRunning();
2384
2525
  const capable = running.length > 0 || (await checkEmulatorAcceleration(sdk.emulator));
2385
2526
  if (!capable)
@@ -2685,7 +2826,13 @@ async function prefetchMobileChromedriver({ config, desc, deviceRegistry, getAnd
2685
2826
  APPIUM_HOME: pre.appiumHome,
2686
2827
  ANDROID_HOME: env.sdkRoot,
2687
2828
  ANDROID_SDK_ROOT: env.sdkRoot,
2688
- }, CHROMEDRIVER_AUTODOWNLOAD_ARGS);
2829
+ }, [
2830
+ ...CHROMEDRIVER_AUTODOWNLOAD_ARGS,
2831
+ // Load only UiAutomator2 (ADR 01117).
2832
+ ...useDriversArgs(pre.appiumHome, [
2833
+ APP_DRIVER_PLATFORMS.android.driverPackage,
2834
+ ]),
2835
+ ]);
2689
2836
  driver = await startDriver(buildMobileBrowserCapabilities({
2690
2837
  platform: "android",
2691
2838
  udid: acquired.entry.udid,
@@ -3016,7 +3163,7 @@ async function captureAutoScreenshot({ config, driver, spec, test, context, step
3016
3163
  * report or summary counters — the caller owns aggregation, which keeps this
3017
3164
  * function safe to run concurrently with sibling contexts.
3018
3165
  */
3019
- async function runContext({ config, spec, test, context, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, logPrefix = "", }) {
3166
+ async function runContext({ config, spec, test, context, runnerDetails, appiumPool, portToDisplay, metaValues, installAttempts, warmUpResults, processRegistry, deviceRegistry, simulatorRegistry, appServerPool, logPrefix = "", }) {
3020
3167
  const platform = runnerDetails.environment.platform;
3021
3168
  // `let`, not `const`: an on-demand browser install below re-detects available
3022
3169
  // apps and reassigns this snapshot.
@@ -3145,6 +3292,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3145
3292
  appSession.appiumHome = pre.appiumHome;
3146
3293
  appSession.iosSimulatorRegistry = simulatorRegistry;
3147
3294
  appSession.iosSimulatorDeps = pre.simulatorDeps;
3295
+ appSession.appServerPool = appServerPool;
3148
3296
  gateBrowserName = pre.mobileWebBrowserName;
3149
3297
  /* c8 ignore stop */
3150
3298
  }
@@ -3372,15 +3520,33 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3372
3520
  // Chromedriver autodownload is an Appium insecure feature; opt in
3373
3521
  // scoped to the uiautomator2 driver on this run-owned server so it can
3374
3522
  // fetch the chromedriver matching the device's Chrome.
3375
- const extraArgs = mobileTarget === "android" ? CHROMEDRIVER_AUTODOWNLOAD_ARGS : [];
3523
+ const extraArgs = [
3524
+ ...(mobileTarget === "android" ? CHROMEDRIVER_AUTODOWNLOAD_ARGS : []),
3525
+ // Load only the device's driver (ADR 01117).
3526
+ ...useDriversArgs(appSession.appiumHome, [
3527
+ (mobileTarget === "android"
3528
+ ? APP_DRIVER_PLATFORMS.android
3529
+ : APP_DRIVER_PLATFORMS.ios).driverPackage,
3530
+ ]),
3531
+ ];
3376
3532
  // Server start + device boot are environment work: any failure there
3377
3533
  // (port pressure, an emulator that can't finish booting on this host,
3378
3534
  // simctl trouble) is a gating SKIP with the reason named — never a
3379
3535
  // FAIL — matching the mobile rule that every environment gap SKIPs.
3380
3536
  let acquired;
3381
3537
  try {
3382
- const server = await startAppiumServer(appSession.appiumEntry, config, undefined, env, extraArgs);
3383
- appSession.server = { port: server.port, process: server.process };
3538
+ const start = async () => startAppiumServer(appSession.appiumEntry, config, undefined, env, extraArgs);
3539
+ // iOS shares the run's pooled server for this launch environment
3540
+ // (ADR 01115); `release` hands it back at context teardown.
3541
+ const pool = appSession.appServerPool;
3542
+ const server = pool
3543
+ ? await pool.acquire(JSON.stringify({ appiumEntry: appSession.appiumEntry, env, extraArgs }), start)
3544
+ : await start();
3545
+ appSession.server = {
3546
+ port: server.port,
3547
+ process: server.process,
3548
+ release: server.release,
3549
+ };
3384
3550
  const desc = normalizeDeviceDescriptor({
3385
3551
  contextDevice: context.device,
3386
3552
  platform: mobileTarget,
@@ -3427,13 +3593,19 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3427
3593
  // The resolved cache root (not the raw config field, which is
3428
3594
  // usually unset) — the chromedriver autodownload dir lives here.
3429
3595
  cacheDir: getCacheDir({ cacheDir: config?.cacheDir }),
3596
+ // iOS: the simulator's version picks the WDA launch mode (ADR 01110).
3597
+ iosMajor: iosMajorOfRuntime(acquired.entry.runtime),
3430
3598
  });
3431
3599
  try {
3432
3600
  driver = await driverStart(capabilities, appSession.server.port, 2, {
3433
3601
  cacheDir: config?.cacheDir,
3602
+ log: (m) => clog("warning", m),
3434
3603
  });
3435
3604
  }
3436
3605
  catch (error) {
3606
+ // A client-side timeout can abort while the server is still creating
3607
+ // the session, so keep a pooled server out of reuse (ADR 01115).
3608
+ appSession.sessionLeftOpen = true;
3437
3609
  // A capable host that can't open the device browser is an
3438
3610
  // environment gap (the absent-browser precedent): SKIP with the
3439
3611
  // likely cause named, never FAIL.
@@ -3500,18 +3672,18 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3500
3672
  runnerDetails,
3501
3673
  name: browserName,
3502
3674
  options: {
3503
- width: overrides?.size?.width ||
3504
- context.browser?.window?.width ||
3505
- 1200,
3506
- height: overrides?.size?.height ||
3507
- context.browser?.window?.height ||
3508
- 800,
3509
3675
  headless,
3510
3676
  ...recordOptions,
3511
3677
  },
3512
3678
  });
3513
- if (overrides?.driverOptions)
3514
- Object.assign(caps, overrides.driverOptions);
3679
+ // Merge the authored escape hatch, minus the capabilities that are
3680
+ // only legal because we opted the server in to an insecure feature.
3681
+ // A plain Object.assign here would let a spec-authored
3682
+ // `appium:geckodriverExecutable` overwrite the managed path and have
3683
+ // Appium spawn any local binary. See PROTECTED_CAPABILITIES.
3684
+ if (overrides?.driverOptions) {
3685
+ applyDriverOptions(caps, overrides.driverOptions, (message) => log(config, "warning", message));
3686
+ }
3515
3687
  return caps;
3516
3688
  };
3517
3689
  const startFailure = () => {
@@ -3652,6 +3824,18 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3652
3824
  const res = await startDriverForBrowser(engine, overrides);
3653
3825
  if (!res.ok)
3654
3826
  throw new Error(res.error);
3827
+ // A later session takes the context's size unless its
3828
+ // startSurface descriptor sets `size`. A sizing failure leaves
3829
+ // the session usable at its default size, so warn, don't fail.
3830
+ try {
3831
+ await applyBrowserSize(res.driver, Number(overrides?.size?.width) > 0 ||
3832
+ Number(overrides?.size?.height) > 0
3833
+ ? { window: overrides.size }
3834
+ : context.browser, config, `browser session for ${engine}`);
3835
+ }
3836
+ catch (error) {
3837
+ log(config, "warning", `Couldn't size the new ${engine} session: ${error?.message ?? error}`);
3838
+ }
3655
3839
  return res.driver;
3656
3840
  },
3657
3841
  isNameTaken: (name) => !!processRegistry?.has(name),
@@ -3663,31 +3847,18 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3663
3847
  driver,
3664
3848
  });
3665
3849
  }
3666
- // Positive-dimension guard (not truthiness): a 0/negative/NaN viewport
3667
- // must fall through to the window-size branch rather than entering the
3668
- // (no-op) viewport path.
3669
- const viewportW = Number(context.browser?.viewport?.width);
3670
- const viewportH = Number(context.browser?.viewport?.height);
3671
- if (viewportW > 0 || viewportH > 0) {
3672
- // Set driver viewport size
3673
- const realized = await setViewportSize(context, driver, config);
3674
- // Stamp the context REPORT (not the context object — the report is a
3675
- // curated copy) when the browser floored the request. A context-level
3676
- // viewport has no step output to carry the realized size, so this is
3677
- // the only signal the post-run `useMobilePlatforms` hint can read.
3678
- if (isViewportFloored({
3679
- ...(viewportW > 0 ? { width: viewportW } : {}),
3680
- ...(viewportH > 0 ? { height: viewportH } : {}),
3681
- }, realized)) {
3682
- contextReport.viewportFloored = true;
3683
- }
3684
- }
3685
- else if (context.browser?.window?.width ||
3686
- context.browser?.window?.height) {
3687
- // Get driver window size
3688
- const windowSize = await driver.getWindowSize();
3689
- // Resize window if necessary
3690
- await driver.setWindowSize(context.browser?.window?.width || windowSize.width, context.browser?.window?.height || windowSize.height);
3850
+ // Size the session: the authored viewport, else the authored window,
3851
+ // else the default viewport (ADR 01144). The label attributes a floor
3852
+ // warning to this context in a multi-context run.
3853
+ const sizing = await applyBrowserSize(driver, context.browser, config, `viewport for ${context.browser?.name ?? "browser"} on ${context.platform ?? "host"}`);
3854
+ // Stamp the context REPORT (not the context object — the report is a
3855
+ // curated copy) when the browser floored an authored viewport. A
3856
+ // context-level viewport has no step output to carry the realized size,
3857
+ // so this is the only signal the post-run `useMobilePlatforms` hint can
3858
+ // read.
3859
+ if (sizing.target.kind === "viewport" &&
3860
+ isViewportFloored(sizing.target, sizing.realized)) {
3861
+ contextReport.viewportFloored = true;
3691
3862
  }
3692
3863
  }
3693
3864
  // Effective autoScreenshot for this context (test > spec > config).
@@ -4129,7 +4300,9 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
4129
4300
  // retry warning the wrapper already logs. The alive-but-unusable cases do,
4130
4301
  // because "the session responded and we retried anyway" is otherwise
4131
4302
  // surprising in a log.
4132
- const retryReason = await classifyContextRetry(probeDrivers);
4303
+ const retryReason = await classifyContextRetry(probeDrivers, {
4304
+ navigated: contextReport.steps.some((s) => s.goTo !== undefined && s.result !== "SKIPPED"),
4305
+ });
4133
4306
  if (retryReason) {
4134
4307
  sessionDiedMidRun = true;
4135
4308
  if (retryReason === "page-broken") {
@@ -4190,8 +4363,11 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
4190
4363
  // start, so the sweep covers it; sessions a closeSurface step already
4191
4364
  // ended are gone from the registry). In a finally so an unexpected throw
4192
4365
  // can't leak sessions while sibling contexts keep running.
4366
+ // A session left open (a failed delete) must keep a pooled iOS app server
4367
+ // from being reused: the mobile-web session runs on it (ADR 01115).
4368
+ let sessionsClosed = true;
4193
4369
  if (browserSessions) {
4194
- await sweepSessions(browserSessions);
4370
+ sessionsClosed = await sweepSessions(browserSessions);
4195
4371
  }
4196
4372
  else if (driver) {
4197
4373
  // Registry creation is unconditional after a driver starts, so this
@@ -4200,9 +4376,12 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
4200
4376
  await driver.deleteSession();
4201
4377
  }
4202
4378
  catch (error) {
4379
+ sessionsClosed = false;
4203
4380
  clog("error", `Failed to delete driver session: ${error.message}`);
4204
4381
  }
4205
4382
  }
4383
+ if (appSession && !sessionsClosed)
4384
+ appSession.sessionLeftOpen = true;
4206
4385
  // Tear down app surfaces: close every remaining app session (the driver
4207
4386
  // terminates apps it launched) and stop the app Appium server. Tree-kill
4208
4387
  // in callback form so the server process is actually gone before the
@@ -4613,10 +4792,32 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4613
4792
  /* best-effort */
4614
4793
  }
4615
4794
  }
4616
- const server = await startAppiumServer(appiumEntry, config, undefined, env);
4617
- return { port: server.port, process: server.process };
4795
+ // Load only this platform's app driver (ADR 01117).
4796
+ const driverPackage = context?.platform
4797
+ ? APP_DRIVER_PLATFORMS[context.platform]?.driverPackage
4798
+ : undefined;
4799
+ const extraArgs = driverPackage
4800
+ ? useDriversArgs(appiumHome, [driverPackage])
4801
+ : [];
4802
+ const start = async () => {
4803
+ const server = await startAppiumServer(appiumEntry, config, undefined, env, extraArgs);
4804
+ return { port: server.port, process: server.process };
4805
+ };
4806
+ // iOS contexts share the run's pooled server for this exact launch
4807
+ // environment and argument list (ADR 01115); teardown releases it
4808
+ // via `release`. Deliberately a different key from the mobile-web
4809
+ // site: app servers also carry the ffmpeg PATH, so the two never
4810
+ // share a server.
4811
+ const pool = appSession?.appServerPool;
4812
+ if (pool) {
4813
+ return pool.acquire(JSON.stringify({ appiumEntry, env, extraArgs }), start);
4814
+ }
4815
+ return start();
4618
4816
  },
4619
- startDriver: (capabilities, port) => driverStart(capabilities, port, 2, { cacheDir: config?.cacheDir }),
4817
+ startDriver: (capabilities, port) => driverStart(capabilities, port, 2, {
4818
+ cacheDir: config?.cacheDir,
4819
+ log: (m) => log(config, "warning", m),
4820
+ }),
4620
4821
  // Mobile device acquisition (boot/create-and-boot, or reuse), bound
4621
4822
  // to the run-level registry stashed on the app session. Android uses
4622
4823
  // the emulator layer; iOS uses the simctl simulator layer. Both
@@ -4783,7 +4984,9 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4783
4984
  async function spawnAppiumServer(appiumEntry, config, display, extraEnv,
4784
4985
  // Extra CLI args for the server, e.g. the scoped `--allow-insecure`
4785
4986
  // chromedriver-autodownload opt-in for android mobile-web sessions.
4786
- extraArgs) {
4987
+ extraArgs,
4988
+ // Node args placed before the entry, e.g. the reuse watchdog (ADR 01125).
4989
+ nodeArgs) {
4787
4990
  const port = await findFreePort();
4788
4991
  log(config, "debug", `Starting Appium on port ${port}`);
4789
4992
  // When a virtual display is supplied (Linux Xvfb recording), launch the
@@ -4798,7 +5001,15 @@ extraArgs) {
4798
5001
  ...(extraEnv ?? {}),
4799
5002
  }
4800
5003
  : process.env;
4801
- const proc = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(port), ...(extraArgs ?? [])], {
5004
+ const proc = spawn(process.execPath, [
5005
+ ...(nodeArgs ?? []),
5006
+ appiumEntry,
5007
+ "-a",
5008
+ "127.0.0.1",
5009
+ "-p",
5010
+ String(port),
5011
+ ...(extraArgs ?? []),
5012
+ ], {
4802
5013
  windowsHide: true,
4803
5014
  cwd: path.join(__dirname, "../.."),
4804
5015
  env,
@@ -4808,6 +5019,8 @@ extraArgs) {
4808
5019
  });
4809
5020
  proc.stdout.on("data", () => { });
4810
5021
  proc.stderr.on("data", () => { });
5022
+ // Overlap webdriverio's ~350 ms import with Appium's boot (ADR 01135).
5023
+ prefetchHeavyDep("webdriverio", { cacheDir: config?.cacheDir });
4811
5024
  return { port, process: proc, display };
4812
5025
  }
4813
5026
  async function startAppiumServer(appiumEntry, config, display, extraEnv,
@@ -4816,7 +5029,9 @@ async function startAppiumServer(appiumEntry, config, display, extraEnv,
4816
5029
  extraArgs) {
4817
5030
  const server = await spawnAppiumServer(appiumEntry, config, display, extraEnv, extraArgs);
4818
5031
  try {
4819
- await appiumIsReady(server.port);
5032
+ await appiumIsReady(server.port, undefined, {
5033
+ pollMs: APPIUM_READY_POLL_MS,
5034
+ });
4820
5035
  }
4821
5036
  catch (error) {
4822
5037
  // appiumIsReady threw or timed out — the spawned child is still alive and
@@ -4841,13 +5056,14 @@ const STATUS_PROBE_TIMEOUT_MS = 10000;
4841
5056
  // `probe`/`sleep` are injectable for hermetic unit tests; the overall timeout
4842
5057
  // cap (default 120s) is unchanged.
4843
5058
  async function appiumIsReady(port, timeoutMs = 120000, deps = {}) {
5059
+ const pollMs = deps.pollMs ?? 250;
4844
5060
  const probe = deps.probe ??
4845
5061
  (async (p) => {
4846
5062
  try {
4847
5063
  // Bound each probe: without a per-request timeout a hung /status
4848
5064
  // response would block this await indefinitely, and the overall
4849
5065
  // `timeoutMs` guard (checked only between probes) could never fire.
4850
- const resp = await axios.get(`http://127.0.0.1:${p}/status`, {
5066
+ const resp = await (await loadAxios()).get(`http://127.0.0.1:${p}/status`, {
4851
5067
  timeout: STATUS_PROBE_TIMEOUT_MS,
4852
5068
  });
4853
5069
  return resp.status === 200;
@@ -4864,7 +5080,7 @@ async function appiumIsReady(port, timeoutMs = 120000, deps = {}) {
4864
5080
  if (Date.now() - start > timeoutMs) {
4865
5081
  throw new Error(`Appium server on port ${port} failed to start within ${timeoutMs / 1000} seconds`);
4866
5082
  }
4867
- await sleep(250);
5083
+ await sleep(pollMs);
4868
5084
  }
4869
5085
  }
4870
5086
  // Start the Appium driver specified in `capabilities`.
@@ -4888,19 +5104,23 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
4888
5104
  let lastError;
4889
5105
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4890
5106
  try {
4891
- // Chromium sessions get a unique free chromedriver port so concurrent
4892
- // browser contexts never collide on chromedriver's fixed default (9515);
4893
- // see withChromedriverPort (ADR 01039). A FRESH port is allocated per
5107
+ // Chromium and Gecko sessions get a unique free driver port so
5108
+ // concurrent browser contexts never collide on chromedriver's fixed
5109
+ // default (9515) or geckodriver's lowest-free-in-range pick; see
5110
+ // withDriverPort (ADR 01039, 01105). A FRESH port is allocated per
4894
5111
  // attempt so a retryable ECONNREFUSED — the very rebind-race the Appium
4895
5112
  // path already retries — moves to a new free port instead of re-racing
4896
- // the same one. Only Chromium caps need a port; every other engine skips
4897
- // the allocation and keeps a byte-identical wdio.remote payload.
4898
- const needsChromedriverPort = capabilities?.["appium:automationName"] === "Chromium" &&
4899
- capabilities?.["appium:chromedriverPort"] === undefined;
4900
- const attemptCaps = needsChromedriverPort
4901
- ? withChromedriverPort(capabilities, await findFreePort())
5113
+ // the same one. Engines without a driver port skip the allocation and
5114
+ // keep a byte-identical wdio.remote payload.
5115
+ const attemptCaps = driverPortCapability(capabilities)
5116
+ ? withDriverPort(capabilities, await findFreePort())
4902
5117
  : capabilities;
4903
- const driver = await wdio.remote({
5118
+ // Every attempt is reported to the reuse cache: a failed or abandoned
5119
+ // start may still register a session on this server, so it must not be
5120
+ // handed to a later run (ADR 01125).
5121
+ const settle = appiumServerCache.sessionStarting(port);
5122
+ // Start from a resolved promise so a synchronous throw still settles.
5123
+ const driver = await Promise.resolve().then(() => (ctx.remote ?? wdio.remote)({
4904
5124
  protocol: "http",
4905
5125
  hostname: "127.0.0.1",
4906
5126
  port,
@@ -4909,6 +5129,15 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
4909
5129
  capabilities: attemptCaps,
4910
5130
  connectionRetryTimeout: startupCeiling,
4911
5131
  waitforTimeout: 120000, // 2 minutes
5132
+ // webdriverio 10's `$` throws when a selector matches several
5133
+ // elements. Keep the documented first-match behavior (ADR 01143).
5134
+ strictSelectors: false,
5135
+ })).then((d) => {
5136
+ settle(true);
5137
+ return d;
5138
+ }, (e) => {
5139
+ settle(false);
5140
+ throw e;
4912
5141
  });
4913
5142
  // Per-context mutable state. `recordings` lives here (not on config)
4914
5143
  // so concurrent contexts can't clobber each other's recordings. It is a
@@ -4923,6 +5152,8 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
4923
5152
  if (!isRetryableSessionError(String(err && err.message), startupCeiling))
4924
5153
  throw err;
4925
5154
  if (attempt < maxAttempts) {
5155
+ const reason = String(err?.message ?? err).split("\n")[0];
5156
+ ctx.log?.(`Session start attempt ${attempt} of ${maxAttempts} failed (${reason}); retrying.`);
4926
5157
  await new Promise((r) => setTimeout(r, 500 * attempt));
4927
5158
  }
4928
5159
  }
@@ -5085,7 +5316,15 @@ async function getRunner(options = {}) {
5085
5316
  if (!appiumEntry) {
5086
5317
  throw new Error("appium is not installed. Run `doc-detective install runtime appium` to install it.");
5087
5318
  }
5088
- const appium = spawn(process.execPath, [appiumEntry, "-a", "127.0.0.1", "-p", String(appiumPort)], {
5319
+ const appium = spawn(process.execPath, [
5320
+ appiumEntry,
5321
+ "-a",
5322
+ "127.0.0.1",
5323
+ "-p",
5324
+ String(appiumPort),
5325
+ // Load only the desktop browser drivers (ADR 01117).
5326
+ ...useDriversArgs(process.env.APPIUM_HOME, browserPoolDriverPackages(process.platform)),
5327
+ ], {
5089
5328
  windowsHide: true,
5090
5329
  cwd: path.join(__dirname, "../.."),
5091
5330
  });
@@ -5095,11 +5334,14 @@ async function getRunner(options = {}) {
5095
5334
  appium.on("error", (err) => {
5096
5335
  log(config, "warning", `Appium process error: ${err?.stack ?? err?.message ?? String(err)}`);
5097
5336
  });
5337
+ prefetchHeavyDep("webdriverio", { cacheDir: config?.cacheDir }); // ADR 01135
5098
5338
  // Wait for Appium to be ready. Same kill-on-throw guard as in
5099
5339
  // runSpecs above — without it, a startup timeout would leave an
5100
5340
  // orphan Appium child holding the ephemeral port.
5101
5341
  try {
5102
- await appiumIsReady(appiumPort);
5342
+ await appiumIsReady(appiumPort, undefined, {
5343
+ pollMs: APPIUM_READY_POLL_MS,
5344
+ });
5103
5345
  }
5104
5346
  catch (error) {
5105
5347
  try {
@@ -5140,6 +5382,15 @@ async function getRunner(options = {}) {
5140
5382
  throw new Error(`Failed to start Chrome runner: ${error.message}`);
5141
5383
  }
5142
5384
  }
5385
+ // A fresh Chromium session starts parked on its initial blank document
5386
+ // (`data:,`) and sometimes stays there even though `url()` resolved, so the
5387
+ // navigation silently didn't take (ADR 01084, ADR 01088). Steps run through
5388
+ // `runStep` are covered by goTo's guard, but the documented way to drive this
5389
+ // runner is `await runner.url(...)` straight through WebdriverIO, which was
5390
+ // not: the caller got a blank page, and the failure surfaced later as an empty
5391
+ // title or a missing element with nothing pointing at the navigation. Guard
5392
+ // the session's own `url` command so every caller gets the same verification.
5393
+ guardBlankDocumentNavigation(runner, config);
5143
5394
  // Set window size
5144
5395
  try {
5145
5396
  await runner.setWindowSize(width, height);