doc-detective 4.35.0 → 4.37.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 (97) hide show
  1. package/dist/cli.js +9 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/common/src/schemas/schemas.json +6702 -393
  4. package/dist/common/src/types/generated/annotate_v3.d.ts +48 -0
  5. package/dist/common/src/types/generated/annotate_v3.d.ts.map +1 -1
  6. package/dist/common/src/types/generated/annotation_v3.d.ts +24 -0
  7. package/dist/common/src/types/generated/annotation_v3.d.ts.map +1 -1
  8. package/dist/common/src/types/generated/closeSurface_v3.d.ts +2 -2
  9. package/dist/common/src/types/generated/config_v3.d.ts +12 -0
  10. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  11. package/dist/common/src/types/generated/context_v3.d.ts +4 -0
  12. package/dist/common/src/types/generated/context_v3.d.ts.map +1 -1
  13. package/dist/common/src/types/generated/report_v3.d.ts +12 -0
  14. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  15. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +20 -0
  16. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  17. package/dist/common/src/types/generated/screenshot_v3.d.ts +25 -1
  18. package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
  19. package/dist/common/src/types/generated/spec_v3.d.ts +8 -0
  20. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  21. package/dist/common/src/types/generated/step_v3.d.ts +162 -6
  22. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  23. package/dist/common/src/types/generated/surface_v3.d.ts +1 -1
  24. package/dist/common/src/types/generated/swipe_v3.d.ts +2 -2
  25. package/dist/common/src/types/generated/test_v3.d.ts +336 -12
  26. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  27. package/dist/common/src/types/generated/type_v3.d.ts +1 -1
  28. package/dist/core/annotations/geometry.d.ts +2 -1
  29. package/dist/core/annotations/geometry.d.ts.map +1 -1
  30. package/dist/core/annotations/geometry.js +11 -1
  31. package/dist/core/annotations/geometry.js.map +1 -1
  32. package/dist/core/tests/activeSurface.d.ts +46 -0
  33. package/dist/core/tests/activeSurface.d.ts.map +1 -0
  34. package/dist/core/tests/activeSurface.js +141 -0
  35. package/dist/core/tests/activeSurface.js.map +1 -0
  36. package/dist/core/tests/appSurface.d.ts +2 -0
  37. package/dist/core/tests/appSurface.d.ts.map +1 -1
  38. package/dist/core/tests/appSurface.js +24 -13
  39. package/dist/core/tests/appSurface.js.map +1 -1
  40. package/dist/core/tests/browserSessions.d.ts +3 -0
  41. package/dist/core/tests/browserSessions.d.ts.map +1 -1
  42. package/dist/core/tests/browserSessions.js +3 -0
  43. package/dist/core/tests/browserSessions.js.map +1 -1
  44. package/dist/core/tests/click.d.ts +3 -1
  45. package/dist/core/tests/click.d.ts.map +1 -1
  46. package/dist/core/tests/click.js +3 -1
  47. package/dist/core/tests/click.js.map +1 -1
  48. package/dist/core/tests/findElement.d.ts +4 -1
  49. package/dist/core/tests/findElement.d.ts.map +1 -1
  50. package/dist/core/tests/findElement.js +52 -27
  51. package/dist/core/tests/findElement.js.map +1 -1
  52. package/dist/core/tests/saveScreenshot.d.ts +4 -1
  53. package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
  54. package/dist/core/tests/saveScreenshot.js +70 -54
  55. package/dist/core/tests/saveScreenshot.js.map +1 -1
  56. package/dist/core/tests/startSurface.d.ts +3 -1
  57. package/dist/core/tests/startSurface.d.ts.map +1 -1
  58. package/dist/core/tests/startSurface.js +36 -2
  59. package/dist/core/tests/startSurface.js.map +1 -1
  60. package/dist/core/tests/swipe.d.ts +4 -1
  61. package/dist/core/tests/swipe.d.ts.map +1 -1
  62. package/dist/core/tests/swipe.js +37 -25
  63. package/dist/core/tests/swipe.js.map +1 -1
  64. package/dist/core/tests/typeKeys.d.ts +3 -1
  65. package/dist/core/tests/typeKeys.d.ts.map +1 -1
  66. package/dist/core/tests/typeKeys.js +42 -36
  67. package/dist/core/tests/typeKeys.js.map +1 -1
  68. package/dist/core/tests.d.ts +9 -2
  69. package/dist/core/tests.d.ts.map +1 -1
  70. package/dist/core/tests.js +257 -15
  71. package/dist/core/tests.js.map +1 -1
  72. package/dist/core/utils.d.ts +7 -1
  73. package/dist/core/utils.d.ts.map +1 -1
  74. package/dist/core/utils.js +141 -2
  75. package/dist/core/utils.js.map +1 -1
  76. package/dist/hints/context.d.ts +15 -0
  77. package/dist/hints/context.d.ts.map +1 -1
  78. package/dist/hints/context.js +115 -0
  79. package/dist/hints/context.js.map +1 -1
  80. package/dist/hints/hints.d.ts.map +1 -1
  81. package/dist/hints/hints.js +38 -0
  82. package/dist/hints/hints.js.map +1 -1
  83. package/dist/hints/types.d.ts +15 -0
  84. package/dist/hints/types.d.ts.map +1 -1
  85. package/dist/index.cjs +7565 -884
  86. package/dist/reporters/htmlReporter.js +3 -3
  87. package/dist/runtime/browserStepKeys.d.ts +37 -0
  88. package/dist/runtime/browserStepKeys.d.ts.map +1 -1
  89. package/dist/runtime/browserStepKeys.js +73 -0
  90. package/dist/runtime/browserStepKeys.js.map +1 -1
  91. package/dist/runtime/inferRuntimeNeeds.d.ts.map +1 -1
  92. package/dist/runtime/inferRuntimeNeeds.js +18 -11
  93. package/dist/runtime/inferRuntimeNeeds.js.map +1 -1
  94. package/dist/utils.d.ts.map +1 -1
  95. package/dist/utils.js +8 -0
  96. package/dist/utils.js.map +1 -1
  97. package/package.json +2 -1
