doc-detective 4.28.0 → 4.29.0

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 (57) hide show
  1. package/dist/cli.js +13 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/common/src/detectTests.d.ts +9 -0
  4. package/dist/common/src/detectTests.d.ts.map +1 -1
  5. package/dist/common/src/detectTests.js +22 -9
  6. package/dist/common/src/detectTests.js.map +1 -1
  7. package/dist/common/src/schemas/schemas.json +85 -1
  8. package/dist/common/src/types/generated/report_v3.d.ts +34 -0
  9. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  10. package/dist/core/detectTests.d.ts.map +1 -1
  11. package/dist/core/detectTests.js +37 -13
  12. package/dist/core/detectTests.js.map +1 -1
  13. package/dist/core/expressions.d.ts.map +1 -1
  14. package/dist/core/expressions.js +24 -1
  15. package/dist/core/expressions.js.map +1 -1
  16. package/dist/core/index.d.ts +2 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +2 -2
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/resolveTests.js +23 -12
  21. package/dist/core/resolveTests.js.map +1 -1
  22. package/dist/core/telem.d.ts +2 -1
  23. package/dist/core/telem.d.ts.map +1 -1
  24. package/dist/core/telem.js +26 -2
  25. package/dist/core/telem.js.map +1 -1
  26. package/dist/core/tests/androidEmulator.d.ts.map +1 -1
  27. package/dist/core/tests/androidEmulator.js +13 -0
  28. package/dist/core/tests/androidEmulator.js.map +1 -1
  29. package/dist/core/tests/appSurface.d.ts +23 -0
  30. package/dist/core/tests/appSurface.d.ts.map +1 -1
  31. package/dist/core/tests/appSurface.js +3 -1
  32. package/dist/core/tests/appSurface.js.map +1 -1
  33. package/dist/core/tests/iosSimulator.d.ts.map +1 -1
  34. package/dist/core/tests/iosSimulator.js +13 -0
  35. package/dist/core/tests/iosSimulator.js.map +1 -1
  36. package/dist/core/tests/mobileBrowser.d.ts +1 -0
  37. package/dist/core/tests/mobileBrowser.d.ts.map +1 -1
  38. package/dist/core/tests/mobileBrowser.js +8 -0
  39. package/dist/core/tests/mobileBrowser.js.map +1 -1
  40. package/dist/core/tests.d.ts +83 -2
  41. package/dist/core/tests.d.ts.map +1 -1
  42. package/dist/core/tests.js +572 -70
  43. package/dist/core/tests.js.map +1 -1
  44. package/dist/core/utils.d.ts +3 -2
  45. package/dist/core/utils.d.ts.map +1 -1
  46. package/dist/core/utils.js +37 -29
  47. package/dist/core/utils.js.map +1 -1
  48. package/dist/core/warmPhase.d.ts +161 -0
  49. package/dist/core/warmPhase.d.ts.map +1 -0
  50. package/dist/core/warmPhase.js +341 -0
  51. package/dist/core/warmPhase.js.map +1 -0
  52. package/dist/index.cjs +776 -90
  53. package/dist/utils.d.ts +2 -1
  54. package/dist/utils.d.ts.map +1 -1
  55. package/dist/utils.js +11 -3
  56. package/dist/utils.js.map +1 -1
  57. package/package.json +1 -1
@@ -13,7 +13,7 @@ import { requiredBrowserAssets, ensureBrowserInstalled, } from "../runtime/brows
13
13
  // Single source of truth for browser/driver-requiring step keys.
14
14
  import { BROWSER_STEP_KEYS as driverActions, startSurfaceDescriptors, stepOpensBrowserSurface, } from "../runtime/browserStepKeys.js";
15
15
  import os from "node:os";
16
- import { log, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, } from "./utils.js";
16
+ import { log, logLevelEnabled, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, } from "./utils.js";
17
17
  import axios from "axios";
18
18
  import { instantiateCursor } from "./tests/moveTo.js";
19
19
  import { goTo } from "./tests/goTo.js";
@@ -34,10 +34,12 @@ import { httpRequest } from "./tests/httpRequest.js";
34
34
  import { clickElement } from "./tests/click.js";
35
35
  import { runCode } from "./tests/runCode.js";
36
36
  import { closeSurface } from "./tests/closeSurface.js";
37
- import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, } from "./tests/appSurface.js";
37
+ import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, APP_DRIVER_PLATFORMS, probeIosToolchain, } from "./tests/appSurface.js";
38
38
  import { startSurfaceStep } from "./tests/startSurface.js";
39
39
  import { isMobileTargetPlatform } from "./tests/mobilePlatform.js";
40
- import { mobileBrowserGate, buildMobileBrowserCapabilities, } from "./tests/mobileBrowser.js";
40
+ import { mobileBrowserGate, buildMobileBrowserCapabilities, CHROMEDRIVER_AUTODOWNLOAD_ARGS, } from "./tests/mobileBrowser.js";
41
+ import { planWarmTasks, executeWarmTasks, raceBootInitiation, wrapInitiationEffects, RUNTIME_INSTALL_RESOURCE, } from "./warmPhase.js";
42
+ import { locateManagedWda } from "../runtime/wdaProducts.js";
41
43
  import { getCacheDir } from "../runtime/cacheDir.js";
42
44
  import { detectAndroidSdk } from "../runtime/androidSdk.js";
43
45
  import { hostAbi, listInstalledSystemImages, installAndroid, } from "../runtime/androidInstaller.js";
@@ -59,9 +61,7 @@ import http from "node:http";
59
61
  import https from "node:https";
60
62
  import { fileURLToPath } from "node:url";
