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.
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/common/src/schemas/schemas.json +6702 -393
- package/dist/common/src/types/generated/annotate_v3.d.ts +48 -0
- package/dist/common/src/types/generated/annotate_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/annotation_v3.d.ts +24 -0
- package/dist/common/src/types/generated/annotation_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/closeSurface_v3.d.ts +2 -2
- package/dist/common/src/types/generated/config_v3.d.ts +12 -0
- package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/context_v3.d.ts +4 -0
- package/dist/common/src/types/generated/context_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/report_v3.d.ts +12 -0
- package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/resolvedTests_v3.d.ts +20 -0
- package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/screenshot_v3.d.ts +25 -1
- package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/spec_v3.d.ts +8 -0
- package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/step_v3.d.ts +162 -6
- package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/surface_v3.d.ts +1 -1
- package/dist/common/src/types/generated/swipe_v3.d.ts +2 -2
- package/dist/common/src/types/generated/test_v3.d.ts +336 -12
- package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/type_v3.d.ts +1 -1
- package/dist/core/annotations/geometry.d.ts +2 -1
- package/dist/core/annotations/geometry.d.ts.map +1 -1
- package/dist/core/annotations/geometry.js +11 -1
- package/dist/core/annotations/geometry.js.map +1 -1
- package/dist/core/tests/activeSurface.d.ts +46 -0
- package/dist/core/tests/activeSurface.d.ts.map +1 -0
- package/dist/core/tests/activeSurface.js +141 -0
- package/dist/core/tests/activeSurface.js.map +1 -0
- package/dist/core/tests/appSurface.d.ts +2 -0
- package/dist/core/tests/appSurface.d.ts.map +1 -1
- package/dist/core/tests/appSurface.js +24 -13
- package/dist/core/tests/appSurface.js.map +1 -1
- package/dist/core/tests/browserSessions.d.ts +3 -0
- package/dist/core/tests/browserSessions.d.ts.map +1 -1
- package/dist/core/tests/browserSessions.js +3 -0
- package/dist/core/tests/browserSessions.js.map +1 -1
- package/dist/core/tests/click.d.ts +3 -1
- package/dist/core/tests/click.d.ts.map +1 -1
- package/dist/core/tests/click.js +3 -1
- package/dist/core/tests/click.js.map +1 -1
- package/dist/core/tests/findElement.d.ts +4 -1
- package/dist/core/tests/findElement.d.ts.map +1 -1
- package/dist/core/tests/findElement.js +52 -27
- package/dist/core/tests/findElement.js.map +1 -1
- package/dist/core/tests/saveScreenshot.d.ts +4 -1
- package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
- package/dist/core/tests/saveScreenshot.js +70 -54
- package/dist/core/tests/saveScreenshot.js.map +1 -1
- package/dist/core/tests/startSurface.d.ts +3 -1
- package/dist/core/tests/startSurface.d.ts.map +1 -1
- package/dist/core/tests/startSurface.js +36 -2
- package/dist/core/tests/startSurface.js.map +1 -1
- package/dist/core/tests/swipe.d.ts +4 -1
- package/dist/core/tests/swipe.d.ts.map +1 -1
- package/dist/core/tests/swipe.js +37 -25
- package/dist/core/tests/swipe.js.map +1 -1
- package/dist/core/tests/typeKeys.d.ts +3 -1
- package/dist/core/tests/typeKeys.d.ts.map +1 -1
- package/dist/core/tests/typeKeys.js +42 -36
- package/dist/core/tests/typeKeys.js.map +1 -1
- package/dist/core/tests.d.ts +9 -2
- package/dist/core/tests.d.ts.map +1 -1
- package/dist/core/tests.js +257 -15
- package/dist/core/tests.js.map +1 -1
- package/dist/core/utils.d.ts +7 -1
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +141 -2
- package/dist/core/utils.js.map +1 -1
- package/dist/hints/context.d.ts +15 -0
- package/dist/hints/context.d.ts.map +1 -1
- package/dist/hints/context.js +115 -0
- package/dist/hints/context.js.map +1 -1
- package/dist/hints/hints.d.ts.map +1 -1
- package/dist/hints/hints.js +38 -0
- package/dist/hints/hints.js.map +1 -1
- package/dist/hints/types.d.ts +15 -0
- package/dist/hints/types.d.ts.map +1 -1
- package/dist/index.cjs +7565 -884
- package/dist/reporters/htmlReporter.js +3 -3
- package/dist/runtime/browserStepKeys.d.ts +37 -0
- package/dist/runtime/browserStepKeys.d.ts.map +1 -1
- package/dist/runtime/browserStepKeys.js +73 -0
- package/dist/runtime/browserStepKeys.js.map +1 -1
- package/dist/runtime/inferRuntimeNeeds.d.ts.map +1 -1
- package/dist/runtime/inferRuntimeNeeds.js +18 -11
- package/dist/runtime/inferRuntimeNeeds.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +8 -0
- package/dist/utils.js.map +1 -1
- package/package.json +2 -1
package/dist/core/tests.js
CHANGED
|
@@ -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
|
|
475
|
-
// app session instead, and the synthetic
|
|
476
|
-
// screen grab —
|
|
477
|
-
// browser test the authored steps already
|
|
478
|
-
// synthetic step never changes the outcome
|
|
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
|
|
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
|
|
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
|
-
|
|
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({
|
|
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") {
|