@@ -13,9 +13,9 @@ 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
- import { BROWSER_STEP_KEYS as driverActions, startSurfaceDescriptors, stepOpensBrowserSurface, } from "../runtime/browserStepKeys.js";
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, realizeViewport, isViewportFloored, } from "./utils.js";
18
+ import { log, logLevelEnabled, replaceEnvs, selectSpecsForRun, findFreePort, runConcurrent, runResourceAware, createResourceRegistry, rollUpResults, createAppiumPool, getRunOutputDir, runArchivesArtifacts, sanitizeFilesystemName, evaluateContextRequirements, isRetryableSessionError, classifyContextRetry, realizeViewport, isViewportFloored, } from "./utils.js";
19
19
  import axios from "axios";
20
20
  import { instantiateCursor } from "./tests/moveTo.js";
21
21
  import { goTo } from "./tests/goTo.js";
@@ -39,6 +39,7 @@ import { runCode } from "./tests/runCode.js";
39
39
  import { closeSurface } from "./tests/closeSurface.js";
40
40
  import { createAppSessionState, appSurfacePreflight, isAppDriverRequired, stepTargetsAppSurface, teardownAppSession, APP_DRIVER_PLATFORMS, probeIosToolchain, } from "./tests/appSurface.js";
41
41
  import { startSurfaceStep } from "./tests/startSurface.js";
42
+ import { createActiveSurfaceTracker, } from "./tests/activeSurface.js";
42
43
  import { isMobileTargetPlatform } from "./tests/mobilePlatform.js";
43
44
  import { mobileBrowserGate, buildMobileBrowserCapabilities, CHROMEDRIVER_AUTODOWNLOAD_ARGS, } from "./tests/mobileBrowser.js";
44
45
  import { planWarmTasks, executeWarmTasks, raceBootInitiation, wrapInitiationEffects, RUNTIME_INSTALL_RESOURCE, } from "./warmPhase.js";
@@ -64,7 +65,7 @@ import http from "node:http";
64
65
  import https from "node:https";
65
66
  import { fileURLToPath } from "node:url";
66
67
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
67
- 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, };
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, };
68
69
  // exports.driverStart = driverStart;
69
70
  // Browser names getDriverCapabilities knows how to build caps for. `safari` is
70
71
  // rewritten to `webkit` during context resolution, so both appear here.
@@ -471,17 +472,25 @@ function isSupportedContext({ context, apps, platform }) {
471
472
  return Boolean(isSupportedApp && isSupportedPlatform);
472
473
  }
473
474
  // Like isDriverRequired, but only counts driver steps that need a BROWSER: a
474
- // step whose payload targets an app surface (object form) is driven by the
475
- // app session instead, and the synthetic autoRecord capture is an ffmpeg
476
- // screen grab — neither may force a default browser into existence (in a
477
- // browser test the authored steps already require one, so excluding the
478
- // synthetic step never changes the outcome there).
475
+ // step whose payload targets an app or process surface (object form) is
476
+ // driven by the app session / process registry instead, and the synthetic
477
+ // autoRecord capture is an ffmpeg screen grab — none may force a default
478
+ // browser into existence (in a browser test the authored steps already
479
+ // require one, so excluding the synthetic step never changes the outcome
480
+ // there). Uniform routing (ADR 01081) adds one more exclusion: a
481
+ // surface-less interaction step in a test that opens or targets a
482
+ // non-browser surface routes to the active surface at runtime, so it doesn't
483
+ // force a browser either — a test with no such signal keeps the browser
484
+ // default unchanged.
479
485
  function isBrowserRequired({ test }) {
480
486
  if (!Array.isArray(test?.steps))
481
487
  return false;
488
+ const hasNonBrowserSignal = testHasNonBrowserSurfaceSignal(test.steps);
482
489
  return test.steps.some((step) => !step?.__autoRecord &&
483
490
  ((driverActions.some((action) => typeof step[action] !== "undefined") &&
484
- !stepTargetsAppSurface(step)) ||
491
+ !stepTargetsAppSurface(step) &&
492
+ !stepTargetsProcessSurface(step) &&
493
+ !(hasNonBrowserSignal && stepIsSurfacelessInteraction(step))) ||
485
494
  // Phase 6: `startSurface: { browser: … }` opens a browser session
486
495
  // (the goTo-opener sibling); app/process descriptors don't.
487
496
  stepOpensBrowserSurface(step)));
@@ -850,6 +859,14 @@ function buildFallbackCandidates({ requestedName, explicit, policy, availableApp
850
859
  function resolveBrowserFallbackPolicy({ context, config, }) {
851
860
  return context?.browserFallback || config?.browserFallback || "auto";
852
861
  }
862
+ // Resolve the mid-run session-death context-retry budget (the `retries` policy):
863
+ // how many times to re-run a whole context on a fresh session when its session
864
+ // dies mid-run. Context overrides config; default 1. Uses `??` (NOT `||`) so an
865
+ // explicit `retries: 0` (disable) is preserved instead of falling through to the
866
+ // default the way a falsy `||` would. Pure and exported for unit testing.
867
+ function resolveRetryPolicy({ context, config, }) {
868
+ return context?.retries ?? config?.retries ?? 1;
869
+ }
853
870
  /**
854
871
  * Whether to attempt a driver repair before falling back away from a browser
855
872
  * whose session just failed to start. We only repair the *requested* engine
@@ -958,6 +975,8 @@ async function runViaApi({ resolvedTests, apiKey, config = {} }) {
958
975
  contexts: { pass: 0, fail: 0, warning: 0, skipped: 0 },
959
976
  steps: { pass: 0, fail: 0, warning: 0, skipped: 0 },
960
977
  },
978
+ // Nothing ran, but the shape stays parity with runSpecs' short-circuit.
979
+ durationMs: 0,
961
980
  specs: [],
962
981
  };
963
982
  }
@@ -1075,6 +1094,14 @@ async function runViaApi({ resolvedTests, apiKey, config = {} }) {
1075
1094
  */
1076
1095
  async function runSpecs({ resolvedTests }) {
1077
1096
  const config = resolvedTests.config;
1097
+ // Run-level wall clock, scoped to the EXECUTION phase — detection,
1098
+ // resolution, and JIT dependency installs happen in runTests before this
1099
+ // point and are deliberately excluded (see ADR 01083). Started before the
1100
+ // filter short-circuit so a zero-spec run reports a real (tiny) duration
1101
+ // rather than omitting the field. Unlike the spec/test sums, this IS elapsed
1102
+ // time — so under concurrency it can be LESS than the sum of the per-spec
1103
+ // durations.
1104
+ const runStart = Date.now();
1078
1105
  // Narrow the spec set to what specFilter / testFilter allow before running.
1079
1106
  // Filtered-out specs / tests do not appear in the report (true filter, not
1080
1107
  // skip). Pass-through when neither filter is set.
@@ -1095,6 +1122,7 @@ async function runSpecs({ resolvedTests }) {
1095
1122
  contexts: { pass: 0, fail: 0, warning: 0, skipped: 0 },
1096
1123
  steps: { pass: 0, fail: 0, warning: 0, skipped: 0 },
1097
1124
  },
1125
+ durationMs: Date.now() - runStart,
1098
1126
  specs: [],
1099
1127
  };
1100
1128
  }
@@ -1623,7 +1651,7 @@ async function runSpecs({ resolvedTests }) {
1623
1651
  // ordering to a single pool over input-ordered jobs.
1624
1652
  const runJob = async (job) => {
1625
1653
  try {
1626
- job.contexts[job.slot] = await runContext({
1654
+ job.contexts[job.slot] = await runContextWithRetries({
1627
1655
  config,
1628
1656
  spec: job.spec,
1629
1657
  test: job.test,
@@ -1729,7 +1757,9 @@ async function runSpecs({ resolvedTests }) {
1729
1757
  // Phase 3: roll results up the tree and count the summary in one
1730
1758
  // deterministic pass after all contexts have finished.
1731
1759
  for (const specReport of report.specs) {
1760
+ let specDurationMs = 0;
1732
1761
  for (const testReport of specReport.tests) {
1762
+ let testDurationMs = 0;
1733
1763
  for (const contextReport of testReport.contexts) {
1734
1764
  // Every slot is assigned by the pool callback (even on crash), so
1735
1765
  // this guard should never fire — it documents the invariant and
@@ -1738,13 +1768,30 @@ async function runSpecs({ resolvedTests }) {
1738
1768
  continue;
1739
1769
  for (const stepReport of contextReport.steps) {
1740
1770
  report.summary.steps[stepReport.result.toLowerCase()]++;
1771
+ // Default here rather than at each construction site: this pass is
1772
+ // the only place that sees EVERY node from both execution paths
1773
+ // (flat pool and routed sequencer), real and synthetic alike. A
1774
+ // node that never ran — a guard-skipped context, a routing marker
1775
+ // — has no clock, and a future synthetic site can't forget the
1776
+ // field. Nodes that ran keep their measured value (ADR 01083).
1777
+ stepReport.durationMs ??= 0;
1741
1778
  }
1742
1779
  report.summary.contexts[contextReport.result.toLowerCase()]++;
1780
+ contextReport.durationMs ??= 0;
1781
+ testDurationMs += contextReport.durationMs;
1743
1782
  }
1744
1783
  testReport.result = rollUpResults(testReport.contexts.filter(Boolean));
1784
+ // Test and spec durations are SUMS of their children, not wall-clock
1785
+ // spans: contexts from every test and spec share one concurrent pool,
1786
+ // so a span would count idle time while unrelated specs ran. The sum
1787
+ // is total work — what slow-test triage and JUnit's `<testsuite time>`
1788
+ // both want. See ADR 01083.
1789
+ testReport.durationMs = testDurationMs;
1790
+ specDurationMs += testDurationMs;
1745
1791
  report.summary.tests[testReport.result.toLowerCase()]++;
1746
1792
  }
1747
1793
  specReport.result = rollUpResults(specReport.tests);
1794
+ specReport.durationMs = specDurationMs;
1748
1795
  report.summary.specs[specReport.result.toLowerCase()]++;
1749
1796
  }
1750
1797
  }
@@ -1821,6 +1868,9 @@ async function runSpecs({ resolvedTests }) {
1821
1868
  };
1822
1869
  }
1823
1870
  }
1871
+ // Stamped last, after teardown and any Heretto upload, so the number matches
1872
+ // the elapsed time a user actually observes.
1873
+ report.durationMs = Date.now() - runStart;
1824
1874
  return report;
1825
1875
  }
1826
1876
  /**
@@ -2008,7 +2058,7 @@ async function runRoutedSpec({ spec, specReport, specGuardSkip, config, runnerDe
2008
2058
  // limit===1 keep the byte-identical runConcurrent path.
2009
2059
  const runRoutedJob = async (job) => {
2010
2060
  try {
2011
- job.contexts[job.slot] = await runContext({
2061
+ job.contexts[job.slot] = await runContextWithRetries({
2012
2062
  config,
2013
2063
  spec: job.spec,
2014
2064
  test: job.test,
@@ -3028,6 +3078,11 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3028
3078
  // context that passes its preflight (phase A3b). Declared here so the mobile
3029
3079
  // branch can set it and fall through to the shared step-execution path.
3030
3080
  let appSession;
3081
+ // Cross-kind active-surface tracker (ADR 01081): one MRU per context,
3082
+ // shared by the browser session registry, the app session, and the process
3083
+ // lanes, so surface-less steps route to the most recently active surface
3084
+ // regardless of kind.
3085
+ const surfaceTracker = createActiveSurfaceTracker();
3031
3086
  // Mobile web (phase A5): set when the mobile preflight resolved a device
3032
3087
  // browser for this context. The try block below then opens the browser
3033
3088
  // session on the device (through the app session's Appium server) instead
@@ -3142,6 +3197,10 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3142
3197
  appSession.appiumEntry = preflight.appiumEntry;
3143
3198
  appSession.appiumHome = preflight.appiumHome;
3144
3199
  }
3200
+ // Whichever branch created the app session (android/ios/desktop), it shares
3201
+ // the context's active-surface tracker with the browser registry below.
3202
+ if (appSession)
3203
+ appSession.tracker = surfaceTracker;
3145
3204
  // If a driver is required but no browser could be resolved (e.g.
3146
3205
  // getDefaultBrowser found nothing installed, or the context supplied a
3147
3206
  // browser object with no name), skip with an explicit reason instead of
@@ -3282,6 +3341,10 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3282
3341
  // WARNING when an explicitly pinned engine was substituted.
3283
3342
  let fellBackNote = "";
3284
3343
  let fellBackPinned = false;
3344
+ // Set by the post-loop liveness probe: true when the context failed AND its
3345
+ // session was found dead mid-run. The caller (runContextWithRetries) reads
3346
+ // this off the returned report to decide whether to retry on a fresh session.
3347
+ let sessionDiedMidRun = false;
3285
3348
  if (driverRequired && !appiumPool) {
3286
3349
  throw new Error("Browser driver requested but no Appium server pool was created; " +
3287
3350
  "the pool sizing (browserJobCount) and this context's isBrowserRequired " +
@@ -3391,6 +3454,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3391
3454
  throw new Error("Additional browser sessions aren't supported on a managed device; the device browser is the context's only browser surface.");
3392
3455
  },
3393
3456
  isNameTaken: (name) => !!processRegistry?.has(name),
3457
+ tracker: surfaceTracker,
3394
3458
  });
3395
3459
  registerSession(browserSessions, {
3396
3460
  name: String(mobileWebBrowserName).toLowerCase(),
@@ -3591,6 +3655,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3591
3655
  return res.driver;
3592
3656
  },
3593
3657
  isNameTaken: (name) => !!processRegistry?.has(name),
3658
+ tracker: surfaceTracker,
3594
3659
  });
3595
3660
  registerSession(browserSessions, {
3596
3661
  name: String(startedName).toLowerCase(),
@@ -3844,6 +3909,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3844
3909
  // it. Surface-less browser steps in that (pathological) state fail on the
3845
3910
  // dead session — acceptable; the run closed its own browser mid-test.
3846
3911
  const runStepOnce = async () => {
3912
+ const stepStart = Date.now();
3847
3913
  const r = await runStep({
3848
3914
  config: config,
3849
3915
  context: context,
@@ -3856,6 +3922,7 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3856
3922
  },
3857
3923
  processRegistry: processRegistry,
3858
3924
  appSession: appSession,
3925
+ surfaceTracker: surfaceTracker,
3859
3926
  });
3860
3927
  if (logLevelEnabled(config, "debug"))
3861
3928
  clog("debug", `RESULT: ${r.status}\n${JSON.stringify(r, null, 2)}`);
@@ -3863,7 +3930,12 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
3863
3930
  r.resultDescription = r.description;
3864
3931
  delete r.status;
3865
3932
  delete r.description;
3866
- return { ...step, ...r };
3933
+ // Stamp AFTER the spread: `step` may carry an authored `duration`
3934
+ // input (a click's press duration, an annotation's display duration),
3935
+ // and `r` is the runStep result — neither may clobber the timing.
3936
+ // On retry the loop below discards this report wholesale, so the
3937
+ // surviving value is the FINAL attempt's, per ADR 01083.
3938
+ return { ...step, ...r, durationMs: Date.now() - stepStart };
3867
3939
  };
3868
3940
  // Run the step, then resolve routing. A `retry` decision re-runs the step
3869
3941
  // (up to `limit` retries — so `limit + 1` total runs — waiting `delay`
@@ -4034,6 +4106,59 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
4034
4106
  contextReport,
4035
4107
  });
4036
4108
  }
4109
+ // Mid-run session-death detection for the `retries` context-retry policy.
4110
+ // A dead session's step FAIL is indistinguishable from a real assertion FAIL
4111
+ // at the result level (handlers catch driver errors and return FAIL), so if
4112
+ // any step failed we probe the session directly here — while it is still
4113
+ // registered, before the finally tears it down. A dead session means the
4114
+ // FAIL is spurious and the whole context can be retried on a fresh session; a
4115
+ // live session means the FAIL is real and stands. Recording sweeps already
4116
+ // ran above, so the probe never races an in-flight capture.
4117
+ if (contextReport.steps.some((s) => s.result === "FAIL")) {
4118
+ // Collect EVERY session the context holds — a multi-surface browser
4119
+ // context plus an app session — not just the first, so a dead app/native
4120
+ // session behind a live browser (or vice versa) is still caught. Which of
4121
+ // these each check applies to differs, and classifyContextRetry owns that
4122
+ // distinction. Primary session first (Map values() is insertion-ordered).
4123
+ const probeDrivers = sessionDrivers(browserSessions, driver);
4124
+ if (appSession?.recordingHost)
4125
+ probeDrivers.push(appSession.recordingHost);
4126
+ // Which sessions each probe applies to, and how they rank, lives in
4127
+ // classifyContextRetry so it can be unit tested without a real context.
4128
+ // A dead session is the silent case: it needs no explanation beyond the
4129
+ // retry warning the wrapper already logs. The alive-but-unusable cases do,
4130
+ // because "the session responded and we retried anyway" is otherwise
4131
+ // surprising in a log.
4132
+ const retryReason = await classifyContextRetry(probeDrivers);
4133
+ if (retryReason) {
4134
+ sessionDiedMidRun = true;
4135
+ if (retryReason === "page-broken") {
4136
+ clog("debug", "Context session is alive but on a browser error page; treating it as a broken context for retry.");
4137
+ }
4138
+ else if (retryReason === "unnavigated") {
4139
+ clog("debug", "Context session is alive but still on its initial blank document (never navigated); treating it as a broken context for retry.");
4140
+ }
4141
+ }
4142
+ if (!sessionDiedMidRun &&
4143
+ probeDrivers.length > 0 &&
4144
+ logLevelEnabled(config, "debug") &&
4145
+ typeof probeDrivers[0].getUrl === "function") {
4146
+ // Diagnostic for whatever live-session modes remain uncovered: log the
4147
+ // page URL of a live-session FAIL that was NOT retried. This is how the
4148
+ // long-running `windows-chrome` flake was characterized — every
4149
+ // occurrence, across both the recording and nav-capture bundles, logged
4150
+ // `url=data:,`, which ADR 01084 now retries. What's left for it to catch
4151
+ // is the same-URL-blank mode (page blanks without changing URL), which
4152
+ // stays unretried because it can't be told apart from a genuine
4153
+ // element-not-found on a correctly-loaded page.
4154
+ try {
4155
+ clog("debug", `Context FAILed on a live, non-error-page session (url=${await probeDrivers[0].getUrl()}); not retried.`);
4156
+ }
4157
+ catch {
4158
+ /* best-effort diagnostic */
4159
+ }
4160
+ }
4161
+ }
4037
4162
  }
4038
4163
  finally {
4039
4164
  // Safety net: if the context threw before the normal sweep above, recordings
@@ -4119,8 +4244,108 @@ async function runContext({ config, spec, test, context, runnerDetails, appiumPo
4119
4244
  ? `${fellBackNote} ${contextReport.resultDescription}`
4120
4245
  : fellBackNote;
4121
4246
  }
4247
+ // Internal hint for runContextWithRetries — a FAIL whose session died mid-run
4248
+ // is retryable. Non-enumerable so it never leaks into the serialized report,
4249
+ // and only set on the retryable case so the wrapper's check is a plain read.
4250
+ if (contextReport.result === "FAIL" && sessionDiedMidRun) {
4251
+ Object.defineProperty(contextReport, "_sessionDied", {
4252
+ value: true,
4253
+ enumerable: false,
4254
+ configurable: true,
4255
+ });
4256
+ }
4122
4257
  return contextReport;
4123
4258
  }
4259
+ // Context fields runContext mutates non-idempotently: `openApi` appends the
4260
+ // config's definitions, and `browser` narrows to a fallback engine/headless
4261
+ // mode. A retry re-invokes runContext, so these are snapshotted and restored
4262
+ // before each attempt (`__display`/`__displaySize` too, so a retry re-resolves
4263
+ // them rather than reusing a stale display) — everything else runContext derives
4264
+ // fresh (contextReport is rebuilt each call; step IDs assign idempotently).
4265
+ const RUN_CONTEXT_MUTATED_KEYS = ["openApi", "browser", "__display", "__displaySize"];
4266
+ // SHALLOW clone — one level. Sufficient for the current RUN_CONTEXT_MUTATED_KEYS
4267
+ // (`openApi` entries are appended, never mutated in place; `browser`/`__display*`
4268
+ // are flat). If a future mutated field nests a value that runContext mutates *in
4269
+ // place*, the snapshot and the live context would share that nested reference and
4270
+ // restore wouldn't protect it — deepen this (or that key's snapshot) then.
4271
+ function cloneMutable(value) {
4272
+ if (Array.isArray(value))
4273
+ return [...value];
4274
+ if (value && typeof value === "object")
4275
+ return { ...value };
4276
+ return value;
4277
+ }
4278
+ // Runs a context and retries the WHOLE context on a fresh session when its
4279
+ // session dies mid-run — an early step passes, then a later step fails on a
4280
+ // now-dead session (WebDriver ECONNREFUSED / invalid session id, or an element
4281
+ // that can't be found because the DOM is dead). Bounded by the resolved
4282
+ // `retries` policy (config/context, default 1). Detection is runContext's active
4283
+ // liveness probe, surfaced as the non-enumerable `_sessionDied` flag on a FAIL
4284
+ // report, so a live-session assertion FAIL is NEVER retried — a real bug still
4285
+ // fails all attempts. Retrying re-invokes runContext, which re-runs setup,
4286
+ // re-provisions every session, and restarts recordings cleanly; the job keeps
4287
+ // its concurrency slot and any exclusive resource (display / native-app-driver /
4288
+ // android-emulator), so only the Appium pool port churns. `runContextFn` is
4289
+ // injectable for unit testing. Exported for that test.
4290
+ async function runContextWithRetries(args, runContextFn = runContext,
4291
+ // Backoff before each retry. Injectable so unit tests pass `() => 0` instead
4292
+ // of paying the real 500ms-per-attempt sleep.
4293
+ delayMs = (attempt) => 500 * (attempt + 1)) {
4294
+ const { context, config } = args;
4295
+ // Time each attempt here rather than inside runContext: runContext has a
4296
+ // dozen exits (eleven early `requires`/preflight SKIPPED returns plus the
4297
+ // normal one), and this wrapper is its only caller, so one clock here stamps
4298
+ // every one of them. A retried context keeps the FINAL attempt's elapsed
4299
+ // time — the attempt the reported `result` describes (ADR 01083).
4300
+ const timedRunContext = async (a) => {
4301
+ const start = Date.now();
4302
+ const report = await runContextFn(a);
4303
+ if (report && typeof report === "object") {
4304
+ report.durationMs = Date.now() - start;
4305
+ }
4306
+ return report;
4307
+ };
4308
+ const retries = resolveRetryPolicy({ context, config });
4309
+ if (!(retries > 0))
4310
+ return timedRunContext(args);
4311
+ // Snapshot the non-idempotent context fields so each retry starts from the
4312
+ // originally-requested state instead of the prior attempt's narrowed one.
4313
+ const had = {};
4314
+ const snapshot = {};
4315
+ for (const key of RUN_CONTEXT_MUTATED_KEYS) {
4316
+ had[key] = key in context;
4317
+ if (had[key])
4318
+ snapshot[key] = cloneMutable(context[key]);
4319
+ }
4320
+ const restore = () => {
4321
+ for (const key of RUN_CONTEXT_MUTATED_KEYS) {
4322
+ if (!had[key])
4323
+ delete context[key];
4324
+ else
4325
+ context[key] = cloneMutable(snapshot[key]);
4326
+ }
4327
+ };
4328
+ let report;
4329
+ let attempt = 0;
4330
+ for (;; attempt++) {
4331
+ report = await timedRunContext(args);
4332
+ const retryable = report?.result === "FAIL" && report?._sessionDied === true;
4333
+ if (!retryable || attempt >= retries)
4334
+ break;
4335
+ log(config, "warning", `Context '${context?.contextId}' session died mid-run; retrying on a fresh session (attempt ${attempt + 2} of ${retries + 1}).`);
4336
+ restore();
4337
+ // Linear backoff (mirrors driverStart's session-creation retry) so a
4338
+ // transient runner blip has a moment to clear before the fresh session.
4339
+ await new Promise((resolve) => setTimeout(resolve, delayMs(attempt)));
4340
+ }
4341
+ // Surface how many retries were spent, so a report consumer can tell a clean
4342
+ // PASS from one recovered after a mid-run session death (the warning log alone
4343
+ // isn't machine-readable). Stamped only when a retry actually happened.
4344
+ if (attempt > 0 && report && typeof report === "object") {
4345
+ report.retries = attempt;
4346
+ }
4347
+ return report;
4348
+ }
4124
4349
  // Every live session driver in the context, falling back to the lone default
4125
4350
  // driver when no registry exists (driver-start throw, or a driverless
4126
4351
  // context). Recording sweeps iterate this — recordings live per session.
@@ -4156,6 +4381,10 @@ async function stopAllRecordings({ config, context, driver, contextReport, }) {
4156
4381
  description: "Stopping recording",
4157
4382
  stepId: randomUUID(),
4158
4383
  };
4384
+ // These sweep steps really execute, so they carry a measured duration
4385
+ // rather than the Phase-3 zero default. Timed in both the success and the
4386
+ // failure path, so a stop that hangs before throwing still shows its cost.
4387
+ const stopStart = Date.now();
4159
4388
  try {
4160
4389
  const stepResult = await runStep({
4161
4390
  config,
@@ -4170,7 +4399,11 @@ async function stopAllRecordings({ config, context, driver, contextReport, }) {
4170
4399
  delete stepResult.description;
4171
4400
  // Don't leak the internal routing marker into the report.
4172
4401
  delete stopRecordStep.__stopAny;
4173
- contextReport.steps.push({ ...stopRecordStep, ...stepResult });
4402
+ contextReport.steps.push({
4403
+ ...stopRecordStep,
4404
+ ...stepResult,
4405
+ durationMs: Date.now() - stopStart,
4406
+ });
4174
4407
  }
4175
4408
  catch (error) {
4176
4409
  // A throw from runStep would otherwise strand the remaining handles.
@@ -4181,12 +4414,13 @@ async function stopAllRecordings({ config, context, driver, contextReport, }) {
4181
4414
  ...stopRecordStep,
4182
4415
  result: "FAIL",
4183
4416
  resultDescription: `Couldn't stop recording. ${error?.message ?? error}`,
4417
+ durationMs: Date.now() - stopStart,
4184
4418
  });
4185
4419
  }
4186
4420
  }
4187
4421
  }
4188
4422
  // Run a specific step
4189
- async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, processRegistry, appSession, }) {
4423
+ async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {}, processRegistry, appSession, surfaceTracker, }) {
4190
4424
  let actionResult;
4191
4425
  // Load values from environment variables
4192
4426
  step = replaceEnvs(step);
@@ -4196,6 +4430,8 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4196
4430
  step: step,
4197
4431
  driver: driver,
4198
4432
  appSession,
4433
+ processRegistry,
4434
+ surfaceTracker,
4199
4435
  });
4200
4436
  }
4201
4437
  else if (typeof step.dragAndDrop !== "undefined") {
@@ -4209,7 +4445,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4209
4445
  actionResult = await checkLink({ config: config, step: step });
4210
4446
  }
4211
4447
  else if (typeof step.find !== "undefined") {
4212
- actionResult = await findElement({ config: config, step: step, driver, appSession });
4448
+ actionResult = await findElement({ config: config, step: step, driver, appSession, processRegistry, surfaceTracker });
4213
4449
  }
4214
4450
  else if (typeof step.stopRecord !== "undefined") {
4215
4451
  actionResult = await stopRecording({
@@ -4344,6 +4580,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4344
4580
  appSession,
4345
4581
  driver,
4346
4582
  processRegistry,
4583
+ surfaceTracker,
4347
4584
  platform: context?.platform ?? "",
4348
4585
  serverDeps: {
4349
4586
  startServer: async (appiumEntry, appiumHome) => {
@@ -4412,6 +4649,8 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4412
4649
  step: step,
4413
4650
  driver: driver,
4414
4651
  appSession,
4652
+ processRegistry,
4653
+ surfaceTracker,
4415
4654
  annotationTheme: options?.annotationTheme,
4416
4655
  });
4417
4656
  }
@@ -4429,6 +4668,8 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4429
4668
  step: step,
4430
4669
  driver: driver,
4431
4670
  appSession,
4671
+ processRegistry,
4672
+ surfaceTracker,
4432
4673
  });
4433
4674
  }
4434
4675
  else if (typeof step.type !== "undefined") {
@@ -4438,6 +4679,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
4438
4679
  driver: driver,
4439
4680
  processRegistry,
4440
4681
  appSession,
4682
+ surfaceTracker,
4441
4683
  });
4442
4684
  }
4443
4685
  else if (typeof step.wait !== "undefined") {