61
63
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
62
- export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, withChromedriverPort, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, jobDisplayResources, };
63
- // exports.appiumStart = appiumStart;
64
- // exports.appiumIsReady = appiumIsReady;
64
+ export { runSpecs, runViaApi, getRunner, ensureChromeAvailable, ensureContextBrowserInstalled, combinationKey, warmUpDecision, selectWarmUpTargets, getDriverCapabilities, withChromedriverPort, getDefaultBrowser, buildFallbackCandidates, driverSkipDiagnostic, resolveBrowserFallbackPolicy, shouldRepairBeforeFallback, isSupportedContext, contextRequirementsSkipMessage, resolveAutoScreenshot, resolveAutoRecord, buildAutoRecordStep, specIsRouted, killTree, jobDisplayResources, buildWarmPlanDeps, warmBrowserInstall, prefetchMobileChromedriver, appiumIsReady, };
65
65
  // exports.driverStart = driverStart;
66
66
  // Browser names getDriverCapabilities knows how to build caps for. `safari` is
67
67
  // rewritten to `webkit` during context resolution, so both appear here.
@@ -157,6 +157,28 @@ function combinationKey(context) {
157
157
  function warmUpDecision(prev) {
158
158
  return prev === "failed" ? "skip" : "attempt";
159
159
  }
160
+ /**
161
+ * Bind the real selection predicates for the warm-phase planner
162
+ * (planWarmTasks in warmPhase.ts). The planner takes these as an injected
163
+ * bag because most of them live in this module — importing them from
164
+ * warmPhase.ts would create a tests.ts ⇄ warmPhase.ts cycle — and because
165
+ * the bag keeps the planner hermetically unit-testable. Exported so planner
166
+ * tests exercise production selection logic, not stand-ins.
167
+ */
168
+ function buildWarmPlanDeps() {
169
+ return {
170
+ isBrowserRequired,
171
+ isAppDriverRequired,
172
+ isMobileTargetPlatform,
173
+ getDefaultBrowser,
174
+ requiredBrowserAssets,
175
+ collectDeviceDescriptors,
176
+ normalizeDeviceDescriptor,
177
+ mobileBrowserGate,
178
+ contextRequirementsSkipMessage,
179
+ appDriverPlatforms: APP_DRIVER_PLATFORMS,
180
+ };
181
+ }
160
182
  // Get Appium driver capabilities and apply options.
161
183
  function getDriverCapabilities({ runnerDetails, name, options }) {
162
184
  let capabilities = {};
@@ -1129,6 +1151,11 @@ async function runSpecs({ resolvedTests }) {
1129
1151
  },
1130
1152
  },
1131
1153
  specs: [],
1154
+ // Inline warm phase results (docs/design/warm-phase.md). Present on the
1155
+ // skeleton so a run that plans nothing (or whose planning fails —
1156
+ // best-effort) still reports the structural empty block; the phase
1157
+ // overwrites it with real task results below.
1158
+ warm: { durationMs: 0, tasks: [] },
1132
1159
  };
1133
1160
  // Resolve concurrency up front (defensive re-resolve: API callers can hand
1134
1161
  // runSpecs a config that never went through core setConfig, leaving
@@ -1392,6 +1419,11 @@ async function runSpecs({ resolvedTests }) {
1392
1419
  // that removes the port race and fails fast on the first server that can't
1393
1420
  // come up, tearing down any already started so they don't leak.
1394
1421
  try {
1422
+ // Spawn servers one at a time (serial spawn keeps the findFreePort race
1423
+ // protection + avoids a CPU spike), but collect their readiness polls and
1424
+ // await them together so the waits OVERLAP — total ≈ max(readiness)
1425
+ // instead of the sum of serial waits.
1426
+ const readinessWaits = [];
1395
1427
  for (let i = 0; i < serverCount; i++) {
1396
1428
  let display;
1397
1429
  if (useXvfbDisplays) {
@@ -1399,7 +1431,20 @@ async function runSpecs({ resolvedTests }) {
1399
1431
  xvfbProcesses.push(await startXvfb(display));
1400
1432
  log(config, "debug", `Started Xvfb on ${display} for recording.`);
1401
1433
  }
1402
- appiumServers.push(await startAppiumServer(appiumEntry, config, display));
1434
+ const server = await spawnAppiumServer(appiumEntry, config, display);
1435
+ appiumServers.push(server);
1436
+ const wait = appiumIsReady(server.port);
1437
+ // Attach a no-op catch so that once Promise.all rejects on the FIRST
1438
+ // failing server, the other still-pending readiness rejections don't
1439
+ // surface as unhandled promise rejections. Promise.all still sees the
1440
+ // original `wait`, so it fails fast on the first error; the catch
1441
+ // block below tears down every spawned server (ready or not).
1442
+ wait.catch(() => { });
1443
+ readinessWaits.push(wait);
1444
+ }
1445
+ await Promise.all(readinessWaits);
1446
+ for (const server of appiumServers) {
1447
+ log(config, "debug", `Appium is ready on port ${server.port}.`);
1403
1448
  }
1404
1449
  }
1405
1450
  catch (error) {
@@ -1439,6 +1484,15 @@ async function runSpecs({ resolvedTests }) {
1439
1484
  // Swept in the `finally` below — only simulators Doc Detective booted are
1440
1485
  // shut down (launch-ownership).
1441
1486
  const simulatorRegistry = createSimulatorRegistry();
1487
+ // One resource registry per run, shared by the warm phase, every phase's
1488
+ // flat pool, AND the routed sequencer — so warm's cache-mutating tasks,
1489
+ // flat-pool recordings, and routed-spec recordings all contend on the same
1490
+ // named mutexes. Warm is awaited before Phase 2 dispatch and
1491
+ // runResourceAware releases every tag in a `finally`, so the pools always
1492
+ // start with an empty registry. Only consulted where items carry tags
1493
+ // (warm tasks always do; jobs only at limit>1 — at limit===1 the pools
1494
+ // stay on the byte-identical runConcurrent path).
1495
+ const resourceRegistry = createResourceRegistry();
1442
1496
  // Kill every still-registered background process (and its child tree) and
1443
1497
  // remove any deferred temp scripts. Awaits the kills so the process tree is
1444
1498
  // actually gone before the run returns. Idempotent: closeSurface already
@@ -1495,25 +1549,56 @@ async function runSpecs({ resolvedTests }) {
1495
1549
  // warmUpContexts (e.g. getAvailableApps failing during the re-detect) would
1496
1550
  // leak the started servers, leaving orphaned processes bound to their ports.
1497
1551
  try {
1498
- // For concurrent runs, resolve missing browser dependencies and warm up
1499
- // each unique driver combination serially *before* the pool. Two contexts
1500
- // can't then race on an on-demand install (which mutates the shared app
1501
- // cache), and a combination that can't start a driver is recorded once here
1502
- // so every parallel context sharing it skips instantly instead of re-paying
1503
- // driverStart's backoff. This pre-populates installAttempts /
1504
- // warmUpResults / runnerDetails.availableApps, so runContext's own gates
1505
- // below collapse to fast cache hits. Sequential runs (limit 1) keep #338's
1506
- // natural first-context-warms-up behavior in runContext no pre-pass, no
1507
- // extra driver start, byte-identical to before.
1508
- if (limit > 1 && appiumPool) {
1509
- await warmUpContexts({
1510
- jobs: sizingJobs,
1511
- config,
1552
+ // Inline warm phase (docs/design/warm-phase.md): always-on, best-effort
1553
+ // provisioning between resolution and execution. The planner derives
1554
+ // every task the run's contexts would JIT-provision anyway (browser and
1555
+ // app-driver installs, device boots, the WDA availability check, the
1556
+ // mobile chromedriver prefetch, the folded-in session probe) and the
1557
+ // executor overlaps them under the run's resource registry so boot ∥
1558
+ // npm install browser download overlap each other even for a serial
1559
+ // test run. A failed task is a warning; the per-context paths retry or
1560
+ // skip with exactly the semantics they have today. The historical
1561
+ // `limit > 1 && appiumPool` gate now guards only the session-probe TASK
1562
+ // (inside planWarmTasks), preserving #338's natural
1563
+ // first-context-warms-up behavior for serial runs, whose memo state is
1564
+ // byte-identical by the warmBrowserInstall mirror contract. Device
1565
+ // boots resolve at initiation; only the chromedriver prefetch awaits
1566
+ // readiness (and only runs with android mobile-web contexts pay it —
1567
+ // they'd pay the same boot + session at their first mobile context).
1568
+ // The never-gates contract covers the whole phase, planning included: a
1569
+ // throw from the planner or its bound predicates must degrade to a
1570
+ // warning + the skeleton's empty warm block, never abort the run
1571
+ // (executeWarmTasks already isolates per-task failures internally).
1572
+ try {
1573
+ const warmTasks = planWarmTasks({
1574
+ sizingJobs,
1512
1575
  runnerDetails,
1513
- appiumPool,
1514
- installAttempts,
1515
- warmUpResults,
1576
+ limit,
1577
+ hasAppiumPool: !!appiumPool,
1578
+ deps: buildWarmPlanDeps(),
1516
1579
  });
1580
+ if (warmTasks.length > 0) {
1581
+ log(config, "debug", `Warm phase: ${warmTasks.length} task(s).`);
1582
+ report.warm = await executeWarmTasks({
1583
+ tasks: warmTasks,
1584
+ registry: resourceRegistry,
1585
+ runTask: buildWarmTaskRunner({
1586
+ config,
1587
+ runnerDetails,
1588
+ sizingJobs,
1589
+ appiumPool,
1590
+ installAttempts,
1591
+ warmUpResults,
1592
+ deviceRegistry,
1593
+ simulatorRegistry,
1594
+ resourceRegistry,
1595
+ }),
1596
+ log: (level, message) => log(config, level, message),
1597
+ });
1598
+ }
1599
+ }
1600
+ catch (error) {
1601
+ log(config, "warning", `Warm phase skipped (planning failed; the run proceeds with on-demand provisioning): ${error?.message ?? error}`);
1517
1602
  }
1518
1603
  // Phase 2: run context jobs through the worker pool, gated into three
1519
1604
  // sequential phases. Config-level `beforeAny` specs all finish before any
@@ -1598,12 +1683,6 @@ async function runSpecs({ resolvedTests }) {
1598
1683
  : "main";
1599
1684
  routedByPhase[phase].push(entry);
1600
1685
  }
1601
- // One resource registry per run, shared by every phase's flat pool AND the
1602
- // routed sequencer, so a flat-pool recording and a routed-spec recording
1603
- // never hold the shared "display" at the same time. Only consulted at
1604
- // limit>1 (where jobs were tagged); at limit===1 the pools stay on the
1605
- // byte-identical runConcurrent path.
1606
- const resourceRegistry = createResourceRegistry();
1607
1686
  for (const phase of PHASES) {
1608
1687
  if (limit > 1) {
1609
1688
  await runResourceAware(jobsByPhase[phase], limit, resourceRegistry, runJob);
@@ -2103,27 +2182,20 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
2103
2182
  context.platform === platform &&
2104
2183
  Array.isArray(context?.steps) &&
2105
2184
  requiredBrowserAssets(context.browser?.name).length > 0) {
2106
- const firstAttempt = !installAttempts.has((context.browser?.name ?? "<none>").toLowerCase());
2107
- const outcome = await ensureContextBrowserInstalled({
2185
+ // Extracted install + first-attempt re-detect (shared with the warm
2186
+ // phase's browser-install task) — the memo state it leaves is exactly
2187
+ // what this loop produced inline before.
2188
+ await warmBrowserInstall({
2108
2189
  browserName: context.browser?.name,
2109
2190
  config,
2191
+ runnerDetails,
2110
2192
  installAttempts,
2111
- deps: {
2112
- ensureBrowser: (asset, options) => ensureBrowserInstalled(asset, options),
2113
- log,
2114
- },
2115
- // Repair a present-but-broken driver, not just install-if-missing.
2116
- repair: true,
2117
2193
  });
2118
- if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
2119
- clearAppCache(config);
2120
- runnerDetails.availableApps = await getAvailableApps({ config });
2121
- supported = isSupportedContext({
2122
- context,
2123
- apps: runnerDetails.availableApps,
2124
- platform,
2125
- });
2126
- }
2194
+ supported = isSupportedContext({
2195
+ context,
2196
+ apps: runnerDetails.availableApps,
2197
+ platform,
2198
+ });
2127
2199
  }
2128
2200
  // Unsupported combinations are left unmarked; runContext skips each with the
2129
2201
  // appropriate per-context reason (install-but-undetected vs unsupported).
@@ -2177,6 +2249,404 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
2177
2249
  }
2178
2250
  }
2179
2251
  }
2252
+ /**
2253
+ * On-demand browser install + first-attempt re-detect — the install half of
2254
+ * warmUpContexts, extracted so the warm phase's browser-install task and the
2255
+ * session-probe loop share ONE implementation of the mirror contract: the
2256
+ * `installAttempts` / `runnerDetails.availableApps` state left behind is
2257
+ * exactly what the first same-browser consuming context would have produced
2258
+ * serially (no more, no less), so every later gate collapses to a cache hit.
2259
+ * Deps are injected for hermetic tests; production callers use the defaults.
2260
+ */
2261
+ async function warmBrowserInstall({ browserName, config, runnerDetails, installAttempts, deps = {}, }) {
2262
+ // Already-detected engines need no install — and, mirroring the serial
2263
+ // path (which only reaches the install when the support gate failed),
2264
+ // no memo entry either.
2265
+ const appName = normalizeBrowserName(browserName);
2266
+ if (runnerDetails.availableApps?.find((app) => app.name === appName)) {
2267
+ return {
2268
+ outcome: "skipped",
2269
+ note: `'${browserName}' is already available`,
2270
+ };
2271
+ }
2272
+ const firstAttempt = !installAttempts.has((browserName ?? "<none>").toLowerCase());
2273
+ const outcome = await ensureContextBrowserInstalled({
2274
+ browserName,
2275
+ config,
2276
+ installAttempts,
2277
+ deps: {
2278
+ ensureBrowser: deps.ensureBrowser ??
2279
+ ((asset, options) => ensureBrowserInstalled(asset, options)),
2280
+ log,
2281
+ },
2282
+ // Repair a present-but-broken driver, not just install-if-missing.
2283
+ repair: true,
2284
+ });
2285
+ // Re-detect only after a FIRST install attempt (installed or failed):
2286
+ // the app cache is stale either way, and later gates must read the
2287
+ // refreshed list or they'd misread the memo as installed-but-undetected.
2288
+ if (firstAttempt && (outcome === "installed" || outcome === "failed")) {
2289
+ (deps.clearAppCache ?? clearAppCache)(config);
2290
+ runnerDetails.availableApps = await (deps.getAvailableApps ??
2291
+ getAvailableApps)({ config });
2292
+ }
2293
+ if (outcome === "installed")
2294
+ return { outcome: "warmed" };
2295
+ if (outcome === "failed") {
2296
+ return { outcome: "failed", note: `couldn't install '${browserName}'` };
2297
+ }
2298
+ return {
2299
+ outcome: "skipped",
2300
+ note: `'${browserName}' has no installable assets`,
2301
+ };
2302
+ }
2303
+ /**
2304
+ * Light per-run Android environment probe for warm tasks: SDK + emulator
2305
+ * binary + acceleration (or a running emulator). Null means "not ready" —
2306
+ * the warm task reports skipped and the consuming context performs the full
2307
+ * androidContextPreflight (including the loud lazy toolchain install, which
2308
+ * warm deliberately never triggers — that decision and its warning belong on
2309
+ * the context report).
2310
+ */
2311
+ /* c8 ignore start — real SDK/emulator probes; exercised on the CI emulator
2312
+ legs and dev boxes. Unit coverage targets the pure planner/executor. */
2313
+ async function resolveAndroidWarmEnv(config) {
2314
+ try {
2315
+ const abi = hostAbi();
2316
+ const sdk = detectAndroidSdk({ cacheDir: config?.cacheDir });
2317
+ if (!sdk?.emulator)
2318
+ return null;
2319
+ const deviceDeps = buildAcquireDeviceDeps(sdk, abi, (m) => log(config, "debug", m));
2320
+ const running = await deviceDeps.listRunning();
2321
+ const capable = running.length > 0 || (await checkEmulatorAcceleration(sdk.emulator));
2322
+ if (!capable)
2323
+ return null;
2324
+ return { sdkRoot: sdk.sdkRoot, deviceDeps };
2325
+ }
2326
+ catch {
2327
+ return null;
2328
+ }
2329
+ }
2330
+ /* c8 ignore stop */
2331
+ /**
2332
+ * Async twin of probeIosToolchain for the warm executor: the sync probe's
2333
+ * spawnSync (up to 120s on a cold CoreSimulator service) would block the
2334
+ * event loop and stall every "concurrent" warm task. Pre-run both probe
2335
+ * commands with async spawns (in parallel), then hand the collected results
2336
+ * to the real probe via its injected runner — the decision logic and every
2337
+ * skip message stay in ONE place.
2338
+ */
2339
+ /* c8 ignore start — real xcode-select/xcrun spawns; the decision logic is
2340
+ probeIosToolchain's and is unit-tested there. */
2341
+ async function probeIosToolchainWarm() {
2342
+ if (process.platform !== "darwin")
2343
+ return probeIosToolchain();
2344
+ const runAsync = (command, args, timeout) => new Promise((resolve) => {
2345
+ let stdout = "";
2346
+ let stderr = "";
2347
+ try {
2348
+ const child = spawn(command, args, { windowsHide: true, timeout });
2349
+ child.stdout?.on("data", (d) => (stdout += String(d)));
2350
+ child.stderr?.on("data", (d) => (stderr += String(d)));
2351
+ child.on("error", () => resolve({ status: null, stdout, stderr }));
2352
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
2353
+ }
2354
+ catch {
2355
+ resolve({ status: null, stdout, stderr });
2356
+ }
2357
+ });
2358
+ const [xcodeSelect, simctl] = await Promise.all([
2359
+ runAsync("xcode-select", ["-p"], 15000),
2360
+ // Same generous ceiling as the sync probe's xcrun spawn: the first cold
2361
+ // simctl call launches CoreSimulatorService.
2362
+ runAsync("xcrun", ["simctl", "list", "devices", "available"], 120000),
2363
+ ]);
2364
+ return probeIosToolchain({
2365
+ run: (command) => (command === "xcrun" ? simctl : xcodeSelect),
2366
+ });
2367
+ }
2368
+ /* c8 ignore stop */
2369
+ /**
2370
+ * Bind the effectful per-kind warm task bodies to the run's state. Every
2371
+ * body upholds the warm contract: best-effort (a throw is caught by the
2372
+ * executor and recorded as failed), and memo effects identical to what the
2373
+ * first consuming context would have produced serially. Device boots resolve
2374
+ * at boot initiation (raceBootInitiation); the chromedriver prefetch is the
2375
+ * one task that awaits device readiness (it needs a live session).
2376
+ */
2377
+ /* c8 ignore start — thin dispatch over injected/imported effects; the pure
2378
+ pieces (planner, executor, raceBootInitiation, warmBrowserInstall) carry
2379
+ the unit coverage, and the wiring is exercised end-to-end by the fixture
2380
+ matrix + core-core.test.js. */
2381
+ function buildWarmTaskRunner({ config, runnerDetails, sizingJobs, appiumPool, installAttempts, warmUpResults, deviceRegistry, simulatorRegistry, resourceRegistry, }) {
2382
+ // One Android env probe per run, shared by device boots and the
2383
+ // chromedriver prefetch.
2384
+ let androidEnv;
2385
+ const getAndroidEnv = () => (androidEnv ??= resolveAndroidWarmEnv(config));
2386
+ // One iOS toolchain probe per run, async so the (potentially slow) xcrun
2387
+ // spawn never blocks the executor's event loop; device boots and the
2388
+ // driver install share the single result.
2389
+ let iosToolchain;
2390
+ const getIosToolchain = () => (iosToolchain ??= probeIosToolchainWarm());
2391
+ // Manual leases on the run's resource registry, for work that must
2392
+ // serialize on a named mutex but can't express its hold window as a task
2393
+ // tag (runResourceAware releases tags at task RESOLUTION, and warm tasks
2394
+ // deliberately resolve before their background work finishes). The
2395
+ // returned release is idempotent.
2396
+ const acquireLease = async (names) => {
2397
+ while (!resourceRegistry.tryAcquire(names)) {
2398
+ await resourceRegistry.waitForFree();
2399
+ }
2400
+ let released = false;
2401
+ return () => {
2402
+ if (released)
2403
+ return;
2404
+ released = true;
2405
+ resourceRegistry.release(names);
2406
+ };
2407
+ };
2408
+ const withRuntimeInstallLock = async (fn) => {
2409
+ const release = await acquireLease([RUNTIME_INSTALL_RESOURCE]);
2410
+ try {
2411
+ return await fn();
2412
+ }
2413
+ finally {
2414
+ release();
2415
+ }
2416
+ };
2417
+ // One android app preflight (driver install + Appium co-homing) per run,
2418
+ // always performed under the install lease.
2419
+ let androidPreflight;
2420
+ const getAndroidPreflight = () => (androidPreflight ??= withRuntimeInstallLock(() => appSurfacePreflight({ config, platform: "android" })));
2421
+ return async (task) => {
2422
+ switch (task.kind) {
2423
+ case "browser-install":
2424
+ return warmBrowserInstall({
2425
+ browserName: task.payload.browserName,
2426
+ config,
2427
+ runnerDetails,
2428
+ installAttempts,
2429
+ });
2430
+ case "driver-install": {
2431
+ // Mirror the per-context preflights' host-capability gates BEFORE
2432
+ // installing: they probe the environment first and skip without
2433
+ // installing on hosts that can never run the platform, and warm
2434
+ // must not install what those gates would refuse.
2435
+ if (task.payload.platform === "android") {
2436
+ const env = await getAndroidEnv();
2437
+ if (!env) {
2438
+ return {
2439
+ outcome: "skipped",
2440
+ note: "Android toolchain not ready; the driver install stays with the consuming context",
2441
+ };
2442
+ }
2443
+ }
2444
+ if (task.payload.platform === "ios") {
2445
+ const toolchain = await getIosToolchain();
2446
+ if (!toolchain.ok) {
2447
+ return { outcome: "skipped", note: toolchain.reason };
2448
+ }
2449
+ }
2450
+ // Same lazy-loaded install path appSurfacePreflight uses; it skips
2451
+ // packages already resolvable, so the later per-context preflight
2452
+ // finds the driver present and pays only Appium co-homing.
2453
+ const { ensureRuntimeInstalled } = await import("../runtime/loader.js");
2454
+ await ensureRuntimeInstalled([task.payload.driverPackage], {
2455
+ ctx: { cacheDir: config?.cacheDir },
2456
+ deps: { logger: (m) => log(config, "debug", m) },
2457
+ });
2458
+ return { outcome: "warmed" };
2459
+ }
2460
+ case "device-boot": {
2461
+ const desc = task.payload.desc;
2462
+ const onError = (error) => log(config, "warning", `Warm boot of '${task.name}' failed (a consuming context will retry): ${error?.message ?? String(error)}`);
2463
+ if (task.payload.platform === "android") {
2464
+ const env = await getAndroidEnv();
2465
+ if (!env) {
2466
+ return {
2467
+ outcome: "skipped",
2468
+ note: "Android toolchain not ready; device setup stays with the consuming context",
2469
+ };
2470
+ }
2471
+ // Hold the run's "android-emulator" mutex from before initiation
2472
+ // until the boot settles — in the BACKGROUND, past this task's
2473
+ // resolution — so warm's boot and any Phase-2 job's boot (which
2474
+ // tag the same name) can never run two emulators at once: on a
2475
+ // small CI runner concurrent boots starve each other and the
2476
+ // sessions that follow. Released via the acquire promise's settle
2477
+ // chain (both branches handled — no unhandled rejection), not a
2478
+ // finally, precisely because the task resolves first.
2479
+ const releaseEmulatorLease = await acquireLease([
2480
+ "android-emulator",
2481
+ ]);
2482
+ return raceBootInitiation({
2483
+ onError,
2484
+ startAcquire: (signalInitiated) => {
2485
+ const acquiring = acquireDevice({
2486
+ desc,
2487
+ registry: deviceRegistry,
2488
+ sdkRoot: env.sdkRoot,
2489
+ deps: wrapInitiationEffects(env.deviceDeps, ["createAvd", "boot"], signalInitiated),
2490
+ });
2491
+ acquiring.then(() => releaseEmulatorLease(), () => releaseEmulatorLease());
2492
+ return acquiring;
2493
+ },
2494
+ });
2495
+ }
2496
+ const toolchain = await getIosToolchain();
2497
+ if (!toolchain.ok) {
2498
+ return { outcome: "skipped", note: toolchain.reason };
2499
+ }
2500
+ const simDeps = buildAcquireSimulatorDeps((m) => log(config, "debug", m));
2501
+ return raceBootInitiation({
2502
+ onError,
2503
+ startAcquire: (signalInitiated) => acquireSimulator({
2504
+ desc,
2505
+ registry: simulatorRegistry,
2506
+ deps: wrapInitiationEffects(simDeps, ["create", "boot"], signalInitiated),
2507
+ }),
2508
+ });
2509
+ }
2510
+ case "wda-check": {
2511
+ const hit = locateManagedWda({ ctx: { cacheDir: config?.cacheDir } });
2512
+ if (hit) {
2513
+ return {
2514
+ outcome: "warmed",
2515
+ note: `prebuilt WebDriverAgent available (${hit.key})`,
2516
+ };
2517
+ }
2518
+ return {
2519
+ outcome: "skipped",
2520
+ note: "no prebuilt WebDriverAgent for the current toolchain — `doc-detective install ios` prebuilds it",
2521
+ };
2522
+ }
2523
+ case "session-probe": {
2524
+ if (!appiumPool) {
2525
+ return { outcome: "skipped", note: "no browser Appium pool" };
2526
+ }
2527
+ await warmUpContexts({
2528
+ jobs: sizingJobs,
2529
+ config,
2530
+ runnerDetails,
2531
+ appiumPool,
2532
+ installAttempts,
2533
+ warmUpResults,
2534
+ });
2535
+ const ok = [...warmUpResults.values()].filter((v) => v === "ok").length;
2536
+ const failed = warmUpResults.size - ok;
2537
+ // Failed combinations are a warm-level note, not a task failure:
2538
+ // they already have per-context recorded-skip semantics downstream.
2539
+ return {
2540
+ outcome: "warmed",
2541
+ note: `${ok} combination${ok === 1 ? "" : "s"} ok${failed ? `, ${failed} failed` : ""}`,
2542
+ };
2543
+ }
2544
+ case "chromedriver-prefetch":
2545
+ return prefetchMobileChromedriver({
2546
+ config,
2547
+ desc: task.payload.desc,
2548
+ deviceRegistry,
2549
+ getAndroidEnv,
2550
+ deps: {
2551
+ // The cache-mutating half (driver install + Appium co-homing)
2552
+ // runs once per run under the manual runtime-install lease, so
2553
+ // the prefetch task itself only holds its device tag while it
2554
+ // awaits readiness and runs the throwaway session.
2555
+ appSurfacePreflight: () => getAndroidPreflight(),
2556
+ acquireEmulatorLease: () => acquireLease(["android-emulator"]),
2557
+ },
2558
+ });
2559
+ }
2560
+ };
2561
+ }
2562
+ /* c8 ignore stop */
2563
+ /**
2564
+ * Pre-pay the on-device chromedriver download for android mobile-web: the
2565
+ * UiAutomator2 server only fetches a chromedriver matching the device's
2566
+ * Chrome at SESSION creation, so this task awaits the device (the one warm
2567
+ * task that blocks on readiness), opens a disposable mobile-web session on a
2568
+ * dedicated short-lived Appium server with the scoped autodownload feature —
2569
+ * the exact shape runContext's mobile-web branch uses — and tears both down.
2570
+ * The downloaded chromedriver lands in the shared cache, so the first real
2571
+ * session skips the download. Because the warm phase is awaited before
2572
+ * Phase 2 dispatch, this throwaway session can never overlap the first real
2573
+ * session on the same device. A throw is the executor's problem (recorded
2574
+ * as failed, run proceeds). Effects are injected for hermetic tests.
2575
+ */
2576
+ async function prefetchMobileChromedriver({ config, desc, deviceRegistry, getAndroidEnv, deps = {}, }) {
2577
+ const preflight = deps.appSurfacePreflight ?? appSurfacePreflight;
2578
+ const acquire = deps.acquireDevice ?? acquireDevice;
2579
+ const startServer = deps.startAppiumServer ?? startAppiumServer;
2580
+ const startDriver = deps.driverStart ?? driverStart;
2581
+ const kill = deps.killTree ?? killTree;
2582
+ const env = await getAndroidEnv();
2583
+ if (!env) {
2584
+ return {
2585
+ outcome: "skipped",
2586
+ note: "Android toolchain not ready; the first mobile-web session downloads chromedriver as needed",
2587
+ };
2588
+ }
2589
+ // Driver install + Appium co-homing, idempotent: usually a no-op after
2590
+ // the driver-install task (the shared runtime-install exclusivity tag
2591
+ // keeps the two from ever mutating the cache concurrently), and android
2592
+ // has no platform probes, so this is exactly the install half.
2593
+ const pre = await preflight({ config, platform: "android" });
2594
+ if (!pre.ok)
2595
+ return { outcome: "skipped", note: pre.reason };
2596
+ // Await the device. If the device-boot task already initiated this boot,
2597
+ // this acquire converges on the same registry entry and awaits its
2598
+ // in-flight `ready`; otherwise it performs the full acquire itself —
2599
+ // under the shared emulator lease, so a boot this task performs never
2600
+ // overlaps another emulator boot in the run.
2601
+ const releaseLease = deps.acquireEmulatorLease
2602
+ ? await deps.acquireEmulatorLease()
2603
+ : undefined;
2604
+ let acquired;
2605
+ try {
2606
+ acquired = await acquire({
2607
+ desc,
2608
+ registry: deviceRegistry,
2609
+ sdkRoot: env.sdkRoot,
2610
+ deps: env.deviceDeps,
2611
+ });
2612
+ }
2613
+ finally {
2614
+ releaseLease?.();
2615
+ }
2616
+ if ("skip" in acquired)
2617
+ return { outcome: "skipped", note: acquired.skip };
2618
+ let server;
2619
+ let driver;
2620
+ try {
2621
+ server = await startServer(pre.appiumEntry, config, undefined, {
2622
+ APPIUM_HOME: pre.appiumHome,
2623
+ ANDROID_HOME: env.sdkRoot,
2624
+ ANDROID_SDK_ROOT: env.sdkRoot,
2625
+ }, CHROMEDRIVER_AUTODOWNLOAD_ARGS);
2626
+ driver = await startDriver(buildMobileBrowserCapabilities({
2627
+ platform: "android",
2628
+ udid: acquired.entry.udid,
2629
+ cacheDir: getCacheDir({ cacheDir: config?.cacheDir }),
2630
+ }), server.port, 2, { cacheDir: config?.cacheDir });
2631
+ return {
2632
+ outcome: "warmed",
2633
+ note: `chromedriver ready for device '${acquired.entry.name}'`,
2634
+ };
2635
+ }
2636
+ finally {
2637
+ if (driver) {
2638
+ try {
2639
+ await driver.deleteSession();
2640
+ }
2641
+ catch {
2642
+ // best-effort teardown of the throwaway session
2643
+ }
2644
+ }
2645
+ if (server) {
2646
+ await kill(server.process?.pid);
2647
+ }
2648
+ }
2649
+ }
2180
2650
  /**
2181
2651
  * Pure predicate: does this spec carry TEST-level routing? True iff ANY of its
2182
2652
  * tests has a non-empty `onPass`/`onFail`/`onWarning`/`onSkip` array. This is
@@ -2814,7 +3284,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2814
3284
  contextReport.resultDescription = errorMessage;
2815
3285
  return contextReport;
2816
3286
  }
2817
- clog("debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
3287
+ if (logLevelEnabled(config, "debug"))
3288
+ clog("debug", `CONTEXT:\n${JSON.stringify(context, null, 2)}`);
2818
3289
  let driver;
2819
3290
  let appiumPort;
2820
3291
  // Multi-surface Phase 4 (ADR 01019): the context's browser-session registry.
@@ -2854,9 +3325,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
2854
3325
  // Chromedriver autodownload is an Appium insecure feature; opt in
2855
3326
  // scoped to the uiautomator2 driver on this run-owned server so it can
2856
3327
  // fetch the chromedriver matching the device's Chrome.
2857
- const extraArgs = mobileTarget === "android"
2858
- ? ["--allow-insecure", "uiautomator2:chromedriver_autodownload"]
2859
- : [];
3328
+ const extraArgs = mobileTarget === "android" ? CHROMEDRIVER_AUTODOWNLOAD_ARGS : [];
2860
3329
  // Server start + device boot are environment work: any failure there
2861
3330
  // (port pressure, an emulator that can't finish booting on this host,
2862
3331
  // simctl trouble) is a gating SKIP with the reason named — never a
@@ -3278,7 +3747,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3278
3747
  stepExecutionFailed = true;
3279
3748
  break;
3280
3749
  }
3281
- clog("debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
3750
+ if (logLevelEnabled(config, "debug"))
3751
+ clog("debug", `STEP:\n${JSON.stringify(step, null, 2)}`);
3282
3752
  if (step.unsafe && runnerDetails.allowUnsafeSteps === false) {
3283
3753
  clog("warning", `Skipping unsafe step: ${step.description} in test ${test.testId} context ${context.contextId}`);
3284
3754
  // Mark as skipped
@@ -3380,7 +3850,8 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3380
3850
  processRegistry: processRegistry,
3381
3851
  appSession: appSession,
3382
3852
  });
3383
- clog("debug", `RESULT: ${r.status}\n${JSON.stringify(r, null, 2)}`);
3853
+ if (logLevelEnabled(config, "debug"))
3854
+ clog("debug", `RESULT: ${r.status}\n${JSON.stringify(r, null, 2)}`);
3384
3855
  r.result = r.status;
3385
3856
  r.resultDescription = r.description;
3386
3857
  delete r.status;
@@ -3968,7 +4439,12 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
3968
4439
  // Start one Appium server on a free port and resolve once it answers /status.
3969
4440
  // Each concurrent runner gets its own server (own port) so parallel contexts
3970
4441
  // never create sessions on the same Appium instance.
3971
- async function startAppiumServer(appiumEntry, config, display, extraEnv,
4442
+ // Spawn an Appium server process WITHOUT waiting for readiness. Split out from
4443
+ // startAppiumServer so the browser-pool startup (below) can spawn servers
4444
+ // SERIALLY — preserving the findFreePort close-to-rebind race protection and
4445
+ // avoiding a startup CPU spike — while OVERLAPPING their readiness polls. A
4446
+ // single-server caller uses startAppiumServer, which spawns then awaits.
4447
+ async function spawnAppiumServer(appiumEntry, config, display, extraEnv,
3972
4448
  // Extra CLI args for the server, e.g. the scoped `--allow-insecure`
3973
4449
  // chromedriver-autodownload opt-in for android mobile-web sessions.
3974
4450
  extraArgs) {
@@ -3996,8 +4472,15 @@ extraArgs) {
3996
4472
  });
3997
4473
  proc.stdout.on("data", () => { });
3998
4474
  proc.stderr.on("data", () => { });
4475
+ return { port, process: proc, display };
4476
+ }
4477
+ async function startAppiumServer(appiumEntry, config, display, extraEnv,
4478
+ // Extra CLI args for the server, e.g. the scoped `--allow-insecure`
4479
+ // chromedriver-autodownload opt-in for android mobile-web sessions.
4480
+ extraArgs) {
4481
+ const server = await spawnAppiumServer(appiumEntry, config, display, extraEnv, extraArgs);
3999
4482
  try {
4000
- await appiumIsReady(port);
4483
+ await appiumIsReady(server.port);
4001
4484
  }
4002
4485
  catch (error) {
4003
4486
  // appiumIsReady threw or timed out — the spawned child is still alive and
@@ -4005,29 +4488,48 @@ extraArgs) {
4005
4488
  // propagating so subsequent runs don't trip on the stale state. Awaited
4006
4489
  // so the process is confirmed gone before this function returns control
4007
4490
  // to the caller.
4008
- await killTree(proc?.pid);
4491
+ await killTree(server.process?.pid);
4009
4492
  throw error;
4010
4493
  }
4011
- log(config, "debug", `Appium is ready on port ${port}.`);
4012
- return { port, process: proc, display };
4494
+ log(config, "debug", `Appium is ready on port ${server.port}.`);
4495
+ return server;
4013
4496
  }
4014
- // Delay execution until Appium server is available.
4015
- async function appiumIsReady(port, timeoutMs = 120000) {
4016
- let isReady = false;
4497
+ // Per-probe HTTP timeout for the Appium `/status` check. Bounds a single
4498
+ // hung request so the overall readiness timeout can still fire; a healthy
4499
+ // server answers in milliseconds.
4500
+ const STATUS_PROBE_TIMEOUT_MS = 10000;
4501
+ // Delay execution until Appium server is available. Probe `/status`
4502
+ // IMMEDIATELY, then poll on a short 250ms interval until ready or the overall
4503
+ // timeout — a server that is already up returns in ~one round-trip instead of
4504
+ // paying a fixed leading 1s sleep (the old loop slept before its first probe).
4505
+ // `probe`/`sleep` are injectable for hermetic unit tests; the overall timeout
4506
+ // cap (default 120s) is unchanged.
4507
+ async function appiumIsReady(port, timeoutMs = 120000, deps = {}) {
4508
+ const probe = deps.probe ??
4509
+ (async (p) => {
4510
+ try {
4511
+ // Bound each probe: without a per-request timeout a hung /status
4512
+ // response would block this await indefinitely, and the overall
4513
+ // `timeoutMs` guard (checked only between probes) could never fire.
4514
+ const resp = await axios.get(`http://127.0.0.1:${p}/status`, {
4515
+ timeout: STATUS_PROBE_TIMEOUT_MS,
4516
+ });
4517
+ return resp.status === 200;
4518
+ }
4519
+ catch {
4520
+ return false;
4521
+ }
4522
+ });
4523
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
4017
4524
  const start = Date.now();
4018
- while (!isReady) {
4525
+ while (true) {
4526
+ if (await probe(port))
4527
+ return true;
4019
4528
  if (Date.now() - start > timeoutMs) {
4020
4529
  throw new Error(`Appium server on port ${port} failed to start within ${timeoutMs / 1000} seconds`);
4021
4530
  }
4022
- await new Promise((resolve) => setTimeout(resolve, 1000));
4023
- try {
4024
- let resp = await axios.get(`http://127.0.0.1:${port}/status`);
4025
- if (resp.status === 200)
4026
- isReady = true;
4027
- }
4028
- catch { }
4531
+ await sleep(250);
4029
4532
  }
4030
- return isReady;
4031
4533
  }
4032
4534
  // Start the Appium driver specified in `capabilities`.
4033
4535
  async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {