@validate.qa/runner 1.0.70 → 1.0.72

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 (3) hide show
  1. package/dist/cli.js +1526 -384
  2. package/dist/cli.mjs +1523 -381
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -735,6 +735,7 @@ var require_mobile_source_parser = __commonJS({
735
735
  exports2.computeScreenHash = computeScreenHash;
736
736
  exports2.buildLocatorBundle = buildLocatorBundle;
737
737
  exports2.preferredLocator = preferredLocator;
738
+ exports2.identityMatchInfo = identityMatchInfo;
738
739
  exports2.parseMobileSource = parseMobileSource2;
739
740
  exports2.resolveLocatorAgainstScreenDetailed = resolveLocatorAgainstScreenDetailed;
740
741
  exports2.resolveLocatorAgainstScreen = resolveLocatorAgainstScreen;
@@ -1349,16 +1350,34 @@ var require_mobile_source_parser = __commonJS({
1349
1350
  bundle.contentDesc = el.accessibilityId;
1350
1351
  return bundle;
1351
1352
  }
1352
- function preferredLocator(el) {
1353
- const durableText = el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1353
+ function identityFieldValue(el, field) {
1354
+ if (field === "text") {
1355
+ return el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1356
+ }
1357
+ return el[field];
1358
+ }
1359
+ function choosePreferredIdentityField(el) {
1354
1360
  const candidates = [
1355
- { value: el.accessibilityId, unique: el.identityUnique?.accessibilityId },
1356
- { value: el.resourceId, unique: el.identityUnique?.resourceId },
1357
- { value: durableText, unique: el.identityUnique?.text }
1361
+ { field: "accessibilityId", value: identityFieldValue(el, "accessibilityId"), unique: el.identityUnique?.accessibilityId },
1362
+ { field: "resourceId", value: identityFieldValue(el, "resourceId"), unique: el.identityUnique?.resourceId },
1363
+ { field: "text", value: identityFieldValue(el, "text"), unique: el.identityUnique?.text }
1358
1364
  ];
1359
1365
  const uniqueFirst = candidates.find((c) => c.value && c.unique !== false);
1360
- const chosen = (uniqueFirst ?? candidates.find((c) => c.value))?.value;
1361
- return chosen ?? el.xpath;
1366
+ const chosen = uniqueFirst ?? candidates.find((c) => c.value);
1367
+ return chosen?.value ? { field: chosen.field, value: chosen.value } : void 0;
1368
+ }
1369
+ function preferredLocator(el) {
1370
+ return choosePreferredIdentityField(el)?.value ?? el.xpath;
1371
+ }
1372
+ function identityMatchInfo(el, elements) {
1373
+ const chosen = choosePreferredIdentityField(el);
1374
+ if (!chosen)
1375
+ return void 0;
1376
+ if (el.identityUnique?.[chosen.field] !== false)
1377
+ return void 0;
1378
+ const sameValue = elements.filter((candidate) => identityFieldValue(candidate, chosen.field) === chosen.value);
1379
+ const matchIndex = sameValue.indexOf(el);
1380
+ return matchIndex >= 0 && sameValue.length > 1 ? { matchIndex, expectedMatchCount: sameValue.length } : void 0;
1362
1381
  }
1363
1382
  function buildScreenSignal(elements, root, platform3) {
1364
1383
  const signal = {
@@ -2112,6 +2131,9 @@ function readMobileMetadata(value) {
2112
2131
  return { screenName: "UnknownScreen" };
2113
2132
  }
2114
2133
  const locatorBundle = isPlainObject(value.locatorBundle) && typeof value.locatorBundle.xpath === "string" && typeof value.locatorBundle.className === "string" ? value.locatorBundle : void 0;
2134
+ const rawMatchIndex = value.matchIndex;
2135
+ const rawExpectedMatchCount = value.expectedMatchCount;
2136
+ const hasValidMatchPair = typeof rawMatchIndex === "number" && Number.isInteger(rawMatchIndex) && rawMatchIndex >= 0 && typeof rawExpectedMatchCount === "number" && Number.isInteger(rawExpectedMatchCount) && rawExpectedMatchCount > rawMatchIndex;
2115
2137
  return {
2116
2138
  screenName: typeof value.screenName === "string" && value.screenName.trim() ? value.screenName.trim() : "UnknownScreen",
2117
2139
  targetLabel: typeof value.targetLabel === "string" && value.targetLabel.trim() ? value.targetLabel.trim() : void 0,
@@ -2126,7 +2148,9 @@ function readMobileMetadata(value) {
2126
2148
  distance: typeof value.distance === "number" && Number.isFinite(value.distance) ? value.distance : void 0,
2127
2149
  appContext: value.appContext === "WEBVIEW" ? "WEBVIEW" : value.appContext === "NATIVE" ? "NATIVE" : void 0,
2128
2150
  webCss: typeof value.webCss === "string" && value.webCss.trim() ? value.webCss.trim() : void 0,
2129
- webviewContext: typeof value.webviewContext === "string" && /^WEBVIEW/i.test(value.webviewContext.trim()) ? value.webviewContext.trim() : void 0
2151
+ webviewContext: typeof value.webviewContext === "string" && /^WEBVIEW/i.test(value.webviewContext.trim()) ? value.webviewContext.trim() : void 0,
2152
+ matchIndex: hasValidMatchPair ? rawMatchIndex : void 0,
2153
+ expectedMatchCount: hasValidMatchPair ? rawExpectedMatchCount : void 0
2130
2154
  };
2131
2155
  }
2132
2156
  function nonEmpty(value) {
@@ -2135,6 +2159,9 @@ function nonEmpty(value) {
2135
2159
  const trimmed = value.trim();
2136
2160
  return trimmed.length > 0 ? trimmed : void 0;
2137
2161
  }
2162
+ function matchInstanceFields(metadata) {
2163
+ return metadata.matchIndex !== void 0 && metadata.expectedMatchCount !== void 0 ? { matchIndex: metadata.matchIndex, expectedMatchCount: metadata.expectedMatchCount } : {};
2164
+ }
2138
2165
  function buildStepTarget(metadata, selector) {
2139
2166
  const locatorBundle = metadata.locatorBundle;
2140
2167
  const trimmedSelector = nonEmpty(selector);
@@ -2145,7 +2172,8 @@ function buildStepTarget(metadata, selector) {
2145
2172
  return {
2146
2173
  primary: trimmedSelector,
2147
2174
  fallbacks: [],
2148
- screenName: metadata.screenName
2175
+ screenName: metadata.screenName,
2176
+ ...matchInstanceFields(metadata)
2149
2177
  };
2150
2178
  }
2151
2179
  const webCss = nonEmpty(metadata.webCss) ?? (trimmedSelector?.startsWith("css=") ? trimmedSelector.slice(4) : void 0) ?? (nonEmpty(locatorBundle.xpath)?.startsWith("css=") ? locatorBundle.xpath.slice(4) : void 0);
@@ -2168,8 +2196,9 @@ function buildStepTarget(metadata, selector) {
2168
2196
  primary,
2169
2197
  fallbacks,
2170
2198
  screenName: metadata.screenName,
2171
- locatorBundle
2199
+ locatorBundle,
2172
2200
  // Include the locatorBundle for better selector generation later
2201
+ ...matchInstanceFields(metadata)
2173
2202
  };
2174
2203
  }
2175
2204
  function labelForAction(action, metadata) {
@@ -2616,6 +2645,165 @@ function buildAppiumCode(testName, target, steps) {
2616
2645
  `main().catch((err) => { console.error(err); process.exitCode = 1; });`
2617
2646
  ].join("\n");
2618
2647
  }
2648
+ function webviewStepLogicalId(step) {
2649
+ if (step.metadata?.appContext !== "WEBVIEW")
2650
+ return null;
2651
+ const webCss = typeof step.metadata?.webCss === "string" && step.metadata.webCss.length > 0 ? step.metadata.webCss : step.target?.primary?.startsWith("css=") ? step.target.primary.slice(4) : "";
2652
+ const match = String(webCss ?? "").match(/^#([A-Za-z][\w-]*)$/);
2653
+ return match ? match[1].toLowerCase() : null;
2654
+ }
2655
+ function nativeStepResourceId(step) {
2656
+ const target = step.target;
2657
+ if (!target)
2658
+ return null;
2659
+ const xpaths = [target.locatorBundle?.xpath, ...target.fallbacks ?? []].filter((value) => typeof value === "string" && value.length > 0);
2660
+ for (const xpath of xpaths) {
2661
+ const match = xpath.match(/@resource-id=['"]([^'"]+)['"]/);
2662
+ if (match)
2663
+ return match[1].split("/").pop().toLowerCase();
2664
+ }
2665
+ const resourceId = nonEmpty(target.locatorBundle?.resourceId);
2666
+ if (resourceId)
2667
+ return resourceId.split("/").pop().toLowerCase();
2668
+ const primary = (target.primary ?? "").trim();
2669
+ if (/^[A-Za-z][\w-]*$/.test(primary) && !primary.startsWith("css="))
2670
+ return primary.toLowerCase();
2671
+ return null;
2672
+ }
2673
+ function renumberMobileSteps(steps) {
2674
+ steps.forEach((step, index) => {
2675
+ step.order = index;
2676
+ });
2677
+ }
2678
+ function dropHybridNativeMirrorStepsInPlace(steps) {
2679
+ const webviewKeys = /* @__PURE__ */ new Set();
2680
+ for (const step of steps) {
2681
+ const id = webviewStepLogicalId(step);
2682
+ if (id)
2683
+ webviewKeys.add(`${step.target?.screenName ?? ""}::${id}`);
2684
+ }
2685
+ if (webviewKeys.size === 0)
2686
+ return 0;
2687
+ const kept = steps.filter((step) => {
2688
+ if (step.metadata?.appContext === "WEBVIEW")
2689
+ return true;
2690
+ if (step.action !== "tap" && step.action !== "type")
2691
+ return true;
2692
+ const id = nativeStepResourceId(step);
2693
+ if (id === null)
2694
+ return true;
2695
+ return !webviewKeys.has(`${step.target?.screenName ?? ""}::${id}`);
2696
+ });
2697
+ const removed = steps.length - kept.length;
2698
+ if (removed > 0) {
2699
+ steps.splice(0, steps.length, ...kept);
2700
+ renumberMobileSteps(steps);
2701
+ }
2702
+ return removed;
2703
+ }
2704
+ function strictOnMobileWanderFlag(value) {
2705
+ const v = (value || "").trim().toLowerCase();
2706
+ return v === "true" || v === "1";
2707
+ }
2708
+ function isMobileWanderSimplifyEnabled() {
2709
+ const value = (process.env.VALIDATEQA_MOBILE_WANDER_SIMPLIFY || "").trim().toLowerCase();
2710
+ return value === "on" || value === "true" || value === "1";
2711
+ }
2712
+ function mobileWanderTraceEnabled() {
2713
+ return strictOnMobileWanderFlag(process.env.VALIDATEQA_MOBILE_WANDER_TRACE);
2714
+ }
2715
+ function isMobileWanderCommitTapText(text) {
2716
+ const isChooser = /\b(continue\s+with|sign\s+in\s+with|log\s+in\s+with|use\s+(?:email|phone)|choose|pick|select)\b/i.test(text);
2717
+ if (isChooser)
2718
+ return false;
2719
+ return /\b(sign\s*in|log\s*in|login|submit|save|continue|create|send|confirm|done|next|apply|checkout|pay|finish|update|register|sign\s*up)\b/i.test(text);
2720
+ }
2721
+ function isMobileWanderCommitStep(step) {
2722
+ if (step.action === "type" && typeof step.value === "string" && /\n$/.test(step.value))
2723
+ return true;
2724
+ if (step.action === "tap") {
2725
+ const text = [step.description, step.target?.primary].filter((v) => typeof v === "string" && v.length > 0).join(" ");
2726
+ return isMobileWanderCommitTapText(text);
2727
+ }
2728
+ return false;
2729
+ }
2730
+ function isMobileWanderStateToggleTap(step) {
2731
+ return step.action === "tap" && isStateValueMobileClassName(step.target?.locatorBundle?.className);
2732
+ }
2733
+ function simplifyMobileWanderingInPlace(steps, trace) {
2734
+ let removed = 0;
2735
+ const afterAdjacentDupes = [];
2736
+ for (const step of steps) {
2737
+ const previous = afterAdjacentDupes[afterAdjacentDupes.length - 1];
2738
+ const primary = step.target?.primary;
2739
+ const isAdjacentDuplicateTap = previous !== void 0 && step.action === "tap" && previous.action === "tap" && !!primary && primary === previous.target?.primary && step.target?.screenName === previous.target?.screenName && step.value === previous.value && !isMobileWanderStateToggleTap(step) && !isMobileWanderStateToggleTap(previous);
2740
+ if (isAdjacentDuplicateTap) {
2741
+ removed += 1;
2742
+ trace?.(`dropped adjacent duplicate tap "${primary}" (exact repeat, nothing between)`);
2743
+ afterAdjacentDupes[afterAdjacentDupes.length - 1] = step;
2744
+ continue;
2745
+ }
2746
+ afterAdjacentDupes.push(step);
2747
+ }
2748
+ const lastTypeIndexByPrimary = /* @__PURE__ */ new Map();
2749
+ const refocusTapIndexesByPrimary = /* @__PURE__ */ new Map();
2750
+ const toDrop = /* @__PURE__ */ new Set();
2751
+ afterAdjacentDupes.forEach((step, index) => {
2752
+ if (step.action === "assertVisible" || step.action === "assertText" || step.action === "waitForElement") {
2753
+ lastTypeIndexByPrimary.clear();
2754
+ refocusTapIndexesByPrimary.clear();
2755
+ return;
2756
+ }
2757
+ if (step.action === "clear") {
2758
+ const primary2 = step.target?.primary;
2759
+ if (primary2) {
2760
+ lastTypeIndexByPrimary.delete(primary2);
2761
+ refocusTapIndexesByPrimary.delete(primary2);
2762
+ }
2763
+ return;
2764
+ }
2765
+ if (isMobileWanderCommitStep(step)) {
2766
+ lastTypeIndexByPrimary.clear();
2767
+ refocusTapIndexesByPrimary.clear();
2768
+ return;
2769
+ }
2770
+ if (step.action === "tap") {
2771
+ const primary2 = step.target?.primary;
2772
+ if (primary2) {
2773
+ const list = refocusTapIndexesByPrimary.get(primary2) ?? [];
2774
+ list.push(index);
2775
+ refocusTapIndexesByPrimary.set(primary2, list);
2776
+ }
2777
+ return;
2778
+ }
2779
+ if (step.action !== "type")
2780
+ return;
2781
+ const primary = step.target?.primary;
2782
+ if (!primary)
2783
+ return;
2784
+ const priorIndex = lastTypeIndexByPrimary.get(primary);
2785
+ if (priorIndex !== void 0) {
2786
+ toDrop.add(priorIndex);
2787
+ removed += 1;
2788
+ trace?.(`dropped superseded type into "${primary}" (a later type into the same field wins)`);
2789
+ for (const tapIndex of refocusTapIndexesByPrimary.get(primary) ?? []) {
2790
+ if (tapIndex > priorIndex && tapIndex < index && !toDrop.has(tapIndex)) {
2791
+ toDrop.add(tapIndex);
2792
+ removed += 1;
2793
+ trace?.(`dropped redundant re-focus tap on "${primary}" (it only re-focused a field whose write was superseded)`);
2794
+ }
2795
+ }
2796
+ }
2797
+ lastTypeIndexByPrimary.set(primary, index);
2798
+ refocusTapIndexesByPrimary.set(primary, []);
2799
+ });
2800
+ if (removed === 0)
2801
+ return 0;
2802
+ const kept = afterAdjacentDupes.filter((_, index) => !toDrop.has(index));
2803
+ steps.splice(0, steps.length, ...kept);
2804
+ renumberMobileSteps(steps);
2805
+ return removed;
2806
+ }
2619
2807
  function generateMobileTestFromSession(input) {
2620
2808
  if (input.medium !== "IOS_NATIVE" && input.medium !== "ANDROID_NATIVE") {
2621
2809
  throw new Error(`Mobile test generation requires IOS_NATIVE or ANDROID_NATIVE medium (received ${input.medium})`);
@@ -2659,12 +2847,20 @@ function generateMobileTestFromSession(input) {
2659
2847
  if (steps.length === 0) {
2660
2848
  throw new Error("No mobile actions were recorded for this session");
2661
2849
  }
2850
+ dropHybridNativeMirrorStepsInPlace(steps);
2851
+ const wanderTraceMessages = [];
2852
+ const wanderTraceSink = (message) => {
2853
+ wanderTraceMessages.push(message);
2854
+ };
2855
+ if (isMobileWanderSimplifyEnabled()) {
2856
+ simplifyMobileWanderingInPlace(steps, mobileWanderTraceEnabled() ? wanderTraceSink : void 0);
2857
+ }
2662
2858
  const interactionSteps = steps.filter((step) => step.action !== "launchApp");
2663
2859
  const runnableInteractions = interactionSteps.filter(isRunnableStep);
2664
2860
  if (interactionSteps.length === 0 || runnableInteractions.length === 0) {
2665
2861
  throw new Error("Generated mobile test has no runnable steps \u2014 re-record the flow so each interaction targets an on-screen element.");
2666
2862
  }
2667
- const warnings = [];
2863
+ const warnings = wanderTraceMessages.map((message) => `[wander] ${message}`);
2668
2864
  const unrunnable = interactionSteps.length - runnableInteractions.length;
2669
2865
  if (unrunnable > 0) {
2670
2866
  warnings.push(`${unrunnable} recorded step(s) could not be turned into a runnable action (missing a replayable identity locator or coordinate) and were kept best-effort.`);
@@ -6221,6 +6417,7 @@ __export(dist_exports, {
6221
6417
  isEnvironmentalError: () => isEnvironmentalError,
6222
6418
  isIdentityMobileXpath: () => isIdentityMobileXpath,
6223
6419
  isMobilePlatform: () => isMobilePlatform,
6420
+ isMobileWanderSimplifyEnabled: () => isMobileWanderSimplifyEnabled,
6224
6421
  isPricingPlan: () => isPricingPlan,
6225
6422
  isRunnableStep: () => isRunnableStep,
6226
6423
  isSafeDeepLink: () => isSafeDeepLink,
@@ -6242,6 +6439,7 @@ __export(dist_exports, {
6242
6439
  mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
6243
6440
  mobileLocatorBundleHasIdentity: () => mobileLocatorBundleHasIdentity,
6244
6441
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
6442
+ mobileWanderTraceEnabled: () => mobileWanderTraceEnabled,
6245
6443
  normalizeControlName: () => normalizeControlName,
6246
6444
  normalizeTtsVoiceId: () => normalizeTtsVoiceId,
6247
6445
  otherMobilePlatform: () => otherMobilePlatform,
@@ -6270,6 +6468,7 @@ __export(dist_exports, {
6270
6468
  scrubUrlPropertyBag: () => scrubUrlPropertyBag,
6271
6469
  selectorCandidatesForStep: () => selectorCandidatesForStep,
6272
6470
  selectorForValue: () => selectorForValue,
6471
+ simplifyMobileWanderingInPlace: () => simplifyMobileWanderingInPlace,
6273
6472
  stepAppliesToPlatform: () => stepAppliesToPlatform,
6274
6473
  stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
6275
6474
  stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
@@ -10556,7 +10755,7 @@ var require_sonic_boom = __commonJS({
10556
10755
  if (!(this instanceof SonicBoom)) {
10557
10756
  return new SonicBoom(opts);
10558
10757
  }
10559
- let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir: mkdir2, retryEAGAIN, fsync, contentMode, mode } = opts || {};
10758
+ let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir: mkdir3, retryEAGAIN, fsync, contentMode, mode } = opts || {};
10560
10759
  fd = fd || dest;
10561
10760
  this._len = 0;
10562
10761
  this.fd = -1;
@@ -10581,7 +10780,7 @@ var require_sonic_boom = __commonJS({
10581
10780
  this.append = append || false;
10582
10781
  this.mode = mode;
10583
10782
  this.retryEAGAIN = retryEAGAIN || (() => true);
10584
- this.mkdir = mkdir2 || false;
10783
+ this.mkdir = mkdir3 || false;
10585
10784
  let fsWriteSync;
10586
10785
  let fsWrite;
10587
10786
  if (contentMode === kContentModeBuffer) {
@@ -11292,8 +11491,8 @@ var require_thread_stream = __commonJS({
11292
11491
  "use strict";
11293
11492
  var { version } = require_package();
11294
11493
  var { EventEmitter: EventEmitter2 } = require("events");
11295
- var { Worker } = require("worker_threads");
11296
- var { join: join7 } = require("path");
11494
+ var { Worker: Worker2 } = require("worker_threads");
11495
+ var { join: join8 } = require("path");
11297
11496
  var { pathToFileURL } = require("url");
11298
11497
  var { wait } = require_wait();
11299
11498
  var {
@@ -11344,8 +11543,8 @@ var require_thread_stream = __commonJS({
11344
11543
  function createWorker(stream, opts) {
11345
11544
  const { filename, workerData } = opts;
11346
11545
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
11347
- const toExecute = bundlerOverrides["thread-stream-worker"] || join7(__dirname, "lib", "worker.js");
11348
- const worker = new Worker(toExecute, {
11546
+ const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11547
+ const worker = new Worker2(toExecute, {
11349
11548
  ...opts.workerOpts,
11350
11549
  name: opts.workerOpts?.name || "thread-stream",
11351
11550
  trackUnmanagedFds: false,
@@ -11812,7 +12011,7 @@ var require_transport = __commonJS({
11812
12011
  var { createRequire } = require("module");
11813
12012
  var { existsSync: existsSync3 } = require("fs");
11814
12013
  var getCallers = require_caller();
11815
- var { join: join7, isAbsolute, sep } = require("path");
12014
+ var { join: join8, isAbsolute: isAbsolute2, sep } = require("path");
11816
12015
  var { fileURLToPath } = require("url");
11817
12016
  var sleep = require_atomic_sleep();
11818
12017
  var onExit = require_on_exit_leak_free();
@@ -11884,7 +12083,7 @@ var require_transport = __commonJS({
11884
12083
  return false;
11885
12084
  }
11886
12085
  }
11887
- return isAbsolute(path) && !existsSync3(path);
12086
+ return isAbsolute2(path) && !existsSync3(path);
11888
12087
  }
11889
12088
  function stripQuotes(value) {
11890
12089
  const first = value[0];
@@ -11965,7 +12164,7 @@ var require_transport = __commonJS({
11965
12164
  throw new Error("only one of target or targets can be specified");
11966
12165
  }
11967
12166
  if (targets) {
11968
- target = bundlerOverrides["pino-worker"] || join7(__dirname, "worker.js");
12167
+ target = bundlerOverrides["pino-worker"] || join8(__dirname, "worker.js");
11969
12168
  options.targets = targets.filter((dest) => dest.target).map((dest) => {
11970
12169
  return {
11971
12170
  ...dest,
@@ -11983,7 +12182,7 @@ var require_transport = __commonJS({
11983
12182
  });
11984
12183
  });
11985
12184
  } else if (pipeline) {
11986
- target = bundlerOverrides["pino-worker"] || join7(__dirname, "worker.js");
12185
+ target = bundlerOverrides["pino-worker"] || join8(__dirname, "worker.js");
11987
12186
  options.pipelines = [pipeline.map((dest) => {
11988
12187
  return {
11989
12188
  ...dest,
@@ -12002,11 +12201,11 @@ var require_transport = __commonJS({
12002
12201
  return buildStream(fixTarget(target), options, worker, sync, name);
12003
12202
  function fixTarget(origin) {
12004
12203
  origin = bundlerOverrides[origin] || origin;
12005
- if (isAbsolute(origin) || origin.indexOf("file://") === 0) {
12204
+ if (isAbsolute2(origin) || origin.indexOf("file://") === 0) {
12006
12205
  return origin;
12007
12206
  }
12008
12207
  if (origin === "pino/file") {
12009
- return join7(__dirname, "..", "file.js");
12208
+ return join8(__dirname, "..", "file.js");
12010
12209
  }
12011
12210
  let fixTarget2;
12012
12211
  for (const filePath of callers) {
@@ -12987,7 +13186,7 @@ var require_safe_stable_stringify = __commonJS({
12987
13186
  return circularValue;
12988
13187
  }
12989
13188
  let res = "";
12990
- let join7 = ",";
13189
+ let join8 = ",";
12991
13190
  const originalIndentation = indentation;
12992
13191
  if (Array.isArray(value)) {
12993
13192
  if (value.length === 0) {
@@ -13001,7 +13200,7 @@ var require_safe_stable_stringify = __commonJS({
13001
13200
  indentation += spacer;
13002
13201
  res += `
13003
13202
  ${indentation}`;
13004
- join7 = `,
13203
+ join8 = `,
13005
13204
  ${indentation}`;
13006
13205
  }
13007
13206
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -13009,13 +13208,13 @@ ${indentation}`;
13009
13208
  for (; i < maximumValuesToStringify - 1; i++) {
13010
13209
  const tmp2 = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
13011
13210
  res += tmp2 !== void 0 ? tmp2 : "null";
13012
- res += join7;
13211
+ res += join8;
13013
13212
  }
13014
13213
  const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
13015
13214
  res += tmp !== void 0 ? tmp : "null";
13016
13215
  if (value.length - 1 > maximumBreadth) {
13017
13216
  const removedKeys = value.length - maximumBreadth - 1;
13018
- res += `${join7}"... ${getItemCount(removedKeys)} not stringified"`;
13217
+ res += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
13019
13218
  }
13020
13219
  if (spacer !== "") {
13021
13220
  res += `
@@ -13036,7 +13235,7 @@ ${originalIndentation}`;
13036
13235
  let separator = "";
13037
13236
  if (spacer !== "") {
13038
13237
  indentation += spacer;
13039
- join7 = `,
13238
+ join8 = `,
13040
13239
  ${indentation}`;
13041
13240
  whitespace = " ";
13042
13241
  }
@@ -13050,13 +13249,13 @@ ${indentation}`;
13050
13249
  const tmp = stringifyFnReplacer(key2, value, stack, replacer, spacer, indentation);
13051
13250
  if (tmp !== void 0) {
13052
13251
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
13053
- separator = join7;
13252
+ separator = join8;
13054
13253
  }
13055
13254
  }
13056
13255
  if (keyLength > maximumBreadth) {
13057
13256
  const removedKeys = keyLength - maximumBreadth;
13058
13257
  res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"`;
13059
- separator = join7;
13258
+ separator = join8;
13060
13259
  }
13061
13260
  if (spacer !== "" && separator.length > 1) {
13062
13261
  res = `
@@ -13097,7 +13296,7 @@ ${originalIndentation}`;
13097
13296
  }
13098
13297
  const originalIndentation = indentation;
13099
13298
  let res = "";
13100
- let join7 = ",";
13299
+ let join8 = ",";
13101
13300
  if (Array.isArray(value)) {
13102
13301
  if (value.length === 0) {
13103
13302
  return "[]";
@@ -13110,7 +13309,7 @@ ${originalIndentation}`;
13110
13309
  indentation += spacer;
13111
13310
  res += `
13112
13311
  ${indentation}`;
13113
- join7 = `,
13312
+ join8 = `,
13114
13313
  ${indentation}`;
13115
13314
  }
13116
13315
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -13118,13 +13317,13 @@ ${indentation}`;
13118
13317
  for (; i < maximumValuesToStringify - 1; i++) {
13119
13318
  const tmp2 = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
13120
13319
  res += tmp2 !== void 0 ? tmp2 : "null";
13121
- res += join7;
13320
+ res += join8;
13122
13321
  }
13123
13322
  const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
13124
13323
  res += tmp !== void 0 ? tmp : "null";
13125
13324
  if (value.length - 1 > maximumBreadth) {
13126
13325
  const removedKeys = value.length - maximumBreadth - 1;
13127
- res += `${join7}"... ${getItemCount(removedKeys)} not stringified"`;
13326
+ res += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
13128
13327
  }
13129
13328
  if (spacer !== "") {
13130
13329
  res += `
@@ -13137,7 +13336,7 @@ ${originalIndentation}`;
13137
13336
  let whitespace = "";
13138
13337
  if (spacer !== "") {
13139
13338
  indentation += spacer;
13140
- join7 = `,
13339
+ join8 = `,
13141
13340
  ${indentation}`;
13142
13341
  whitespace = " ";
13143
13342
  }
@@ -13146,7 +13345,7 @@ ${indentation}`;
13146
13345
  const tmp = stringifyArrayReplacer(key2, value[key2], stack, replacer, spacer, indentation);
13147
13346
  if (tmp !== void 0) {
13148
13347
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
13149
- separator = join7;
13348
+ separator = join8;
13150
13349
  }
13151
13350
  }
13152
13351
  if (spacer !== "" && separator.length > 1) {
@@ -13204,20 +13403,20 @@ ${originalIndentation}`;
13204
13403
  indentation += spacer;
13205
13404
  let res2 = `
13206
13405
  ${indentation}`;
13207
- const join8 = `,
13406
+ const join9 = `,
13208
13407
  ${indentation}`;
13209
13408
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
13210
13409
  let i = 0;
13211
13410
  for (; i < maximumValuesToStringify - 1; i++) {
13212
13411
  const tmp2 = stringifyIndent(String(i), value[i], stack, spacer, indentation);
13213
13412
  res2 += tmp2 !== void 0 ? tmp2 : "null";
13214
- res2 += join8;
13413
+ res2 += join9;
13215
13414
  }
13216
13415
  const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation);
13217
13416
  res2 += tmp !== void 0 ? tmp : "null";
13218
13417
  if (value.length - 1 > maximumBreadth) {
13219
13418
  const removedKeys = value.length - maximumBreadth - 1;
13220
- res2 += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
13419
+ res2 += `${join9}"... ${getItemCount(removedKeys)} not stringified"`;
13221
13420
  }
13222
13421
  res2 += `
13223
13422
  ${originalIndentation}`;
@@ -13233,16 +13432,16 @@ ${originalIndentation}`;
13233
13432
  return '"[Object]"';
13234
13433
  }
13235
13434
  indentation += spacer;
13236
- const join7 = `,
13435
+ const join8 = `,
13237
13436
  ${indentation}`;
13238
13437
  let res = "";
13239
13438
  let separator = "";
13240
13439
  let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth);
13241
13440
  if (isTypedArrayWithEntries(value)) {
13242
- res += stringifyTypedArray(value, join7, maximumBreadth);
13441
+ res += stringifyTypedArray(value, join8, maximumBreadth);
13243
13442
  keys = keys.slice(value.length);
13244
13443
  maximumPropertiesToStringify -= value.length;
13245
- separator = join7;
13444
+ separator = join8;
13246
13445
  }
13247
13446
  if (deterministic) {
13248
13447
  keys = sort(keys, comparator);
@@ -13253,13 +13452,13 @@ ${indentation}`;
13253
13452
  const tmp = stringifyIndent(key2, value[key2], stack, spacer, indentation);
13254
13453
  if (tmp !== void 0) {
13255
13454
  res += `${separator}${strEscape(key2)}: ${tmp}`;
13256
- separator = join7;
13455
+ separator = join8;
13257
13456
  }
13258
13457
  }
13259
13458
  if (keyLength > maximumBreadth) {
13260
13459
  const removedKeys = keyLength - maximumBreadth;
13261
13460
  res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"`;
13262
- separator = join7;
13461
+ separator = join8;
13263
13462
  }
13264
13463
  if (separator !== "") {
13265
13464
  res = `
@@ -61935,11 +62134,11 @@ var require_core = __commonJS({
61935
62134
  function mapOuter(r, f) {
61936
62135
  return r.matched ? f(r) : r;
61937
62136
  }
61938
- function ab(pa, pb, join7) {
61939
- return (data, i) => mapOuter(pa(data, i), (ma) => mapInner(pb(data, ma.position), (vb, j) => join7(ma.value, vb, data, i, j)));
62137
+ function ab(pa, pb, join8) {
62138
+ return (data, i) => mapOuter(pa(data, i), (ma) => mapInner(pb(data, ma.position), (vb, j) => join8(ma.value, vb, data, i, j)));
61940
62139
  }
61941
- function abc(pa, pb, pc, join7) {
61942
- return (data, i) => mapOuter(pa(data, i), (ma) => mapOuter(pb(data, ma.position), (mb) => mapInner(pc(data, mb.position), (vc, j) => join7(ma.value, mb.value, vc, data, i, j))));
62140
+ function abc(pa, pb, pc, join8) {
62141
+ return (data, i) => mapOuter(pa(data, i), (ma) => mapOuter(pb(data, ma.position), (mb) => mapInner(pc(data, mb.position), (vc, j) => join8(ma.value, mb.value, vc, data, i, j))));
61943
62142
  }
61944
62143
  function action(f) {
61945
62144
  return (data, i) => {
@@ -74445,6 +74644,7 @@ var require_mobile_explorer = __commonJS({
74445
74644
  Object.defineProperty(exports2, "__esModule", { value: true });
74446
74645
  exports2.computeMobileSurveyBreadthContinuation = computeMobileSurveyBreadthContinuation;
74447
74646
  exports2.computeMobileBreadthFinishRedirectDecision = computeMobileBreadthFinishRedirectDecision;
74647
+ exports2.hybridScreenFingerprint = hybridScreenFingerprint;
74448
74648
  exports2.runMobileExplorePhase = runMobileExplorePhase;
74449
74649
  var crypto_1 = require("crypto");
74450
74650
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
@@ -74898,11 +75098,12 @@ var require_mobile_explorer = __commonJS({
74898
75098
  const validSurfaceDigest = /^[0-9a-f]{16,64}$/.test(normalizedSurfaceDigest) ? normalizedSurfaceDigest : void 0;
74899
75099
  const legacyElementDigests = [...new Set(elements.map((element) => element.pageIdentityDigest?.trim().toLowerCase() ?? "").filter((digest) => /^[0-9a-f]{16,64}$/.test(digest)))];
74900
75100
  const pageIdentityDigest = validSurfaceDigest ?? (legacyElementDigests.length === 1 ? legacyElementDigests[0] : void 0);
75101
+ const hasStableControlIdentity = elements.some((element) => Boolean(safeHybridIdentity(element.id, credentials) || safeHybridIdentity(element.name, credentials)));
74901
75102
  const descriptors = elements.map((element) => {
74902
75103
  const editable = element.editable === true || /^(?:input|textarea|select)$/i.test(element.tag) || /(?:textbox|searchbox|combobox)/i.test(element.role);
74903
75104
  const id = safeHybridIdentity(element.id, credentials)?.toLowerCase() ?? "";
74904
75105
  const name = safeHybridIdentity(element.name, credentials)?.toLowerCase() ?? "";
74905
- const semanticText = editable || id || name ? "" : stableHybridSemanticText(element.text, credentials);
75106
+ const semanticText = editable || id || name || hasStableControlIdentity ? "" : stableHybridSemanticText(element.text, credentials);
74906
75107
  return [
74907
75108
  element.tag.trim().toLowerCase(),
74908
75109
  element.role.trim().toLowerCase(),
@@ -81167,10 +81368,14 @@ var require_mobile_generator = __commonJS({
81167
81368
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
81168
81369
  "use strict";
81169
81370
  Object.defineProperty(exports2, "__esModule", { value: true });
81371
+ exports2.structuralScreenKey = structuralScreenKey;
81170
81372
  exports2.collapseConsecutiveBacks = collapseConsecutiveBacks;
81171
81373
  exports2.scenarioIntentMismatchReason = scenarioIntentMismatchReason;
81172
81374
  exports2.dedupeConsecutiveIdenticalActions = dedupeConsecutiveIdenticalActions;
81173
81375
  exports2.collapseRetryCycles = collapseRetryCycles;
81376
+ exports2.coalesceSameFieldTypes = coalesceSameFieldTypes;
81377
+ exports2.coalesceRepeatedTaps = coalesceRepeatedTaps;
81378
+ exports2.elideNoOpDetours = elideNoOpDetours;
81174
81379
  exports2.mobileActionsToPseudoPlaywright = mobileActionsToPseudoPlaywright;
81175
81380
  exports2.evaluateMobileAssertionStrength = evaluateMobileAssertionStrength;
81176
81381
  exports2.prepareMobileScenarioAppState = prepareMobileScenarioAppState;
@@ -81379,6 +81584,22 @@ var require_mobile_generator = __commonJS({
81379
81584
  MOBILE_ASSERT_TEXT_TOOL,
81380
81585
  FINISH_GENERATE_TOOL
81381
81586
  ];
81587
+ var STRUCTURAL_KEY_MAX_ELEMENTS = 16;
81588
+ function structuralScreenKey(resolved) {
81589
+ if (!resolved)
81590
+ return null;
81591
+ const nativeElements = resolved.screen.elements;
81592
+ const hybridElements = resolved.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81593
+ if (nativeElements.length + hybridElements.length < 2)
81594
+ return null;
81595
+ const nativeSkeleton = [...nativeElements].sort((a, b) => a.role.localeCompare(b.role) || locatorLocalName(a.resourceId ?? "").localeCompare(locatorLocalName(b.resourceId ?? "")) || (a.accessibilityId ?? "").localeCompare(b.accessibilityId ?? "")).slice(0, STRUCTURAL_KEY_MAX_ELEMENTS).map((el) => `${el.role}|${locatorLocalName(el.resourceId ?? "")}|${el.accessibilityId ?? ""}`);
81596
+ const hybridSkeleton = [...hybridElements].sort((a, b) => (a.id || a.role).localeCompare(b.id || b.role)).slice(0, STRUCTURAL_KEY_MAX_ELEMENTS).map((el) => `${el.role}|${el.id ?? ""}`);
81597
+ const activityOrBundle = resolved.signal.activity ?? resolved.signal.bundleId ?? "";
81598
+ const payload = `${activityOrBundle}
81599
+ ${nativeSkeleton.join("\n")}
81600
+ ${hybridSkeleton.join("\n")}`;
81601
+ return (0, crypto_1.createHash)("sha1").update(payload).digest("hex");
81602
+ }
81382
81603
  function platformForMedium(medium) {
81383
81604
  if (medium === "IOS_NATIVE")
81384
81605
  return "IOS";
@@ -81540,18 +81761,39 @@ var require_mobile_generator = __commonJS({
81540
81761
  }
81541
81762
  return `Typed value does not appear in the UI tree after typing into "${labelForElement(target)}". The field may not have been focused (common on WebView). Re-snapshot, tap the field, and type again.`;
81542
81763
  }
81543
- function metadataForElement(element, screenName, platform3) {
81764
+ function alreadyFocusedWithEqualValue(element, value) {
81765
+ return element.focused === true && typeof element.text === "string" && value.length > 0 && element.text === value;
81766
+ }
81767
+ function nativeElementsOf(resolved) {
81768
+ return resolved?.screen.elements ?? [];
81769
+ }
81770
+ function hybridElementsOf(resolved) {
81771
+ return resolved?.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81772
+ }
81773
+ function metadataForElement(element, screenName, platform3, elements = []) {
81774
+ const match = (0, mobile_source_parser_js_2.identityMatchInfo)(element, elements);
81544
81775
  return {
81545
81776
  screenName,
81546
81777
  targetLabel: labelForElement(element),
81547
81778
  locatorBundle: (0, mobile_source_parser_js_2.buildLocatorBundle)(element, platform3),
81548
- bounds: element.bounds
81779
+ bounds: element.bounds,
81780
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81549
81781
  };
81550
81782
  }
81551
- function metadataForHybridElement(element, screenName) {
81783
+ function hybridIdentityMatchInfo(element, elements) {
81784
+ const hybridIdentityValue = (candidate) => nonEmpty3(candidate.id) ?? (candidate.editable === true ? void 0 : nonEmpty3(candidate.text));
81785
+ const chosenValue = hybridIdentityValue(element);
81786
+ if (!chosenValue)
81787
+ return void 0;
81788
+ const sameValue = elements.filter((candidate) => hybridIdentityValue(candidate) === chosenValue);
81789
+ const matchIndex = sameValue.indexOf(element);
81790
+ return matchIndex >= 0 && sameValue.length > 1 ? { matchIndex, expectedMatchCount: sameValue.length } : void 0;
81791
+ }
81792
+ function metadataForHybridElement(element, screenName, elements = []) {
81552
81793
  const editable = isEditableHybridElement(element);
81553
81794
  const label = labelForHybridElement(element);
81554
81795
  const safeText = editable ? void 0 : nonEmpty3(element.text);
81796
+ const match = hybridIdentityMatchInfo(element, elements);
81555
81797
  return {
81556
81798
  screenName,
81557
81799
  targetLabel: label,
@@ -81560,11 +81802,16 @@ var require_mobile_generator = __commonJS({
81560
81802
  webviewContext: element.webviewContext,
81561
81803
  bounds: element.bounds,
81562
81804
  locatorBundle: {
81563
- xpath: `css=${element.css}`,
81805
+ // WebView controls resolve via `webCss` (above) in the WEBVIEW context, not
81806
+ // by xpath. `LocatorBundle.xpath` is contractually an XPath, so never store a
81807
+ // `css=` selector here — an empty xpath keeps the fallback chain honest and
81808
+ // prevents the css selector being misread as an xpath at replay.
81809
+ xpath: "",
81564
81810
  className: element.tag || "webview",
81565
81811
  ...element.id ? { accessibilityId: element.id } : {},
81566
81812
  ...safeText ? { text: safeText } : {}
81567
- }
81813
+ },
81814
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81568
81815
  };
81569
81816
  }
81570
81817
  function isEditableHybridElement(element) {
@@ -82282,7 +82529,15 @@ ${statePacket}`, redactionCredentials) },
82282
82529
  screenName: () => lastScreenName || "UnknownScreen",
82283
82530
  recordAction: (action) => {
82284
82531
  const screenHash = getLatest()?.signal.screenHash;
82285
- const stamped = screenHash && !action.metadata.screenHash ? { ...action, metadata: { ...action.metadata, screenHash } } : action;
82532
+ const structuralKey = structuralScreenKey(getLatest());
82533
+ const stamped = screenHash && !action.metadata.screenHash || structuralKey && !action.metadata.structuralKey ? {
82534
+ ...action,
82535
+ metadata: {
82536
+ ...action.metadata,
82537
+ ...screenHash && !action.metadata.screenHash ? { screenHash } : {},
82538
+ ...structuralKey && !action.metadata.structuralKey ? { structuralKey } : {}
82539
+ }
82540
+ } : action;
82286
82541
  actions.push((0, shared_1.placeholderizeRecordedMobileAction)(finalizeAction(stamped), credentials));
82287
82542
  if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
82288
82543
  assertCount++;
@@ -82388,7 +82643,7 @@ ${statePacket}`, redactionCredentials) },
82388
82643
  const result2 = await driver.webTap(hybridTap.webviewContext, hybridTap.css);
82389
82644
  const absorbed2 = await absorbActionResult(result2, "mobile_tap(webview)", opensAuthenticationSurface(hybridTap));
82390
82645
  if (shouldRecordAction(result2, absorbed2)) {
82391
- const metadata = metadataForHybridElement(hybridTap, beforeScreenName2);
82646
+ const metadata = metadataForHybridElement(hybridTap, beforeScreenName2, hybridElementsOf(before2));
82392
82647
  const label = metadata.targetLabel ?? hybridTap.css;
82393
82648
  const tapAction = {
82394
82649
  type: "TAP",
@@ -82440,7 +82695,7 @@ ${statePacket}`, redactionCredentials) },
82440
82695
  const tapAction = {
82441
82696
  type: "TAP",
82442
82697
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82443
- metadata: metadataForElement(element, beforeScreenName, platform3),
82698
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before)),
82444
82699
  innerText: null,
82445
82700
  url: "",
82446
82701
  value: null
@@ -82480,10 +82735,11 @@ ${statePacket}`, redactionCredentials) },
82480
82735
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82481
82736
  }
82482
82737
  const beforeScreenName2 = screenName();
82738
+ const before2 = latest();
82483
82739
  const result2 = await driver.webType(hybridType.webviewContext, hybridType.css, value);
82484
82740
  const absorbed2 = await absorbActionResult(result2, "mobile_type(webview)");
82485
82741
  if (shouldRecordAction(result2, absorbed2)) {
82486
- const metadata = metadataForHybridElement(hybridType, beforeScreenName2);
82742
+ const metadata = metadataForHybridElement(hybridType, beforeScreenName2, hybridElementsOf(before2));
82487
82743
  const label = metadata.targetLabel ?? hybridType.css;
82488
82744
  recordAction({
82489
82745
  type: "TYPE",
@@ -82498,7 +82754,14 @@ ${statePacket}`, redactionCredentials) },
82498
82754
  const element = resolveRef(toolArgs.ref);
82499
82755
  if (!element)
82500
82756
  return unknownRef(toolArgs.ref);
82757
+ if (alreadyFocusedWithEqualValue(element, value)) {
82758
+ return {
82759
+ ok: false,
82760
+ error: `"${labelForElement(element)}" is already focused and already contains exactly this value \u2014 typing it again is a no-op. Move on to the next step.`
82761
+ };
82762
+ }
82501
82763
  const beforeScreenName = screenName();
82764
+ const before = latest();
82502
82765
  const result = await driver.type(value, element.bounds);
82503
82766
  const absorbed = await absorbActionResult(result, "mobile_type");
82504
82767
  if (shouldRecordAction(result, absorbed)) {
@@ -82506,7 +82769,7 @@ ${statePacket}`, redactionCredentials) },
82506
82769
  type: "TYPE",
82507
82770
  value,
82508
82771
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82509
- metadata: metadataForElement(element, beforeScreenName, platform3)
82772
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82510
82773
  });
82511
82774
  }
82512
82775
  const payload = actionPayload(result, absorbed);
@@ -82524,6 +82787,7 @@ ${statePacket}`, redactionCredentials) },
82524
82787
  return { ok: false, error: "This driver does not support non-clearing WEBVIEW keyboard submit." };
82525
82788
  }
82526
82789
  const beforeScreenName2 = screenName();
82790
+ const before2 = latest();
82527
82791
  const result2 = await driver.webSubmitKeyboard(hybridField.webviewContext, hybridField.css);
82528
82792
  const absorbed2 = await absorbActionResult(result2, "mobile_submit_keyboard(webview)");
82529
82793
  if (shouldRecordAction(result2, absorbed2)) {
@@ -82531,7 +82795,7 @@ ${statePacket}`, redactionCredentials) },
82531
82795
  type: "TYPE",
82532
82796
  value: "\n",
82533
82797
  selector: `css=${hybridField.css}`,
82534
- metadata: metadataForHybridElement(hybridField, beforeScreenName2)
82798
+ metadata: metadataForHybridElement(hybridField, beforeScreenName2, hybridElementsOf(before2))
82535
82799
  });
82536
82800
  }
82537
82801
  return actionPayload(result2, absorbed2);
@@ -82543,6 +82807,7 @@ ${statePacket}`, redactionCredentials) },
82543
82807
  return { ok: false, error: "This driver does not support non-clearing keyboard submit." };
82544
82808
  }
82545
82809
  const beforeScreenName = screenName();
82810
+ const before = latest();
82546
82811
  const result = await driver.submitKeyboard(element.bounds);
82547
82812
  const absorbed = await absorbActionResult(result, "mobile_submit_keyboard");
82548
82813
  if (shouldRecordAction(result, absorbed)) {
@@ -82550,7 +82815,7 @@ ${statePacket}`, redactionCredentials) },
82550
82815
  type: "TYPE",
82551
82816
  value: "\n",
82552
82817
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82553
- metadata: metadataForElement(element, beforeScreenName, platform3)
82818
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82554
82819
  });
82555
82820
  }
82556
82821
  return actionPayload(result, absorbed);
@@ -82575,10 +82840,11 @@ ${statePacket}`, redactionCredentials) },
82575
82840
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82576
82841
  }
82577
82842
  const beforeScreenName2 = screenName();
82843
+ const before2 = latest();
82578
82844
  const result2 = await driver.webType(hybridFill.webviewContext, hybridFill.css, secret);
82579
82845
  const absorbed2 = await absorbActionResult(result2, "mobile_fill_credential(webview)");
82580
82846
  if (shouldRecordAction(result2, absorbed2)) {
82581
- const metadata = metadataForHybridElement(hybridFill, beforeScreenName2);
82847
+ const metadata = metadataForHybridElement(hybridFill, beforeScreenName2, hybridElementsOf(before2));
82582
82848
  const label = metadata.targetLabel ?? hybridFill.css;
82583
82849
  recordAction({
82584
82850
  type: "TYPE",
@@ -82593,7 +82859,14 @@ ${statePacket}`, redactionCredentials) },
82593
82859
  const element = resolveRef(toolArgs.ref);
82594
82860
  if (!element)
82595
82861
  return unknownRef(toolArgs.ref);
82862
+ if (alreadyFocusedWithEqualValue(element, secret)) {
82863
+ return {
82864
+ ok: false,
82865
+ error: `"${labelForElement(element)}" is already focused and already holds this exact credential value \u2014 typing it again is a no-op. Move on to the next step.`
82866
+ };
82867
+ }
82596
82868
  const beforeScreenName = screenName();
82869
+ const before = latest();
82597
82870
  const result = await driver.type(secret, element.bounds);
82598
82871
  const absorbed = await absorbActionResult(result, "mobile_fill_credential");
82599
82872
  if (shouldRecordAction(result, absorbed)) {
@@ -82601,7 +82874,7 @@ ${statePacket}`, redactionCredentials) },
82601
82874
  type: "TYPE",
82602
82875
  value: (0, shared_1.mobileCredentialPlaceholder)(key),
82603
82876
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82604
- metadata: metadataForElement(element, beforeScreenName, platform3)
82877
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82605
82878
  });
82606
82879
  log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
82607
82880
  }
@@ -82629,13 +82902,14 @@ ${statePacket}`, redactionCredentials) },
82629
82902
  if (!element)
82630
82903
  return unknownRef(toolArgs.ref);
82631
82904
  const beforeScreenName = screenName();
82905
+ const before = latest();
82632
82906
  const result = await driver.clear(element.bounds);
82633
82907
  const absorbed = await absorbActionResult(result, "mobile_clear");
82634
82908
  if (shouldRecordAction(result, absorbed)) {
82635
82909
  recordAction({
82636
82910
  type: "CLEAR",
82637
82911
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82638
- metadata: metadataForElement(element, beforeScreenName, platform3)
82912
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82639
82913
  });
82640
82914
  }
82641
82915
  return actionPayload(result, absorbed);
@@ -82698,7 +82972,7 @@ ${statePacket}`, redactionCredentials) },
82698
82972
  case "mobile_assert_visible": {
82699
82973
  const hybrid = resolveHybridRef(toolArgs.ref);
82700
82974
  if (hybrid) {
82701
- const metadata = metadataForHybridElement(hybrid, screenName());
82975
+ const metadata = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82702
82976
  recordAction({
82703
82977
  type: "ASSERT_VISIBLE",
82704
82978
  selector: `css=${hybrid.css}`,
@@ -82738,7 +83012,7 @@ ${statePacket}`, redactionCredentials) },
82738
83012
  error: `Live WEBVIEW text assertion failed: expected ${JSON.stringify(expected)} was not present on ref ${hybrid.ref}. Re-snapshot after the outcome appears and assert the text that is actually visible.`
82739
83013
  };
82740
83014
  }
82741
- const metadata2 = metadataForHybridElement(hybrid, screenName());
83015
+ const metadata2 = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82742
83016
  const stableWebIdentity = Boolean(nonEmpty3(hybrid.id) || nonEmpty3(hybrid.name));
82743
83017
  if (stableWebIdentity) {
82744
83018
  recordAction({
@@ -82791,7 +83065,7 @@ ${statePacket}`, redactionCredentials) },
82791
83065
  error: `Expected text ${JSON.stringify(expected)} is ${volatileExpected.tier === 1 ? "transient one-shot UI" : "session-dynamic content"} (${volatileExpected.reason}) \u2014 it changes between runs, so the test would fail forever on a healthy app. Assert durable post-state instead: an element unique to the destination screen, a stable label, or text your flow itself entered.`
82792
83066
  };
82793
83067
  }
82794
- const metadata = metadataForElement(element, screenName(), platform3);
83068
+ const metadata = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82795
83069
  const expectedNeedle = expected.trim().toLowerCase();
82796
83070
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
82797
83071
  const visibleText = (element.text ?? "").trim().toLowerCase();
@@ -82837,7 +83111,7 @@ ${statePacket}`, redactionCredentials) },
82837
83111
  error: `Ref ${element.ref} is not a durable assertion target (${weakReason2}). Re-snapshot and assert a human-visible label, text, or stable control that proves the screen outcome.`
82838
83112
  };
82839
83113
  }
82840
- const metadata2 = metadataForElement(element, screenName(), platform3);
83114
+ const metadata2 = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82841
83115
  recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata: metadata2 });
82842
83116
  return { ok: true, label: metadata2.targetLabel ?? "element" };
82843
83117
  }
@@ -82887,7 +83161,7 @@ ${statePacket}`, redactionCredentials) },
82887
83161
  error: `The live element matching ${JSON.stringify(accessibilityId ?? text)} is not a durable assertion target (${weakReason}).`
82888
83162
  };
82889
83163
  }
82890
- const metadata = metadataForElement(liveElement, screenName(), platform3);
83164
+ const metadata = metadataForElement(liveElement, screenName(), platform3, live.screen.elements);
82891
83165
  recordAction({
82892
83166
  type: "ASSERT_VISIBLE",
82893
83167
  selector: (0, mobile_source_parser_js_2.preferredLocator)(liveElement),
@@ -83015,7 +83289,7 @@ ${statePacket}`, redactionCredentials) },
83015
83289
  actions.push(finalizeAction({
83016
83290
  type: "ASSERT_VISIBLE",
83017
83291
  selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83018
- metadata: metadataForElement(anchor, screen, platform3)
83292
+ metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83019
83293
  }));
83020
83294
  return true;
83021
83295
  }
@@ -83358,6 +83632,168 @@ ${statePacket}`, redactionCredentials) },
83358
83632
  }
83359
83633
  return out;
83360
83634
  }
83635
+ function isDurableMutation(action) {
83636
+ if (action.type === "TYPE" || action.type === "CLEAR")
83637
+ return true;
83638
+ if (action.type === "TAP")
83639
+ return (0, shared_1.isStateValueMobileClassName)(action.metadata.locatorBundle?.className);
83640
+ return false;
83641
+ }
83642
+ function mobileWanderTraceEnabled2() {
83643
+ const v = (process.env.VALIDATEQA_MOBILE_WANDER_TRACE || "").trim().toLowerCase();
83644
+ return v === "true" || v === "1";
83645
+ }
83646
+ function wanderTraceLabel(action) {
83647
+ const identity = action.metadata.targetLabel || action.selector || action.metadata.locatorBundle?.accessibilityId || action.metadata.locatorBundle?.resourceId || "(no identity)";
83648
+ return `${action.type} ${identity}`;
83649
+ }
83650
+ function logWanderDiff(pass, before, after, log2) {
83651
+ if (after.length >= before.length)
83652
+ return;
83653
+ log2(` ${pass}: collapsed ${before.length - after.length} step(s) (${before.length} \u2192 ${after.length}).`);
83654
+ if (!mobileWanderTraceEnabled2())
83655
+ return;
83656
+ const survivors = new Set(after);
83657
+ for (const action of before) {
83658
+ if (!survivors.has(action)) {
83659
+ log2(` [wander] ${pass}: dropped ${wanderTraceLabel(action)}`);
83660
+ }
83661
+ }
83662
+ }
83663
+ function coalesceSameFieldTypes(actions) {
83664
+ if (actions.length <= 1)
83665
+ return actions;
83666
+ const toDrop = /* @__PURE__ */ new Set();
83667
+ const lastTypeIndexBySelector = /* @__PURE__ */ new Map();
83668
+ const refocusTapIndexesBySelector = /* @__PURE__ */ new Map();
83669
+ for (let i = 0; i < actions.length; i++) {
83670
+ const current = actions[i];
83671
+ if (current.type === "ASSERT_VISIBLE" || current.type === "ASSERT_TEXT") {
83672
+ lastTypeIndexBySelector.clear();
83673
+ refocusTapIndexesBySelector.clear();
83674
+ continue;
83675
+ }
83676
+ if (current.type === "CLEAR") {
83677
+ lastTypeIndexBySelector.delete(current.selector);
83678
+ refocusTapIndexesBySelector.delete(current.selector);
83679
+ continue;
83680
+ }
83681
+ if (isFormCommitAction(current)) {
83682
+ lastTypeIndexBySelector.clear();
83683
+ refocusTapIndexesBySelector.clear();
83684
+ continue;
83685
+ }
83686
+ if (current.type === "TAP") {
83687
+ if (current.selector) {
83688
+ const list = refocusTapIndexesBySelector.get(current.selector) ?? [];
83689
+ list.push(i);
83690
+ refocusTapIndexesBySelector.set(current.selector, list);
83691
+ }
83692
+ continue;
83693
+ }
83694
+ if (current.type !== "TYPE" || !current.selector)
83695
+ continue;
83696
+ const priorIndex = lastTypeIndexBySelector.get(current.selector);
83697
+ if (priorIndex !== void 0) {
83698
+ toDrop.add(priorIndex);
83699
+ for (const tapIndex of refocusTapIndexesBySelector.get(current.selector) ?? []) {
83700
+ if (tapIndex > priorIndex && tapIndex < i)
83701
+ toDrop.add(tapIndex);
83702
+ }
83703
+ }
83704
+ lastTypeIndexBySelector.set(current.selector, i);
83705
+ refocusTapIndexesBySelector.set(current.selector, []);
83706
+ }
83707
+ if (toDrop.size === 0)
83708
+ return actions;
83709
+ return actions.filter((_, index) => !toDrop.has(index));
83710
+ }
83711
+ function coalesceRepeatedTaps(actions) {
83712
+ if (actions.length <= 1)
83713
+ return actions;
83714
+ const toDrop = /* @__PURE__ */ new Set();
83715
+ const lastTapIndexBySelector = /* @__PURE__ */ new Map();
83716
+ for (let i = 0; i < actions.length; i++) {
83717
+ const action = actions[i];
83718
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT") {
83719
+ lastTapIndexBySelector.clear();
83720
+ continue;
83721
+ }
83722
+ if (isDurableMutation(action)) {
83723
+ lastTapIndexBySelector.clear();
83724
+ continue;
83725
+ }
83726
+ if (action.type !== "TAP" || !action.selector)
83727
+ continue;
83728
+ const key = action.metadata.structuralKey;
83729
+ const priorIndex = lastTapIndexBySelector.get(action.selector);
83730
+ if (priorIndex !== void 0 && key && actions[priorIndex].metadata.structuralKey === key) {
83731
+ const priorHash = actions[priorIndex].metadata.screenHash;
83732
+ const currentHash = action.metadata.screenHash;
83733
+ const screenAdvanced = !!priorHash && !!currentHash && priorHash !== currentHash;
83734
+ if (!screenAdvanced) {
83735
+ toDrop.add(priorIndex);
83736
+ }
83737
+ }
83738
+ lastTapIndexBySelector.set(action.selector, i);
83739
+ }
83740
+ if (toDrop.size === 0)
83741
+ return actions;
83742
+ return actions.filter((_, index) => !toDrop.has(index));
83743
+ }
83744
+ var MAX_DETOUR_LENGTH = 6;
83745
+ function isDetourClosingAction(action) {
83746
+ if (action.type === "BACK" || action.type === "HOME")
83747
+ return true;
83748
+ return isNavigationChromeTap(action);
83749
+ }
83750
+ function isEvidenceAction(action) {
83751
+ return action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "WAIT_FOR_ELEMENT";
83752
+ }
83753
+ function elideNoOpDetours(actions) {
83754
+ if (actions.length < 2)
83755
+ return actions;
83756
+ const toDrop = /* @__PURE__ */ new Set();
83757
+ for (let j = 0; j < actions.length; j++) {
83758
+ if (toDrop.has(j))
83759
+ continue;
83760
+ if (!isDetourClosingAction(actions[j]))
83761
+ continue;
83762
+ const keyAfter = actions[j].metadata.structuralKey;
83763
+ if (!keyAfter)
83764
+ continue;
83765
+ const next = actions[j + 1];
83766
+ if (next && isEvidenceAction(next))
83767
+ continue;
83768
+ let bestStart = null;
83769
+ let leftScreen = false;
83770
+ const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83771
+ for (let i = j; i >= earliestStart; i--) {
83772
+ if (i < j) {
83773
+ const inside = actions[i];
83774
+ if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83775
+ break;
83776
+ }
83777
+ const insideKey = inside.metadata.structuralKey;
83778
+ if (insideKey && insideKey !== keyAfter)
83779
+ leftScreen = true;
83780
+ }
83781
+ if (i === 0)
83782
+ continue;
83783
+ const keyBefore = actions[i - 1].metadata.structuralKey;
83784
+ if (i < j && leftScreen && keyBefore && keyBefore === keyAfter) {
83785
+ bestStart = i;
83786
+ }
83787
+ }
83788
+ if (bestStart !== null) {
83789
+ for (let k = bestStart; k <= j; k++)
83790
+ toDrop.add(k);
83791
+ }
83792
+ }
83793
+ if (toDrop.size === 0)
83794
+ return actions;
83795
+ return actions.filter((_, index) => !toDrop.has(index));
83796
+ }
83361
83797
  function mobileActionsToPseudoPlaywright(actions) {
83362
83798
  const escape = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
83363
83799
  const lines = ["test('mobile', async ({ page }) => {"];
@@ -83445,6 +83881,15 @@ ${statePacket}`, redactionCredentials) },
83445
83881
  if (actions.length < beforeCycleCollapse) {
83446
83882
  log2(` collapsed ${beforeCycleCollapse - actions.length} retry-cycle step(s) out of the recorded trace (${beforeCycleCollapse} \u2192 ${actions.length}).`);
83447
83883
  }
83884
+ const beforeTypeCoalesce = actions;
83885
+ actions = coalesceSameFieldTypes(actions);
83886
+ logWanderDiff("coalesceSameFieldTypes", beforeTypeCoalesce, actions, log2);
83887
+ const beforeTapCoalesce = actions;
83888
+ actions = coalesceRepeatedTaps(actions);
83889
+ logWanderDiff("coalesceRepeatedTaps", beforeTapCoalesce, actions, log2);
83890
+ const beforeDetourElision = actions;
83891
+ actions = elideNoOpDetours(actions);
83892
+ logWanderDiff("elideNoOpDetours", beforeDetourElision, actions, log2);
83448
83893
  const beforeContainerDrop = actions.length;
83449
83894
  actions = actions.filter((a) => !isBareContainerTap(a));
83450
83895
  if (actions.length < beforeContainerDrop) {
@@ -83629,48 +84074,29 @@ ${statePacket}`, redactionCredentials) },
83629
84074
  log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
83630
84075
  return applyScenarioEntryDeepLink(driver, target, { appDataCleared: false, coldResetUnavailable: false, refuseGenerate: false, reason: "preserve_policy" }, log2);
83631
84076
  }
83632
- if (platform3 !== "IOS") {
83633
- const appDataCleared = await driver.resetAppState?.().catch((err) => {
83634
- log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
83635
- return false;
83636
- }) ?? false;
83637
- if (appDataCleared) {
83638
- return applyScenarioEntryDeepLink(driver, target, { appDataCleared: true, coldResetUnavailable: false, refuseGenerate: false }, log2);
83639
- }
83640
- await driver.relaunch();
83641
- if (resetPolicy === "CLEAR_APP_DATA") {
83642
- log2(" CLEAR_APP_DATA required but app-data reset was unavailable \u2014 refusing to generate from warm state.");
83643
- return {
83644
- appDataCleared: false,
83645
- coldResetUnavailable: true,
83646
- refuseGenerate: true,
83647
- reason: "clear_app_data_unavailable"
83648
- };
83649
- }
83650
- log2(" app-data reset unavailable \u2014 generating with @warm-state (relaunch only; may not match cold-boot replay).");
83651
- return applyScenarioEntryDeepLink(driver, target, {
83652
- appDataCleared: false,
83653
- coldResetUnavailable: true,
83654
- refuseGenerate: false,
83655
- reason: platform3 === "ANDROID" ? "android_reset_unavailable" : "reset_unavailable"
83656
- }, log2);
84077
+ const appDataCleared = await driver.resetAppState?.().catch((err) => {
84078
+ log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
84079
+ return false;
84080
+ }) ?? false;
84081
+ if (appDataCleared) {
84082
+ return applyScenarioEntryDeepLink(driver, target, { appDataCleared: true, coldResetUnavailable: false, refuseGenerate: false }, log2);
83657
84083
  }
83658
84084
  await driver.relaunch();
83659
84085
  if (resetPolicy === "CLEAR_APP_DATA") {
83660
- log2(" CLEAR_APP_DATA required but iOS has no supported clearApp primitive \u2014 refusing to generate from warm state.");
84086
+ log2(platform3 === "IOS" ? " CLEAR_APP_DATA required but this iOS device cannot be cleared (simulator required) \u2014 refusing to generate from warm state." : " CLEAR_APP_DATA required but app-data reset was unavailable \u2014 refusing to generate from warm state.");
83661
84087
  return {
83662
84088
  appDataCleared: false,
83663
84089
  coldResetUnavailable: true,
83664
84090
  refuseGenerate: true,
83665
- reason: "ios_clear_app_data_unavailable"
84091
+ reason: platform3 === "IOS" ? "ios_clear_app_data_unavailable" : "clear_app_data_unavailable"
83666
84092
  };
83667
84093
  }
83668
- log2(" iOS has no clearApp \u2014 scenario records after relaunch (warm-state risk for auth flows).");
84094
+ log2(platform3 === "IOS" ? " iOS app-data reset unavailable (physical device or simctl failure) \u2014 generating with @warm-state (relaunch only; may not match cold-boot replay)." : " app-data reset unavailable \u2014 generating with @warm-state (relaunch only; may not match cold-boot replay).");
83669
84095
  return applyScenarioEntryDeepLink(driver, target, {
83670
84096
  appDataCleared: false,
83671
84097
  coldResetUnavailable: true,
83672
84098
  refuseGenerate: false,
83673
- reason: "ios_no_clear_app"
84099
+ reason: platform3 === "IOS" ? "ios_no_clear_app" : platform3 === "ANDROID" ? "android_reset_unavailable" : "reset_unavailable"
83674
84100
  }, log2);
83675
84101
  }
83676
84102
  async function runMobileGeneratePhase(args) {
@@ -84273,6 +84699,8 @@ var require_mobile_heal_loop = __commonJS({
84273
84699
  "use strict";
84274
84700
  Object.defineProperty(exports2, "__esModule", { value: true });
84275
84701
  exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = void 0;
84702
+ exports2.timed = timed;
84703
+ exports2.timedSync = timedSync;
84276
84704
  exports2.executeMobileScriptDrivenHeal = executeMobileScriptDrivenHeal;
84277
84705
  exports2.executeMobileTestWithHealing = executeMobileTestWithHealing2;
84278
84706
  exports2.executeMobileLiveHeal = executeMobileLiveHeal2;
@@ -84482,6 +84910,32 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84482
84910
  function detectInvalidMobileAuthSetup(steps, error2) {
84483
84911
  return detectInvalidExternalAuthSetup(steps, error2) ?? detectInvalidAuthValidationSetup(steps, error2);
84484
84912
  }
84913
+ function formatHealTimingLine(category, label, ms, failed, errMessage) {
84914
+ const base2 = `\u{1FA7A}\u23F1 [${category}] ${label} \u2014 ${ms}ms`;
84915
+ return failed ? `${base2} (FAILED: ${errMessage ?? "unknown error"})` : base2;
84916
+ }
84917
+ async function timed(category, label, onProgress, fn) {
84918
+ const start = Date.now();
84919
+ try {
84920
+ const result = await fn();
84921
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84922
+ return result;
84923
+ } catch (err) {
84924
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84925
+ throw err;
84926
+ }
84927
+ }
84928
+ function timedSync(category, label, onProgress, fn) {
84929
+ const start = Date.now();
84930
+ try {
84931
+ const result = fn();
84932
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84933
+ return result;
84934
+ } catch (err) {
84935
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84936
+ throw err;
84937
+ }
84938
+ }
84485
84939
  var IDENTITY_GATE_ERROR_RE = /No identity locator \(accessibility id \/ resource id \/ text\) available for this step/;
84486
84940
  function isIdentityEqualityXpath(value) {
84487
84941
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
@@ -84699,21 +85153,23 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84699
85153
  return JSON.stringify(base2?.target) !== JSON.stringify(healed.target);
84700
85154
  }
84701
85155
  function findMissingChangedLocators(baseSteps, healedSteps, screen) {
84702
- const missing = [];
85156
+ const results = [];
84703
85157
  healedSteps.forEach((healed, index) => {
84704
85158
  if (!stepTargetChanged(baseSteps[index], healed))
84705
85159
  return;
84706
85160
  const candidate = stepToLocatorCandidate(healed);
84707
85161
  if (!candidate)
84708
85162
  return;
84709
- if ((0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length === 0) {
84710
- missing.push({
84711
- label: healed.target?.primary ?? JSON.stringify(candidate),
84712
- expressions: stepIdentityExpressions(healed)
84713
- });
85163
+ const matchCount = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length;
85164
+ const label = healed.target?.primary ?? JSON.stringify(candidate);
85165
+ const expressions = stepIdentityExpressions(healed);
85166
+ if (matchCount === 0) {
85167
+ results.push({ label, expressions, kind: "missing" });
85168
+ } else if (matchCount > 1) {
85169
+ results.push({ label, expressions, kind: "ambiguous", matchCount });
84714
85170
  }
84715
85171
  });
84716
- return missing;
85172
+ return results;
84717
85173
  }
84718
85174
  function changedStepExpressions(baseSteps, healedSteps) {
84719
85175
  const out = [];
@@ -84942,10 +85398,10 @@ ${args.directive}`);
84942
85398
  let content;
84943
85399
  let finishReason;
84944
85400
  try {
84945
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
85401
+ const response = await timed("model", `phase1-attempt-${attempt}-model-call`, options.onProgress, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
84946
85402
  arm();
84947
- return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
84948
- }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
85403
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85404
+ }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true }));
84949
85405
  content = response?.choices?.[0]?.message?.content ?? "";
84950
85406
  finishReason = response?.choices?.[0]?.finish_reason ?? void 0;
84951
85407
  } catch {
@@ -84988,14 +85444,18 @@ ${args.directive}`);
84988
85444
  continue;
84989
85445
  }
84990
85446
  if (activeFailingSource && isLocatorNotFound(activeError) && !isDrift(activeError)) {
84991
- const screen = (0, mobile_source_parser_js_1.parseMobileSource)(activeFailingSource, platform3);
84992
- const missing = findMissingChangedLocators(baseSteps, healedSteps, screen);
85447
+ const failingSourceSnapshot = activeFailingSource;
85448
+ const missing = timedSync("device-op", `phase1-attempt-${attempt}-static-verify`, options.onProgress, () => {
85449
+ const screen = (0, mobile_source_parser_js_1.parseMobileSource)(failingSourceSnapshot, platform3);
85450
+ return findMissingChangedLocators(baseSteps, healedSteps, screen);
85451
+ });
84993
85452
  if (missing.length > 0) {
84994
85453
  for (const entry of missing)
84995
85454
  pushUnique(triedLocators, entry.expressions);
84996
85455
  markRecoverableTurn();
84997
85456
  lastVerdict = "fix";
84998
- pendingDirective = `Your proposed locator ${missing[0].label} does NOT exist in the captured screen tree. Pick an identity locator (resource-id, accessibility-id, or exact @text=) that is PRESENT in the tree.`;
85457
+ const first = missing[0];
85458
+ pendingDirective = first.kind === "ambiguous" ? `Your proposed locator ${first.label} matches ${first.matchCount ?? "more than one"} elements in the captured screen tree \u2014 it cannot identify the recorded instance. Pick an identity locator (resource-id, accessibility-id, or exact @text=) that resolves to EXACTLY ONE element in the tree.` : `Your proposed locator ${first.label} does NOT exist in the captured screen tree. Pick an identity locator (resource-id, accessibility-id, or exact @text=) that is PRESENT in the tree.`;
84999
85459
  continue;
85000
85460
  }
85001
85461
  }
@@ -85009,9 +85469,10 @@ ${args.directive}`);
85009
85469
  pendingDirective = "The proposed fix could not be device-verified here. If it is not clearly correct, revise it.";
85010
85470
  continue;
85011
85471
  }
85472
+ const runNativeTest = options.runNativeTest;
85012
85473
  let verify;
85013
85474
  try {
85014
- verify = await options.runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
85475
+ verify = await timed("reexec", `phase1-attempt-${attempt}-device-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables }));
85015
85476
  } catch {
85016
85477
  return {
85017
85478
  passed: false,
@@ -85118,6 +85579,9 @@ ${args.directive}`);
85118
85579
  }
85119
85580
  }
85120
85581
  async function executeMobileTestWithHealing2(steps, target, ctx, options) {
85582
+ return timed("total", "heal-total", options.onProgress, () => executeMobileTestWithHealingImpl(steps, target, ctx, options));
85583
+ }
85584
+ async function executeMobileTestWithHealingImpl(steps, target, ctx, options) {
85121
85585
  const invalidAuthSetup = detectInvalidMobileAuthSetup(steps, ctx.error);
85122
85586
  if (invalidAuthSetup) {
85123
85587
  return { passed: false, verdict: "test_invalid", healReason: invalidAuthSetup };
@@ -85244,9 +85708,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85244
85708
  const candidate = partsToCandidate(parts);
85245
85709
  if (!hasIdentitySignal(candidate))
85246
85710
  return;
85247
- const first = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen)[0];
85248
- if (first)
85249
- await driver.tap(first.bounds);
85711
+ const matches = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen);
85712
+ const target = matches.find((el) => el.bounds.width > 0 && el.bounds.height > 0) ?? matches[0];
85713
+ if (target)
85714
+ await driver.tap(target.bounds);
85250
85715
  }
85251
85716
  function buildLiveUserContent(args) {
85252
85717
  const parts = [];
@@ -85311,7 +85776,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85311
85776
  options.onProgress?.(`Phase 2 pass ${pass}/${maxPasses} \u2014 observing the live screen...`);
85312
85777
  let snap;
85313
85778
  try {
85314
- snap = await liveDriver.snapshot();
85779
+ snap = await timed("device-op", `phase2-pass-${pass}-snapshot`, options.onProgress, () => liveDriver.snapshot());
85315
85780
  } catch {
85316
85781
  return {
85317
85782
  passed: false,
@@ -85337,10 +85802,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85337
85802
  ];
85338
85803
  let content;
85339
85804
  try {
85340
- const response = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(keepAlivePingable, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
85805
+ const response = await timed("model", `phase2-pass-${pass}-model-call`, options.onProgress, () => (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(keepAlivePingable, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
85341
85806
  arm();
85342
- return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
85343
- }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true }));
85807
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85808
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true })));
85344
85809
  content = response?.choices?.[0]?.message?.content ?? "";
85345
85810
  } catch {
85346
85811
  continue;
@@ -85372,7 +85837,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85372
85837
  const healedSteps = parsed.steps;
85373
85838
  let verify;
85374
85839
  try {
85375
- verify = await runNativeTest({ steps: healedSteps, target, testVariables });
85840
+ verify = await timed("reexec", `phase2-pass-${pass}-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables }));
85376
85841
  } catch {
85377
85842
  return {
85378
85843
  passed: false,
@@ -89226,7 +89691,7 @@ var require_kill_tree = __commonJS({
89226
89691
  var node_child_process_1 = require("child_process");
89227
89692
  var node_util_1 = require("util");
89228
89693
  var node_fs_1 = require("fs");
89229
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89694
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89230
89695
  var PS_TIMEOUT_MS = 2e3;
89231
89696
  var PS_BUFFER = 1024 * 1024;
89232
89697
  async function gracefulCloseWithTreeKill(options) {
@@ -89280,7 +89745,7 @@ var require_kill_tree = __commonJS({
89280
89745
  timer.unref?.();
89281
89746
  let stdout;
89282
89747
  try {
89283
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89748
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89284
89749
  maxBuffer: PS_BUFFER,
89285
89750
  signal: controller.signal
89286
89751
  });
@@ -89318,7 +89783,7 @@ var require_kill_tree = __commonJS({
89318
89783
  timer.unref?.();
89319
89784
  let stdout;
89320
89785
  try {
89321
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89786
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89322
89787
  maxBuffer: PS_BUFFER,
89323
89788
  signal: controller.signal
89324
89789
  });
@@ -89395,7 +89860,7 @@ var require_kill_tree = __commonJS({
89395
89860
  timer.unref?.();
89396
89861
  let stdout;
89397
89862
  try {
89398
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid="], {
89863
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid="], {
89399
89864
  maxBuffer: PS_BUFFER,
89400
89865
  signal: controller.signal
89401
89866
  });
@@ -99274,8 +99739,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99274
99739
  onRetry: (attempt, reason, delayMs) => logs2.push(` \u26A0 scenario-gen AI call retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
99275
99740
  });
99276
99741
  } catch (aiErr) {
99277
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99278
- logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg2}`);
99742
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99743
+ logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg3}`);
99279
99744
  throw (0, ai_retry_js_1.markAIProviderError)(aiErr);
99280
99745
  }
99281
99746
  const iterDuration = Date.now() - iterStart;
@@ -99318,8 +99783,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99318
99783
  try {
99319
99784
  toolArgs = JSON.parse(toolCall.function.arguments);
99320
99785
  } catch (parseErr) {
99321
- const errMsg2 = `Invalid tool arguments: ${parseErr.message}`;
99322
- toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg2 });
99786
+ const errMsg3 = `Invalid tool arguments: ${parseErr.message}`;
99787
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg3 });
99323
99788
  continue;
99324
99789
  }
99325
99790
  if (toolName === "finish_generate") {
@@ -100577,7 +101042,7 @@ var require_util4 = __commonJS({
100577
101042
  }
100578
101043
  path = url.path;
100579
101044
  }
100580
- var isAbsolute = exports2.isAbsolute(path);
101045
+ var isAbsolute2 = exports2.isAbsolute(path);
100581
101046
  var parts = path.split(/\/+/);
100582
101047
  for (var part, up = 0, i = parts.length - 1; i >= 0; i--) {
100583
101048
  part = parts[i];
@@ -100597,7 +101062,7 @@ var require_util4 = __commonJS({
100597
101062
  }
100598
101063
  path = parts.join("/");
100599
101064
  if (path === "") {
100600
- path = isAbsolute ? "/" : ".";
101065
+ path = isAbsolute2 ? "/" : ".";
100601
101066
  }
100602
101067
  if (url) {
100603
101068
  url.path = path;
@@ -100606,7 +101071,7 @@ var require_util4 = __commonJS({
100606
101071
  return path;
100607
101072
  }
100608
101073
  exports2.normalize = normalize;
100609
- function join7(aRoot, aPath) {
101074
+ function join8(aRoot, aPath) {
100610
101075
  if (aRoot === "") {
100611
101076
  aRoot = ".";
100612
101077
  }
@@ -100638,7 +101103,7 @@ var require_util4 = __commonJS({
100638
101103
  }
100639
101104
  return joined;
100640
101105
  }
100641
- exports2.join = join7;
101106
+ exports2.join = join8;
100642
101107
  exports2.isAbsolute = function(aPath) {
100643
101108
  return aPath.charAt(0) === "/" || urlRegexp.test(aPath);
100644
101109
  };
@@ -100811,7 +101276,7 @@ var require_util4 = __commonJS({
100811
101276
  parsed.path = parsed.path.substring(0, index + 1);
100812
101277
  }
100813
101278
  }
100814
- sourceURL = join7(urlGenerate(parsed), sourceURL);
101279
+ sourceURL = join8(urlGenerate(parsed), sourceURL);
100815
101280
  }
100816
101281
  return normalize(sourceURL);
100817
101282
  }
@@ -314524,8 +314989,8 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314524
314989
  ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel)
314525
314990
  })), { label: `script-heal-${attempt}` });
314526
314991
  } catch (aiErr) {
314527
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314528
- logs2.push(`\u26A0 AI call failed: ${errMsg2}`);
314992
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314993
+ logs2.push(`\u26A0 AI call failed: ${errMsg3}`);
314529
314994
  attemptHistory.push({
314530
314995
  phase: "script",
314531
314996
  attempt,
@@ -314533,13 +314998,13 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314533
314998
  passed: false,
314534
314999
  changedCode: false,
314535
315000
  summary: "AI call failed before returning a heal result",
314536
- errorSummary: truncateForPrompt(errMsg2),
315001
+ errorSummary: truncateForPrompt(errMsg3),
314537
315002
  failedStep: summarizeFailedStep(currentStepResults)
314538
315003
  });
314539
315004
  if (feedbackEnabled) {
314540
- lastTransportFailure = truncateForPrompt(errMsg2, 200) ?? "the AI request failed";
315005
+ lastTransportFailure = truncateForPrompt(errMsg3, 200) ?? "the AI request failed";
314541
315006
  } else {
314542
- currentError = `AI call failed on attempt ${attempt}: ${errMsg2}`;
315007
+ currentError = `AI call failed on attempt ${attempt}: ${errMsg3}`;
314543
315008
  }
314544
315009
  markRecoverableTurn();
314545
315010
  continue;
@@ -314823,8 +315288,8 @@ ${currentError.slice(0, 2e3)}`
314823
315288
  // item 5b
314824
315289
  });
314825
315290
  } catch (runErr) {
314826
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
314827
- logs2.push(`\u{1F4A5} Native execution threw: ${errMsg2}`);
315291
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
315292
+ logs2.push(`\u{1F4A5} Native execution threw: ${errMsg3}`);
314828
315293
  attemptHistory.push({
314829
315294
  phase: "script",
314830
315295
  attempt,
@@ -314832,9 +315297,9 @@ ${currentError.slice(0, 2e3)}`
314832
315297
  passed: false,
314833
315298
  changedCode: true,
314834
315299
  summary: truncateForPrompt(changeDesc) ?? "Applied code fix, but native execution threw before verification completed",
314835
- errorSummary: truncateForPrompt(errMsg2)
315300
+ errorSummary: truncateForPrompt(errMsg3)
314836
315301
  });
314837
- currentError = `Native execution threw: ${errMsg2}`;
315302
+ currentError = `Native execution threw: ${errMsg3}`;
314838
315303
  currentStepResults = [];
314839
315304
  continue;
314840
315305
  }
@@ -315590,9 +316055,9 @@ Last error (after heal attempts): ${lastErrorSnippet}` : `Heal aborted: same err
315590
316055
  previousErrorSignature = errorSig;
315591
316056
  }
315592
316057
  } catch (runErr) {
315593
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
315594
- logs2.push(`\u{1F4A5} Native run threw: ${errMsg2}`);
315595
- currentError = errMsg2;
316058
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
316059
+ logs2.push(`\u{1F4A5} Native run threw: ${errMsg3}`);
316060
+ currentError = errMsg3;
315596
316061
  currentStepResults = [];
315597
316062
  }
315598
316063
  } else {
@@ -316154,8 +316619,8 @@ When you call finish_fix, preserve locators only when they are stable structural
316154
316619
  }
316155
316620
  }
316156
316621
  } catch (toolErr) {
316157
- const errMsg2 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316158
- let errorContent = `Error: ${errMsg2}`;
316622
+ const errMsg3 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316623
+ let errorContent = `Error: ${errMsg3}`;
316159
316624
  try {
316160
316625
  const consoleResult = await mcpClient.callTool({ name: "browser_console_messages", arguments: {} });
316161
316626
  const consoleText = Array.isArray(consoleResult.content) ? consoleResult.content.map((c) => c.type === "text" ? c.text ?? "" : "").join("\n") : String(consoleResult.content ?? "");
@@ -316176,8 +316641,8 @@ ${errorContent}`;
316176
316641
  }
316177
316642
  }
316178
316643
  } catch (mcpErr) {
316179
- const errMsg2 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316180
- logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg2}`);
316644
+ const errMsg3 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316645
+ logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg3}`);
316181
316646
  }
316182
316647
  if (verdict === "app_bug") {
316183
316648
  phase2AttemptHistory.push({
@@ -316771,9 +317236,9 @@ ${testBlocks}
316771
317236
  }
316772
317237
  emit(` \u2713 ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
316773
317238
  } catch (err) {
316774
- const errMsg2 = err instanceof Error ? err.message : String(err);
316775
- toolResultContent = `ERROR: ${errMsg2}`;
316776
- emit(` \u2717 ${errMsg2}`);
317239
+ const errMsg3 = err instanceof Error ? err.message : String(err);
317240
+ toolResultContent = `ERROR: ${errMsg3}`;
317241
+ emit(` \u2717 ${errMsg3}`);
316777
317242
  }
316778
317243
  toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: toolResultContent });
316779
317244
  }
@@ -323057,8 +323522,8 @@ var require_capture_page_normalizer = __commonJS({
323057
323522
  const snapItems = Array.isArray(snapResult.content) ? snapResult.content : [];
323058
323523
  snapshotText = extractInlineSnapshotYaml(snapItems.find((c) => c.type === "text")?.text || null);
323059
323524
  } catch (err) {
323060
- const errMsg2 = err instanceof Error ? err.message : String(err);
323061
- const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg2);
323525
+ const errMsg3 = err instanceof Error ? err.message : String(err);
323526
+ const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg3);
323062
323527
  return {
323063
323528
  snapshotText: null,
323064
323529
  meta: {
@@ -327134,9 +327599,9 @@ ${currentSummary}`;
327134
327599
  });
327135
327600
  }
327136
327601
  } catch (err) {
327137
- const errMsg2 = err instanceof Error ? err.message : String(err);
327138
- logs2.push(` Error persisting page: ${errMsg2}`);
327139
- toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: `ERROR persisting page: ${errMsg2}` });
327602
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327603
+ logs2.push(` Error persisting page: ${errMsg3}`);
327604
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: `ERROR persisting page: ${errMsg3}` });
327140
327605
  }
327141
327606
  continue;
327142
327607
  }
@@ -327338,12 +327803,12 @@ ${currentSummary}`;
327338
327803
  })
327339
327804
  });
327340
327805
  } catch (err) {
327341
- const errMsg2 = err instanceof Error ? err.message : String(err);
327342
- logs2.push(` Error persisting page: ${errMsg2}`);
327806
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327807
+ logs2.push(` Error persisting page: ${errMsg3}`);
327343
327808
  toolResults.push({
327344
327809
  role: "tool",
327345
327810
  tool_call_id: toolCall.id,
327346
- content: `ERROR persisting page: ${errMsg2}`
327811
+ content: `ERROR persisting page: ${errMsg3}`
327347
327812
  });
327348
327813
  }
327349
327814
  continue;
@@ -327378,12 +327843,12 @@ ${currentSummary}`;
327378
327843
  content: JSON.stringify({ acknowledged: true })
327379
327844
  });
327380
327845
  } catch (err) {
327381
- const errMsg2 = err instanceof Error ? err.message : String(err);
327382
- logs2.push(` Error persisting transition: ${errMsg2}`);
327846
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327847
+ logs2.push(` Error persisting transition: ${errMsg3}`);
327383
327848
  toolResults.push({
327384
327849
  role: "tool",
327385
327850
  tool_call_id: toolCall.id,
327386
- content: `ERROR persisting transition: ${errMsg2}`
327851
+ content: `ERROR persisting transition: ${errMsg3}`
327387
327852
  });
327388
327853
  }
327389
327854
  continue;
@@ -328079,15 +328544,15 @@ Exploration complete: ${summary}`);
328079
328544
  toolResultContent = (0, snapshot_context_compactor_js_1.formatMCPToolResult)(toolResultContent, MAX_TOOL_RESULT_CHARS);
328080
328545
  logs2.push(` OK: ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
328081
328546
  } catch (err) {
328082
- const errMsg2 = err instanceof Error ? err.message : String(err);
328083
- toolResultContent = `ERROR: ${errMsg2}`;
328084
- logs2.push(` Error: ${errMsg2}`);
328547
+ const errMsg3 = err instanceof Error ? err.message : String(err);
328548
+ toolResultContent = `ERROR: ${errMsg3}`;
328549
+ logs2.push(` Error: ${errMsg3}`);
328085
328550
  if (useV2) {
328086
328551
  (0, exploration_state_js_1.reduceEvent)(state2, {
328087
328552
  type: "ACTION_FAILED",
328088
328553
  route: state2.currentRoute,
328089
328554
  action: `${toolName}(${JSON.stringify(toolArgs).slice(0, 100)})`,
328090
- error: errMsg2
328555
+ error: errMsg3
328091
328556
  });
328092
328557
  }
328093
328558
  }
@@ -330936,10 +331401,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
330936
331401
  onLog: options.onLog,
330937
331402
  onProvenCommands: recordProvenCommands
330938
331403
  });
330939
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
331404
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
330940
331405
  const fixPrompt = [
330941
331406
  "Your previous attempt did not produce a valid result file.",
330942
- `Error: ${errMsg2}`,
331407
+ `Error: ${errMsg3}`,
330943
331408
  `Re-read BRIEF.md and write a valid SurveyorOutput JSON to ${resultFilePath}.`,
330944
331409
  "Single JSON object. No prose. Then exit."
330945
331410
  ].join("\n");
@@ -333080,7 +333545,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333080
333545
  log2(`First-pass discovery-result invalid or missing: ${firstError.message}`);
333081
333546
  }
333082
333547
  if (!parsedResult) {
333083
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333548
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333084
333549
  if (planDebounceTimerRef.current) {
333085
333550
  clearTimeout(planDebounceTimerRef.current);
333086
333551
  planDebounceTimerRef.current = void 0;
@@ -333105,7 +333570,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333105
333570
  ""
333106
333571
  ].join("\n");
333107
333572
  const fixPrompt = [
333108
- `Your previous attempt did not produce a valid result file. Error: ${errMsg2}`,
333573
+ `Your previous attempt did not produce a valid result file. Error: ${errMsg3}`,
333109
333574
  "Re-read BRIEF.md.",
333110
333575
  phaseContext,
333111
333576
  `Continue from where you left off \u2014 already verified tests are in ${testsDirPath}/`,
@@ -334734,10 +335199,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
334734
335199
  onLog: parserLog,
334735
335200
  onProvenCommands: recordProvenCommands
334736
335201
  });
334737
- const errMsg2 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
335202
+ const errMsg3 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
334738
335203
  const fixPrompt = [
334739
335204
  "Your previous attempt did not leave a valid result file.",
334740
- `Error: ${errMsg2}`,
335205
+ `Error: ${errMsg3}`,
334741
335206
  `Writing a valid SurveyorOutput JSON object to ${resultFilePath} is your ONLY priority now.`,
334742
335207
  "Procedure: navigate to at most 6-8 of the main pages, then IMMEDIATELY use",
334743
335208
  `the Write tool to save the SurveyorOutput JSON to ${resultFilePath}. Do this`,
@@ -338618,8 +339083,8 @@ var require_resource_governor = __commonJS({
338618
339083
  function isDiscoveryLike(mode) {
338619
339084
  return mode === "discovery";
338620
339085
  }
338621
- function evaluateRunAdmission2(options, request) {
338622
- const snapshot = getResourceSnapshot2();
339086
+ function evaluateRunAdmission2(options, request, snapshotOverride) {
339087
+ const snapshot = snapshotOverride ?? getResourceSnapshot2();
338623
339088
  const candidateWeight = modeWeight(request.mode, options.weights);
338624
339089
  const currentUnits = activeUnits(request.activeByType, options.weights);
338625
339090
  const memory = pressureFactor(snapshot.memoryPct, options.memorySoftPct, options.memoryHardPct);
@@ -341117,8 +341582,8 @@ var require_adm_zip = __commonJS({
341117
341582
  return null;
341118
341583
  }
341119
341584
  function fixPath(zipPath) {
341120
- const { join: join7, normalize, sep } = pth.posix;
341121
- return join7(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341585
+ const { join: join8, normalize, sep } = pth.posix;
341586
+ return join8(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341122
341587
  }
341123
341588
  function filenameFilter(filterfn) {
341124
341589
  if (filterfn instanceof RegExp) {
@@ -343953,11 +344418,11 @@ var require_winchars = __commonJS({
343953
344418
  var require_strip_absolute_path = __commonJS({
343954
344419
  "../../node_modules/tar/lib/strip-absolute-path.js"(exports2, module2) {
343955
344420
  "use strict";
343956
- var { isAbsolute, parse } = require("path").win32;
344421
+ var { isAbsolute: isAbsolute2, parse } = require("path").win32;
343957
344422
  module2.exports = (path) => {
343958
344423
  let r = "";
343959
344424
  let parsed = parse(path);
343960
- while (isAbsolute(path) || parsed.root) {
344425
+ while (isAbsolute2(path) || parsed.root) {
343961
344426
  const root = path.charAt(0) === "/" && path.slice(0, 4) !== "//?/" ? "/" : parsed.root;
343962
344427
  path = path.slice(root.length);
343963
344428
  r += root;
@@ -346469,7 +346934,7 @@ var require_update = __commonJS({
346469
346934
  var require_opts_arg = __commonJS({
346470
346935
  "../../node_modules/tar/node_modules/mkdirp/lib/opts-arg.js"(exports2, module2) {
346471
346936
  "use strict";
346472
- var { promisify: promisify3 } = require("util");
346937
+ var { promisify: promisify4 } = require("util");
346473
346938
  var fs = require("fs");
346474
346939
  var optsArg = (opts) => {
346475
346940
  if (!opts)
@@ -346483,9 +346948,9 @@ var require_opts_arg = __commonJS({
346483
346948
  else
346484
346949
  throw new TypeError("invalid options argument");
346485
346950
  opts.mkdir = opts.mkdir || opts.fs.mkdir || fs.mkdir;
346486
- opts.mkdirAsync = promisify3(opts.mkdir);
346951
+ opts.mkdirAsync = promisify4(opts.mkdir);
346487
346952
  opts.stat = opts.stat || opts.fs.stat || fs.stat;
346488
- opts.statAsync = promisify3(opts.stat);
346953
+ opts.statAsync = promisify4(opts.stat);
346489
346954
  opts.statSync = opts.statSync || opts.fs.statSync || fs.statSync;
346490
346955
  opts.mkdirSync = opts.mkdirSync || opts.fs.mkdirSync || fs.mkdirSync;
346491
346956
  return opts;
@@ -346738,10 +347203,10 @@ var require_chownr = __commonJS({
346738
347203
  }
346739
347204
  } : (path2, uid, gid) => lchownSync(path2, uid, gid);
346740
347205
  var nodeVersion = process.version;
346741
- var readdir2 = (path2, options, cb) => fs.readdir(path2, options, cb);
347206
+ var readdir3 = (path2, options, cb) => fs.readdir(path2, options, cb);
346742
347207
  var readdirSync = (path2, options) => fs.readdirSync(path2, options);
346743
347208
  if (/^v4\./.test(nodeVersion))
346744
- readdir2 = (path2, options, cb) => fs.readdir(path2, cb);
347209
+ readdir3 = (path2, options, cb) => fs.readdir(path2, cb);
346745
347210
  var chown = (cpath, uid, gid, cb) => {
346746
347211
  fs[LCHOWN](cpath, uid, gid, handleEISDIR(cpath, uid, gid, (er) => {
346747
347212
  cb(er && er.code !== "ENOENT" ? er : null);
@@ -346768,7 +347233,7 @@ var require_chownr = __commonJS({
346768
347233
  }
346769
347234
  };
346770
347235
  var chownr = (p, uid, gid, cb) => {
346771
- readdir2(p, { withFileTypes: true }, (er, children) => {
347236
+ readdir3(p, { withFileTypes: true }, (er, children) => {
346772
347237
  if (er) {
346773
347238
  if (er.code === "ENOENT")
346774
347239
  return cb();
@@ -347042,7 +347507,7 @@ var require_path_reservations = __commonJS({
347042
347507
  var assert = require("assert");
347043
347508
  var normalize = require_normalize_unicode();
347044
347509
  var stripSlashes = require_strip_trailing_slashes();
347045
- var { join: join7 } = require("path");
347510
+ var { join: join8 } = require("path");
347046
347511
  var platform3 = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform;
347047
347512
  var isWindows = platform3 === "win32";
347048
347513
  module2.exports = () => {
@@ -347051,7 +347516,7 @@ var require_path_reservations = __commonJS({
347051
347516
  const getDirs = (path) => {
347052
347517
  const dirs = path.split("/").slice(0, -1).reduce((set, path2) => {
347053
347518
  if (set.length) {
347054
- path2 = join7(set[set.length - 1], path2);
347519
+ path2 = join8(set[set.length - 1], path2);
347055
347520
  }
347056
347521
  set.push(path2 || "/");
347057
347522
  return set;
@@ -347119,7 +347584,7 @@ var require_path_reservations = __commonJS({
347119
347584
  };
347120
347585
  const reserve = (paths, fn) => {
347121
347586
  paths = isWindows ? ["win32 parallelization disabled"] : paths.map((p) => {
347122
- return stripSlashes(join7(normalize(p))).toLowerCase();
347587
+ return stripSlashes(join8(normalize(p))).toLowerCase();
347123
347588
  });
347124
347589
  const dirs = new Set(
347125
347590
  paths.map((path) => getDirs(path)).reduce((a, b) => a.concat(b))
@@ -347174,7 +347639,7 @@ var require_unpack = __commonJS({
347174
347639
  var fs = require("fs");
347175
347640
  var fsm = require_fs_minipass();
347176
347641
  var path = require("path");
347177
- var mkdir2 = require_mkdir();
347642
+ var mkdir3 = require_mkdir();
347178
347643
  var wc = require_winchars();
347179
347644
  var pathReservations = require_path_reservations();
347180
347645
  var stripAbsolutePath = require_strip_absolute_path();
@@ -347425,7 +347890,7 @@ var require_unpack = __commonJS({
347425
347890
  }
347426
347891
  }
347427
347892
  [MKDIR](dir, mode, cb) {
347428
- mkdir2(normPath(dir), {
347893
+ mkdir3(normPath(dir), {
347429
347894
  uid: this.uid,
347430
347895
  gid: this.gid,
347431
347896
  processUid: this.processUid,
@@ -347828,7 +348293,7 @@ var require_unpack = __commonJS({
347828
348293
  }
347829
348294
  [MKDIR](dir, mode) {
347830
348295
  try {
347831
- return mkdir2.sync(normPath(dir), {
348296
+ return mkdir3.sync(normPath(dir), {
347832
348297
  uid: this.uid,
347833
348298
  gid: this.gid,
347834
348299
  processUid: this.processUid,
@@ -351334,7 +351799,7 @@ var require_process_inventory = __commonJS({
351334
351799
  var node_child_process_1 = require("child_process");
351335
351800
  var node_os_1 = __importDefault(require("os"));
351336
351801
  var node_util_1 = require("util");
351337
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351802
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351338
351803
  var DEFAULT_LIMIT = 60;
351339
351804
  var DEFAULT_TIMEOUT_MS2 = 800;
351340
351805
  var MAX_COMMAND_LENGTH = 320;
@@ -351396,7 +351861,7 @@ var require_process_inventory = __commonJS({
351396
351861
  const timer = setTimeout(() => controller.abort(), timeoutMs);
351397
351862
  timer.unref?.();
351398
351863
  try {
351399
- const { stdout } = await execFileAsync3(command, args, {
351864
+ const { stdout } = await execFileAsync4(command, args, {
351400
351865
  maxBuffer: 1024 * 1024,
351401
351866
  signal: controller.signal
351402
351867
  });
@@ -352542,7 +353007,7 @@ var require_package4 = __commonJS({
352542
353007
  "package.json"(exports2, module2) {
352543
353008
  module2.exports = {
352544
353009
  name: "@validate.qa/runner",
352545
- version: "1.0.70",
353010
+ version: "1.0.72",
352546
353011
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
352547
353012
  bin: {
352548
353013
  "validate-runner": "dist/cli.js"
@@ -352551,7 +353016,7 @@ var require_package4 = __commonJS({
352551
353016
  build: "tsup",
352552
353017
  dev: "tsx src/cli.ts",
352553
353018
  test: "node --import tsx --test src/version-check.test.ts src/services/auth-state.test.ts",
352554
- "test:mobile": "node --import tsx --test --test-concurrency=2 src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
353019
+ "test:mobile": "node --import tsx --test --test-concurrency=2 src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/runner-watchdog.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
352555
353020
  prepublishOnly: "npm run build"
352556
353021
  },
352557
353022
  files: [
@@ -352629,7 +353094,7 @@ function getConfigDir() {
352629
353094
  }
352630
353095
 
352631
353096
  // src/runner.ts
352632
- var import_path4 = require("path");
353097
+ var import_path5 = require("path");
352633
353098
  var import_os6 = require("os");
352634
353099
  var import_node_crypto2 = require("crypto");
352635
353100
 
@@ -353224,6 +353689,69 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
353224
353689
  };
353225
353690
  }
353226
353691
 
353692
+ // src/services/mobile/ios-simulator-reset.ts
353693
+ var import_child_process2 = require("child_process");
353694
+ var import_util = require("util");
353695
+ var import_promises = require("fs/promises");
353696
+ var import_path3 = require("path");
353697
+ var execFileAsync = (0, import_util.promisify)(import_child_process2.execFile);
353698
+ var SIMCTL_TIMEOUT_MS = 15e3;
353699
+ var SIMULATOR_UDID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
353700
+ function isIosSimulatorUdid(udid) {
353701
+ return typeof udid === "string" && SIMULATOR_UDID_RE.test(udid.trim());
353702
+ }
353703
+ function errMsg(err) {
353704
+ return err instanceof Error ? err.message : String(err);
353705
+ }
353706
+ async function simctl(args) {
353707
+ const { stdout } = await execFileAsync("xcrun", ["simctl", ...args], {
353708
+ timeout: SIMCTL_TIMEOUT_MS,
353709
+ encoding: "utf-8"
353710
+ });
353711
+ return stdout.trim();
353712
+ }
353713
+ async function clearIosSimulatorAppData(udid, bundleId, env = process.env) {
353714
+ if (!isIosSimulatorUdid(udid)) {
353715
+ return { ok: false, detail: `'${udid}' is not a simulator UDID \u2014 physical iOS devices cannot be cleared` };
353716
+ }
353717
+ let container;
353718
+ try {
353719
+ container = await simctl(["get_app_container", udid, bundleId, "data"]);
353720
+ } catch (err) {
353721
+ return { ok: false, detail: `simctl get_app_container failed (app not installed?): ${errMsg(err)}` };
353722
+ }
353723
+ if (!(0, import_path3.isAbsolute)(container) || !container.includes("/Containers/Data/Application/")) {
353724
+ return { ok: false, detail: `unexpected app container path: ${container}` };
353725
+ }
353726
+ try {
353727
+ await simctl(["spawn", udid, "defaults", "delete", bundleId]);
353728
+ } catch {
353729
+ }
353730
+ try {
353731
+ const entries = await (0, import_promises.readdir)(container);
353732
+ for (const entry of entries) {
353733
+ await (0, import_promises.rm)((0, import_path3.join)(container, entry), { recursive: true, force: true });
353734
+ }
353735
+ for (const dir of ["Documents", "Library/Caches", "Library/Preferences", "tmp"]) {
353736
+ await (0, import_promises.mkdir)((0, import_path3.join)(container, dir), { recursive: true });
353737
+ }
353738
+ } catch (err) {
353739
+ return { ok: false, detail: `app container wipe failed: ${errMsg(err)}` };
353740
+ }
353741
+ const keychainPolicy = (env.VALIDATEQA_MOBILE_IOS_KEYCHAIN_RESET ?? "").trim().toLowerCase();
353742
+ if (keychainPolicy !== "never") {
353743
+ try {
353744
+ await simctl(["keychain", udid, "reset"]);
353745
+ } catch (err) {
353746
+ return {
353747
+ ok: false,
353748
+ detail: `simulator keychain reset failed (${errMsg(err)}) \u2014 app data was wiped but keychain items (e.g. auth tokens) may survive. Set VALIDATEQA_MOBILE_IOS_KEYCHAIN_RESET=never to skip the keychain step.`
353749
+ };
353750
+ }
353751
+ }
353752
+ return { ok: true };
353753
+ }
353754
+
353227
353755
  // src/services/mobile/appium-actions.ts
353228
353756
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
353229
353757
  var APPIUM_WEBVIEW_CONTEXT_TIMEOUT_MS = 12e4;
@@ -353406,9 +353934,12 @@ async function tapAt(sessionPath, bounds) {
353406
353934
  }
353407
353935
  }
353408
353936
  function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
353409
- if (platform3 !== "ANDROID") return false;
353410
353937
  const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
353411
353938
  if (policy === "never") return false;
353939
+ if (platform3 === "IOS") {
353940
+ return isIosSimulatorUdid(deviceId);
353941
+ }
353942
+ if (platform3 !== "ANDROID") return false;
353412
353943
  if (policy === "always") return true;
353413
353944
  return !!deviceId && deviceId.startsWith("emulator-");
353414
353945
  }
@@ -354287,6 +354818,45 @@ async function pressHome(sessionPath, platform3) {
354287
354818
  });
354288
354819
  }
354289
354820
 
354821
+ // src/services/device-quarantine.ts
354822
+ var QUARANTINE_MAX_MS = (() => {
354823
+ const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
354824
+ return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
354825
+ })();
354826
+ var quarantinedAt = /* @__PURE__ */ new Map();
354827
+ var DeviceQuarantinedError = class extends Error {
354828
+ constructor(deviceId) {
354829
+ super(
354830
+ `Device ${deviceId} is quarantined: a previous run on it was force-released by the watchdog and has not settled yet. Refusing to open a new Appium session until it does.`
354831
+ );
354832
+ this.name = "DeviceQuarantinedError";
354833
+ }
354834
+ };
354835
+ function quarantineDevice(deviceId, now = Date.now()) {
354836
+ quarantinedAt.set(deviceId, now);
354837
+ }
354838
+ function unquarantineDevice(deviceId) {
354839
+ quarantinedAt.delete(deviceId);
354840
+ }
354841
+ function isDeviceQuarantined(deviceId, now = Date.now()) {
354842
+ if (typeof deviceId !== "string") return false;
354843
+ const since = quarantinedAt.get(deviceId);
354844
+ if (since === void 0) return false;
354845
+ if (now - since >= QUARANTINE_MAX_MS) {
354846
+ quarantinedAt.delete(deviceId);
354847
+ console.warn(
354848
+ `[device-quarantine] Quarantine on ${deviceId} exceeded its ${Math.round(QUARANTINE_MAX_MS / 6e4)}min ceiling without the original run settling \u2014 lifting it so the device is usable again. If this recurs, the zombie run is wedged in non-device work (check the runner log).`
354849
+ );
354850
+ return false;
354851
+ }
354852
+ return true;
354853
+ }
354854
+ function assertDeviceNotQuarantined(deviceId, now = Date.now()) {
354855
+ if (isDeviceQuarantined(deviceId, now)) {
354856
+ throw new DeviceQuarantinedError(deviceId);
354857
+ }
354858
+ }
354859
+
354290
354860
  // src/services/appium-executor.ts
354291
354861
  var import_node_child_process2 = require("child_process");
354292
354862
  var import_node_util2 = require("util");
@@ -354295,11 +354865,11 @@ var import_node_path2 = require("path");
354295
354865
  var import_runner_core8 = __toESM(require_dist2());
354296
354866
 
354297
354867
  // src/services/device-discovery.ts
354298
- var import_child_process2 = require("child_process");
354868
+ var import_child_process3 = require("child_process");
354299
354869
  var import_os3 = require("os");
354300
354870
  function runCommand(command, args, timeout = 1e4) {
354301
354871
  try {
354302
- return (0, import_child_process2.execFileSync)(command, args, {
354872
+ return (0, import_child_process3.execFileSync)(command, args, {
354303
354873
  encoding: "utf-8",
354304
354874
  timeout,
354305
354875
  stdio: ["pipe", "pipe", "pipe"]
@@ -354599,9 +355169,6 @@ function countReadyDevicesByKind(platform3, snapshot = getCachedDeviceSnapshot()
354599
355169
  function selectFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(), requirements = {}) {
354600
355170
  const ready = snapshot.filter((d) => d.platform === platform3 && d.isAvailable && d.state === "BOOTED");
354601
355171
  if (ready.length === 0) return { kind: "busy", reason: `no booted ${platform3} device` };
354602
- if (requirements.resetPolicy === "CLEAR_APP_DATA" && platform3 !== "ANDROID") {
354603
- return { kind: "unsatisfiable", reason: "CLEAR_APP_DATA is unsupported on iOS" };
354604
- }
354605
355172
  let candidates = requirements.resetPolicy === "CLEAR_APP_DATA" ? ready.filter((d) => !d.isPhysical) : [...ready];
354606
355173
  const appId = requirements.appId?.trim();
354607
355174
  if (appId) {
@@ -354651,13 +355218,13 @@ function pickFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(),
354651
355218
  // src/services/mobile/mobile-network-capture.ts
354652
355219
  var import_node_child_process = require("child_process");
354653
355220
  var import_node_crypto = require("crypto");
354654
- var import_promises = require("fs/promises");
355221
+ var import_promises2 = require("fs/promises");
354655
355222
  var import_node_os = require("os");
354656
355223
  var import_node_path = require("path");
354657
355224
  var import_node_util = require("util");
354658
355225
  var import_mockttp = require("mockttp");
354659
355226
  var import_runner_core7 = __toESM(require_dist2());
354660
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
355227
+ var execFileAsync2 = (0, import_node_util.promisify)(import_node_child_process.execFile);
354661
355228
  var MAX_CALLS = 1e3;
354662
355229
  var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
354663
355230
  var DEVICE_CMD_TIMEOUT_MS = 15e3;
@@ -354845,7 +355412,7 @@ var MitmProxy = class {
354845
355412
  try {
354846
355413
  await server3.stop();
354847
355414
  } catch (err) {
354848
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
355415
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg2(err)}`);
354849
355416
  }
354850
355417
  this.onLog?.(
354851
355418
  `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
@@ -354870,7 +355437,7 @@ var MitmProxy = class {
354870
355437
  isHttps
354871
355438
  });
354872
355439
  } catch (err) {
354873
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
355440
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg2(err)}`);
354874
355441
  }
354875
355442
  }
354876
355443
  // ── response → finalize the matched request ───────────────────────────────
@@ -354913,7 +355480,7 @@ var MitmProxy = class {
354913
355480
  intercepted: true
354914
355481
  });
354915
355482
  } catch (err) {
354916
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
355483
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg2(err)}`);
354917
355484
  }
354918
355485
  }
354919
355486
  // ── tls-client-error → record WHY interception failed ─────────────────────
@@ -354991,16 +355558,16 @@ async function generateCaCert(dir, onLog) {
354991
355558
  // rather than on a short expiry.
354992
355559
  });
354993
355560
  const caCertPath = (0, import_node_path.join)(dir, "validateqa-mobile-ca.pem");
354994
- await (0, import_promises.writeFile)(caCertPath, cert, "utf-8");
355561
+ await (0, import_promises2.writeFile)(caCertPath, cert, "utf-8");
354995
355562
  return { caCertPath, caKeyPem: key, caCertPem: cert };
354996
355563
  } catch (err) {
354997
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
355564
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg2(err)}`);
354998
355565
  return null;
354999
355566
  }
355000
355567
  }
355001
355568
  async function androidReadProxy(deviceId) {
355002
355569
  try {
355003
- const { stdout } = await execFileAsync(
355570
+ const { stdout } = await execFileAsync2(
355004
355571
  "adb",
355005
355572
  ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
355006
355573
  { timeout: DEVICE_CMD_TIMEOUT_MS }
@@ -355013,33 +355580,33 @@ async function androidReadProxy(deviceId) {
355013
355580
  }
355014
355581
  async function androidSetProxy(deviceId, hostPort, onLog) {
355015
355582
  try {
355016
- await execFileAsync(
355583
+ await execFileAsync2(
355017
355584
  "adb",
355018
355585
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
355019
355586
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355020
355587
  );
355021
355588
  return true;
355022
355589
  } catch (err) {
355023
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
355590
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg2(err)}`);
355024
355591
  return false;
355025
355592
  }
355026
355593
  }
355027
355594
  async function androidClearProxy(deviceId, originalProxy, onLog) {
355028
355595
  const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
355029
355596
  try {
355030
- await execFileAsync(
355597
+ await execFileAsync2(
355031
355598
  "adb",
355032
355599
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
355033
355600
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355034
355601
  );
355035
355602
  } catch (err) {
355036
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
355603
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg2(err)}`);
355037
355604
  }
355038
355605
  }
355039
355606
  async function androidInstallCa(deviceId, caCertPath) {
355040
355607
  const devicePath = "/sdcard/validateqa-mobile-ca.pem";
355041
355608
  try {
355042
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355609
+ await execFileAsync2("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355043
355610
  timeout: DEVICE_CMD_TIMEOUT_MS
355044
355611
  });
355045
355612
  return {
@@ -355047,23 +355614,23 @@ async function androidInstallCa(deviceId, caCertPath) {
355047
355614
  note: `CA copied from ${caCertPath} to ${devicePath} but NOT auto-trusted: Android 7+ apps must opt into user CAs (network_security_config), and the system store requires a rooted/writable image. HTTPS bodies decrypt only for apps that trust user CAs; pinned apps never decrypt.`
355048
355615
  };
355049
355616
  } catch (err) {
355050
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
355617
+ return { installed: false, note: `CA push failed: ${errMsg2(err)}` };
355051
355618
  }
355052
355619
  }
355053
355620
  async function androidRemoveCa(deviceId, onLog) {
355054
355621
  try {
355055
- await execFileAsync(
355622
+ await execFileAsync2(
355056
355623
  "adb",
355057
355624
  ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
355058
355625
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355059
355626
  );
355060
355627
  } catch (err) {
355061
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
355628
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg2(err)}`);
355062
355629
  }
355063
355630
  }
355064
355631
  async function iosSimTrustCa(deviceId, caCertPath) {
355065
355632
  try {
355066
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355633
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355067
355634
  timeout: DEVICE_CMD_TIMEOUT_MS
355068
355635
  });
355069
355636
  return {
@@ -355071,18 +355638,18 @@ async function iosSimTrustCa(deviceId, caCertPath) {
355071
355638
  note: "CA added to simulator keychain (add-root-cert). NOTE: the simulator has no per-app HTTP proxy setting controllable via simctl \u2014 it shares the host network, so only traffic that actually routes through the proxy is intercepted; pinned apps still never decrypt."
355072
355639
  };
355073
355640
  } catch (err) {
355074
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
355641
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg2(err)}` };
355075
355642
  }
355076
355643
  }
355077
355644
  async function iosSimRemoveCa(deviceId, onLog) {
355078
355645
  if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
355079
355646
  try {
355080
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355647
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355081
355648
  timeout: DEVICE_CMD_TIMEOUT_MS
355082
355649
  });
355083
355650
  onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
355084
355651
  } catch (err) {
355085
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
355652
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg2(err)}`);
355086
355653
  }
355087
355654
  return;
355088
355655
  }
@@ -355090,7 +355657,7 @@ async function iosSimRemoveCa(deviceId, onLog) {
355090
355657
  `[mobile-net] NOTE: the validate.qa capture CA remains TRUSTED in simulator ${deviceId}. simctl has no scoped remove (only a full keychain reset that wipes ALL certs), so we leave the single CA in place. Use a throwaway simulator, or set VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET=1 to reset the whole sim keychain on teardown.`
355091
355658
  );
355092
355659
  }
355093
- function errMsg(err) {
355660
+ function errMsg2(err) {
355094
355661
  return err instanceof Error ? err.message : String(err);
355095
355662
  }
355096
355663
  function isProcessAlive(pid) {
@@ -355181,7 +355748,7 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355181
355748
  let service;
355182
355749
  let prior;
355183
355750
  try {
355184
- const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
355751
+ const { stdout: order } = await execFileAsync2("networksetup", ["-listnetworkserviceorder"], {
355185
355752
  timeout: DEVICE_CMD_TIMEOUT_MS
355186
355753
  });
355187
355754
  const svc = parsePrimaryNetworkService(order);
@@ -355190,26 +355757,26 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355190
355757
  }
355191
355758
  service = svc;
355192
355759
  const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
355193
- execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
355194
- execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
355760
+ execFileAsync2("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
355761
+ execFileAsync2("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
355195
355762
  ]);
355196
355763
  prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
355197
355764
  for (const args of buildHostProxySetCommands(service, host, port)) {
355198
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355765
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355199
355766
  }
355200
355767
  onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
355201
355768
  return { configured: true, service, prior };
355202
355769
  } catch (err) {
355203
355770
  if (service && prior) {
355204
- onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
355771
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg2(err)}`);
355205
355772
  for (const args of buildHostProxyRestoreCommands(service, prior)) {
355206
355773
  try {
355207
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355774
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355208
355775
  } catch {
355209
355776
  }
355210
355777
  }
355211
355778
  }
355212
- return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
355779
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg2(err)}` };
355213
355780
  }
355214
355781
  }
355215
355782
  async function iosSimRestoreHostProxy(service, prior, onLog) {
@@ -355219,9 +355786,9 @@ async function iosSimRestoreHostProxy(service, prior, onLog) {
355219
355786
  };
355220
355787
  for (const args of buildHostProxyRestoreCommands(service, restore)) {
355221
355788
  try {
355222
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355789
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355223
355790
  } catch (err) {
355224
- onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
355791
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg2(err)}`);
355225
355792
  }
355226
355793
  }
355227
355794
  onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
@@ -355233,14 +355800,14 @@ function markerPath(sessionKey) {
355233
355800
  }
355234
355801
  async function writePendingCleanup(sessionKey, marker) {
355235
355802
  try {
355236
- await (0, import_promises.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
355237
- await (0, import_promises.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
355803
+ await (0, import_promises2.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
355804
+ await (0, import_promises2.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
355238
355805
  } catch {
355239
355806
  }
355240
355807
  }
355241
355808
  async function removePendingCleanup(sessionKey) {
355242
355809
  try {
355243
- await (0, import_promises.unlink)(markerPath(sessionKey));
355810
+ await (0, import_promises2.unlink)(markerPath(sessionKey));
355244
355811
  } catch {
355245
355812
  }
355246
355813
  }
@@ -355273,7 +355840,7 @@ function restoreDeviceSync(marker, onLog) {
355273
355840
  async function reconcilePendingCleanups(onLog) {
355274
355841
  let files;
355275
355842
  try {
355276
- files = await (0, import_promises.readdir)(PENDING_CLEANUP_DIR);
355843
+ files = await (0, import_promises2.readdir)(PENDING_CLEANUP_DIR);
355277
355844
  } catch {
355278
355845
  return;
355279
355846
  }
@@ -355283,7 +355850,7 @@ async function reconcilePendingCleanups(onLog) {
355283
355850
  if (activeCleanups.has(sessionKey)) continue;
355284
355851
  const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
355285
355852
  try {
355286
- const raw = await (0, import_promises.readFile)(full, "utf-8");
355853
+ const raw = await (0, import_promises2.readFile)(full, "utf-8");
355287
355854
  const marker = JSON.parse(raw);
355288
355855
  if (marker && typeof marker.deviceId === "string" && marker.platform) {
355289
355856
  if (!shouldReconcilePendingCleanup(marker)) {
@@ -355296,7 +355863,7 @@ async function reconcilePendingCleanups(onLog) {
355296
355863
  } catch {
355297
355864
  }
355298
355865
  try {
355299
- await (0, import_promises.unlink)(full);
355866
+ await (0, import_promises2.unlink)(full);
355300
355867
  } catch {
355301
355868
  }
355302
355869
  }
@@ -355334,7 +355901,7 @@ async function startMobileNetworkCapture(options) {
355334
355901
  let caKeyPem = null;
355335
355902
  let caCertPem = null;
355336
355903
  try {
355337
- tmpDir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
355904
+ tmpDir = await (0, import_promises2.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
355338
355905
  const ca = await generateCaCert(tmpDir, onLog);
355339
355906
  if (ca) {
355340
355907
  caCertPath = ca.caCertPath;
@@ -355342,7 +355909,7 @@ async function startMobileNetworkCapture(options) {
355342
355909
  caCertPem = ca.caCertPem;
355343
355910
  }
355344
355911
  } catch (err) {
355345
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
355912
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg2(err)}`);
355346
355913
  }
355347
355914
  if (!caKeyPem || !caCertPem) {
355348
355915
  try {
@@ -355350,9 +355917,9 @@ async function startMobileNetworkCapture(options) {
355350
355917
  caKeyPem = fallback.key;
355351
355918
  caCertPem = fallback.cert;
355352
355919
  } catch (err) {
355353
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
355920
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg2(err)}`);
355354
355921
  if (tmpDir) {
355355
- await (0, import_promises.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355922
+ await (0, import_promises2.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355356
355923
  }
355357
355924
  throw err instanceof Error ? err : new Error(String(err));
355358
355925
  }
@@ -355407,7 +355974,7 @@ async function startMobileNetworkCapture(options) {
355407
355974
  }
355408
355975
  }
355409
355976
  } catch (err) {
355410
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
355977
+ wiringReason = `device wiring degraded: ${errMsg2(err)}`;
355411
355978
  onLog?.(`[mobile-net] ${wiringReason}`);
355412
355979
  }
355413
355980
  if (proxySet || caInstalledOnDevice || hostProxySet) {
@@ -355496,9 +356063,9 @@ async function startMobileNetworkCapture(options) {
355496
356063
  await proxy.close();
355497
356064
  if (capturedTmpDir) {
355498
356065
  try {
355499
- await (0, import_promises.rm)(capturedTmpDir, { recursive: true, force: true });
356066
+ await (0, import_promises2.rm)(capturedTmpDir, { recursive: true, force: true });
355500
356067
  } catch (err) {
355501
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
356068
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg2(err)}`);
355502
356069
  }
355503
356070
  }
355504
356071
  const apiCount = finalCalls.filter((c) => c.isApi).length;
@@ -355531,11 +356098,11 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
355531
356098
  })();
355532
356099
  var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
355533
356100
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
355534
- var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
356101
+ var WD_SESSION_CREATE_TIMEOUT_MS = Number(process.env.VALIDATEQA_WD_SESSION_CREATE_TIMEOUT_MS) || 3e5;
355535
356102
  var WD_ACTION_TIMEOUT_MS = 3e4;
355536
356103
  var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
355537
356104
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
355538
- var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
356105
+ var execFileAsync3 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
355539
356106
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
355540
356107
  var WebDriverTransportError = class extends Error {
355541
356108
  isTransport = true;
@@ -355547,6 +356114,13 @@ var WebDriverTransportError = class extends Error {
355547
356114
  function isTransportError(err) {
355548
356115
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
355549
356116
  }
356117
+ var MobileAmbiguousMatchError = class extends Error {
356118
+ isAmbiguousMatch = true;
356119
+ constructor(message) {
356120
+ super(message);
356121
+ this.name = "MobileAmbiguousMatchError";
356122
+ }
356123
+ };
355550
356124
  function crossPlatformRunReadinessError(steps, authoringPlatform, runPlatform) {
355551
356125
  if (!authoringPlatform || authoringPlatform === runPlatform) return null;
355552
356126
  const missing = stepsMissingPlatformLocators(steps, runPlatform);
@@ -355692,6 +356266,7 @@ function androidSystemPortForwardRemovalArgs(target, error2, env = process.env)
355692
356266
  return ["-s", deviceId, "forward", "--remove", `tcp:${systemPort}`];
355693
356267
  }
355694
356268
  async function createSession(target) {
356269
+ assertDeviceNotQuarantined(target.deviceId);
355695
356270
  const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
355696
356271
  const parallelCaps = mobileParallelSessionCapabilities(
355697
356272
  target.platform,
@@ -355713,6 +356288,13 @@ async function createSession(target) {
355713
356288
  // whole survey. The runner owns session teardown explicitly, so a long
355714
356289
  // idle window is safe. Env-overridable.
355715
356290
  "appium:newCommandTimeout": MOBILE_NEW_COMMAND_TIMEOUT_SECONDS,
356291
+ // Xcode 27+ relocates Simulator.app out of <DEVELOPER_DIR>/Applications, so
356292
+ // XCUITest's default "open Simulator UI" step fails ("Simulator.app does not
356293
+ // exist") on this toolchain. The simulator is already booted and driven
356294
+ // headlessly via CoreSimulator, so tell XCUITest to skip opening the GUI.
356295
+ // Scoped to simulator UDIDs (a physical iOS device has no such step);
356296
+ // override with APPIUM_IOS_HEADLESS=false to force a visible Simulator run.
356297
+ ...isIosSimulatorUdid(target.deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
355716
356298
  ...parallelCaps,
355717
356299
  ...realCaps
355718
356300
  } : {
@@ -355752,7 +356334,7 @@ async function removeStaleAndroidSystemPortForward(target, error2) {
355752
356334
  const args = androidSystemPortForwardRemovalArgs(target, error2);
355753
356335
  if (!args) return false;
355754
356336
  try {
355755
- await execFileAsync2("adb", args, { timeout: 1e4, windowsHide: true });
356337
+ await execFileAsync3("adb", args, { timeout: 1e4, windowsHide: true });
355756
356338
  return true;
355757
356339
  } catch {
355758
356340
  return false;
@@ -355919,6 +356501,9 @@ async function deleteSession(sessionId) {
355919
356501
  } catch {
355920
356502
  }
355921
356503
  }
356504
+ async function deleteAppiumSession(sessionId) {
356505
+ await deleteSession(sessionId);
356506
+ }
355922
356507
  async function takeScreenshot(sessionId) {
355923
356508
  try {
355924
356509
  const result = await wdRequest(`/session/${sessionId}/screenshot`);
@@ -355990,10 +356575,9 @@ async function stopScreenRecording(sessionId, log2) {
355990
356575
  return void 0;
355991
356576
  }
355992
356577
  }
355993
- function boundsFromStep(step) {
355994
- const bounds = step.metadata?.bounds;
355995
- if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
355996
- const parsed = bounds;
356578
+ function parseMobileBoundsLike(value) {
356579
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.x === "number" && typeof value.y === "number" && typeof value.width === "number" && typeof value.height === "number") {
356580
+ const parsed = value;
355997
356581
  if ([parsed.x, parsed.y, parsed.width, parsed.height].every(Number.isFinite)) {
355998
356582
  return {
355999
356583
  x: parsed.x,
@@ -356005,6 +356589,9 @@ function boundsFromStep(step) {
356005
356589
  }
356006
356590
  return null;
356007
356591
  }
356592
+ function boundsFromStep(step) {
356593
+ return parseMobileBoundsLike(step.metadata?.bounds);
356594
+ }
356008
356595
  function boundsForRun(step, platform3, isCrossPlatform) {
356009
356596
  if (isCrossPlatform) return null;
356010
356597
  if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
@@ -356156,6 +356743,131 @@ async function revealLikelyOffscreenElement(sessionId) {
356156
356743
  });
356157
356744
  await new Promise((resolve2) => setTimeout(resolve2, 250));
356158
356745
  }
356746
+ function strictMatchMode() {
356747
+ const raw = (process.env.VALIDATEQA_MOBILE_STRICT_MATCH ?? "").trim().toLowerCase();
356748
+ if (raw === "strict") return "strict";
356749
+ if (raw === "off") return "off";
356750
+ return "lenient";
356751
+ }
356752
+ var MOBILE_MAX_DISAMBIG_CANDIDATES = 20;
356753
+ var COINCIDENT_CENTER_EPSILON_PX = 2;
356754
+ function isRealElementRect(rect) {
356755
+ return rect.width > 1 && rect.height > 1;
356756
+ }
356757
+ function rectContainsPoint(rect, x, y) {
356758
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
356759
+ }
356760
+ function rectCenter(rect) {
356761
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
356762
+ }
356763
+ function centerDistance(rect, point) {
356764
+ const center = rectCenter(rect);
356765
+ return Math.hypot(center.x - point.x, center.y - point.y);
356766
+ }
356767
+ function chooseRecordedMatch(candidates, opts) {
356768
+ if (candidates.length === 0) return { kind: "ambiguous", count: 0 };
356769
+ if (candidates.length === 1) {
356770
+ return { kind: "matched", elementId: candidates[0].elementId, reason: "single" };
356771
+ }
356772
+ if (typeof opts.matchIndex === "number" && Number.isInteger(opts.matchIndex) && opts.matchIndex >= 0 && opts.matchIndex < candidates.length && typeof opts.expectedMatchCount === "number" && opts.expectedMatchCount === candidates.length) {
356773
+ return { kind: "matched", elementId: candidates[opts.matchIndex].elementId, reason: "matchIndex" };
356774
+ }
356775
+ {
356776
+ const withRect = candidates.filter(
356777
+ (candidate) => candidate.rect != null
356778
+ );
356779
+ if (withRect.length === candidates.length) {
356780
+ const real = withRect.filter((candidate) => isRealElementRect(candidate.rect));
356781
+ if (real.length === 1) {
356782
+ return { kind: "matched", elementId: real[0].elementId, reason: "visible" };
356783
+ }
356784
+ const centers = withRect.map((candidate) => rectCenter(candidate.rect));
356785
+ const coincident = centers.every((center) => Math.abs(center.x - centers[0].x) <= COINCIDENT_CENTER_EPSILON_PX && Math.abs(center.y - centers[0].y) <= COINCIDENT_CENTER_EPSILON_PX);
356786
+ if (coincident) {
356787
+ return { kind: "matched", elementId: withRect[0].elementId, reason: "coincident" };
356788
+ }
356789
+ }
356790
+ }
356791
+ if (opts.recordedBounds) {
356792
+ const bounds = opts.recordedBounds;
356793
+ const center = rectCenter(bounds);
356794
+ const withRect = candidates.filter(
356795
+ (candidate) => candidate.rect != null
356796
+ );
356797
+ const containing = withRect.filter((candidate) => rectContainsPoint(candidate.rect, center.x, center.y));
356798
+ if (containing.length > 0) {
356799
+ const best = [...containing].sort(
356800
+ (a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height || centerDistance(a.rect, center) - centerDistance(b.rect, center)
356801
+ )[0];
356802
+ return { kind: "matched", elementId: best.elementId, reason: "bounds" };
356803
+ }
356804
+ if (withRect.length > 0) {
356805
+ const tolerance = Math.max(bounds.height, 1);
356806
+ const nearest = [...withRect].sort(
356807
+ (a, b) => centerDistance(a.rect, center) - centerDistance(b.rect, center)
356808
+ )[0];
356809
+ if (centerDistance(nearest.rect, center) <= tolerance) {
356810
+ return { kind: "matched", elementId: nearest.elementId, reason: "bounds" };
356811
+ }
356812
+ }
356813
+ }
356814
+ return opts.strict === "strict" ? { kind: "ambiguous", count: candidates.length } : { kind: "fallback", elementId: candidates[0].elementId, count: candidates.length };
356815
+ }
356816
+ function extractElementIdsFromPluralResponse(result) {
356817
+ const rawList = result?.value;
356818
+ if (!Array.isArray(rawList)) return [];
356819
+ const ids = [];
356820
+ for (const entry of rawList) {
356821
+ const record = entry;
356822
+ const id = validateElementId(record?.ELEMENT ?? record?.["element-6066-11e4-a52e-4f735466cecf"]);
356823
+ if (id) ids.push(id);
356824
+ }
356825
+ return ids;
356826
+ }
356827
+ async function findElements(sessionId, strategy) {
356828
+ const result = await wdRequest(`/session/${sessionId}/elements`, {
356829
+ method: "POST",
356830
+ body: JSON.stringify({ using: strategy.using, value: strategy.value })
356831
+ });
356832
+ return extractElementIdsFromPluralResponse(result);
356833
+ }
356834
+ async function findWebViewElements(sessionPath, css) {
356835
+ const result = await wdRequest(`${sessionPath}/elements`, {
356836
+ method: "POST",
356837
+ body: JSON.stringify({ using: "css selector", value: css })
356838
+ });
356839
+ return extractElementIdsFromPluralResponse(result);
356840
+ }
356841
+ async function readElementRect(sessionId, elementId) {
356842
+ try {
356843
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/rect`);
356844
+ return parseMobileBoundsLike(result?.value);
356845
+ } catch (err) {
356846
+ if (isTransportError(err)) throw err;
356847
+ return null;
356848
+ }
356849
+ }
356850
+ async function disambiguateElementIds(sessionId, candidateIds, opts) {
356851
+ const indexOnly = candidateIds.map((elementId) => ({ elementId }));
356852
+ const indexResult = chooseRecordedMatch(indexOnly, {
356853
+ recordedBounds: null,
356854
+ matchIndex: opts.matchIndex,
356855
+ expectedMatchCount: opts.expectedMatchCount,
356856
+ strict: opts.strict
356857
+ });
356858
+ if (indexResult.kind === "matched" || candidateIds.length > MOBILE_MAX_DISAMBIG_CANDIDATES) {
356859
+ return indexResult;
356860
+ }
356861
+ const withRects = await Promise.all(
356862
+ candidateIds.map(async (elementId) => ({ elementId, rect: await readElementRect(sessionId, elementId) }))
356863
+ );
356864
+ return chooseRecordedMatch(withRects, {
356865
+ recordedBounds: opts.recordedBounds ?? null,
356866
+ matchIndex: opts.matchIndex,
356867
+ expectedMatchCount: opts.expectedMatchCount,
356868
+ strict: opts.strict
356869
+ });
356870
+ }
356159
356871
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
356160
356872
  const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
356161
356873
  if (strategies.length === 0) {
@@ -356163,22 +356875,45 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356163
356875
  opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
356164
356876
  );
356165
356877
  }
356878
+ const mode = strictMatchMode();
356879
+ const matchIndex = typeof target?.matchIndex === "number" ? target.matchIndex : void 0;
356880
+ const expectedMatchCount = typeof target?.expectedMatchCount === "number" ? target.expectedMatchCount : void 0;
356166
356881
  const startTime = Date.now();
356167
356882
  let lastError = "";
356883
+ let lastAmbiguity = null;
356168
356884
  let revealAttempted = false;
356169
356885
  while (Date.now() - startTime < timeoutMs) {
356170
356886
  for (const strategy of strategies) {
356171
356887
  try {
356172
- const result = await wdRequest(`/session/${sessionId}/element`, {
356173
- method: "POST",
356174
- body: JSON.stringify({ using: strategy.using, value: strategy.value })
356888
+ const ids = await findElements(sessionId, strategy);
356889
+ if (ids.length === 0) continue;
356890
+ if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
356891
+ return ids[0];
356892
+ }
356893
+ const result = await disambiguateElementIds(sessionId, ids, {
356894
+ recordedBounds: opts?.recordedBounds,
356895
+ matchIndex,
356896
+ expectedMatchCount,
356897
+ strict: mode
356175
356898
  });
356176
- const elementId = validateElementId(
356177
- result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
356899
+ if (result.kind === "matched") return result.elementId;
356900
+ const shouldFailClosed = opts?.failClosedOnAmbiguity || result.kind === "ambiguous" && !opts?.tolerateAmbiguity;
356901
+ if (shouldFailClosed) {
356902
+ throw new MobileAmbiguousMatchError(
356903
+ `ambiguous locator: ${result.count} matches, cannot identify the recorded instance (strategy=${strategy.using} value=${strategy.value})`
356904
+ );
356905
+ }
356906
+ opts?.log?.(
356907
+ `\u26A0 ambiguous locator (${result.count} matches for strategy=${strategy.using} value=${strategy.value}) \u2014 using the first match. Set VALIDATEQA_MOBILE_STRICT_MATCH=strict to fail instead.`
356178
356908
  );
356179
- if (elementId) return elementId;
356909
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
356180
356910
  } catch (err) {
356181
356911
  if (isTransportError(err)) throw err;
356912
+ if (err instanceof MobileAmbiguousMatchError) {
356913
+ lastAmbiguity = err;
356914
+ lastError = err.message;
356915
+ continue;
356916
+ }
356182
356917
  lastError = err instanceof Error ? err.message : String(err);
356183
356918
  }
356184
356919
  }
@@ -356193,6 +356928,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356193
356928
  }
356194
356929
  await new Promise((resolve2) => setTimeout(resolve2, 500));
356195
356930
  }
356931
+ if (lastAmbiguity) throw lastAmbiguity;
356196
356932
  throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
356197
356933
  }
356198
356934
  async function tapCoordinates(sessionId, bounds) {
@@ -356226,7 +356962,7 @@ async function prepareAndroidInputEnvironment(deviceId) {
356226
356962
  ];
356227
356963
  for (const [namespace, key, value] of settingsToDisable) {
356228
356964
  try {
356229
- await execFileAsync2("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356965
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356230
356966
  timeout: 8e3,
356231
356967
  windowsHide: true
356232
356968
  });
@@ -356238,14 +356974,14 @@ async function sendAndroidTextToFocusedInput(deviceId, value) {
356238
356974
  if (!deviceId) {
356239
356975
  throw new Error("Android keyboard fallback requires a device id");
356240
356976
  }
356241
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356977
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356242
356978
  timeout: 1e4,
356243
356979
  windowsHide: true
356244
356980
  });
356245
356981
  }
356246
356982
  async function pressAndroidKeyboardEnter(deviceId) {
356247
356983
  if (!deviceId) throw new Error("Android keyboard Enter fallback requires a device id");
356248
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356984
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356249
356985
  timeout: 1e4,
356250
356986
  windowsHide: true
356251
356987
  });
@@ -356333,21 +357069,40 @@ async function executeInWebViewContext(sessionId, step, css, body) {
356333
357069
  const cleanCss = css.startsWith("css=") ? css.slice(4) : css;
356334
357070
  await withWebViewContext(sessionPath, webview, () => body(sessionPath, cleanCss));
356335
357071
  }
356336
- async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
357072
+ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
357073
+ const mode = strictMatchMode();
356337
357074
  const startedAt = Date.now();
356338
357075
  let lastError = "";
356339
357076
  while (Date.now() - startedAt < timeoutMs) {
356340
357077
  try {
356341
- const result = await wdRequest(`${sessionPath}/element`, {
356342
- method: "POST",
356343
- body: JSON.stringify({ using: "css selector", value: css })
356344
- });
356345
- const elementId = validateElementId(
356346
- result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
356347
- );
356348
- if (elementId) return elementId;
357078
+ const ids = await findWebViewElements(sessionPath, css);
357079
+ if (ids.length === 0) {
357080
+ lastError = `no match for css=${css}`;
357081
+ } else if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
357082
+ return ids[0];
357083
+ } else {
357084
+ const candidates = ids.map((elementId) => ({ elementId }));
357085
+ const result = chooseRecordedMatch(candidates, {
357086
+ recordedBounds: null,
357087
+ matchIndex: opts?.matchIndex,
357088
+ expectedMatchCount: opts?.expectedMatchCount,
357089
+ strict: mode
357090
+ });
357091
+ if (result.kind === "matched") {
357092
+ return result.elementId;
357093
+ }
357094
+ if (result.kind === "ambiguous" && !opts?.tolerateAmbiguity) {
357095
+ throw new MobileAmbiguousMatchError(
357096
+ `ambiguous WEBVIEW locator: ${result.count} matches for css=${css}, cannot identify the recorded instance`
357097
+ );
357098
+ }
357099
+ opts?.log?.(
357100
+ `\u26A0 ambiguous WEBVIEW locator (${result.count} matches for css=${css}) \u2014 using the first match. Set VALIDATEQA_MOBILE_STRICT_MATCH=strict to fail instead.`
357101
+ );
357102
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
357103
+ }
356349
357104
  } catch (err) {
356350
- if (isTransportError(err)) throw err;
357105
+ if (isTransportError(err) || err instanceof MobileAmbiguousMatchError) throw err;
356351
357106
  lastError = err instanceof Error ? err.message : String(err);
356352
357107
  }
356353
357108
  await new Promise((resolve2) => setTimeout(resolve2, 250));
@@ -356356,21 +357111,42 @@ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIM
356356
357111
  `WEBVIEW element not found for css=${css} after ${timeoutMs}ms` + (lastError ? `. Last error: ${lastError}` : "")
356357
357112
  );
356358
357113
  }
357114
+ function isWebViewHydrationTransient(err) {
357115
+ const message = err instanceof Error ? err.message : String(err);
357116
+ return /no such element|NoSuchElement|element not interactable|stale element|WEBVIEW step requires|Empty page dictionary|not connectable|no WEBVIEW context|chrome not reachable/i.test(message);
357117
+ }
357118
+ var WEBVIEW_HYDRATION_TIMEOUT_MS = Number(process.env.VALIDATEQA_WEBVIEW_HYDRATION_TIMEOUT_MS) || 3e4;
357119
+ async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, body, timeoutMs) {
357120
+ const deadline = Date.now() + Math.max(1e3, timeoutMs);
357121
+ for (; ; ) {
357122
+ try {
357123
+ await executeInWebViewContext(sessionId, step, css, body);
357124
+ return;
357125
+ } catch (err) {
357126
+ if (!isWebViewHydrationTransient(err) || Date.now() >= deadline) throw err;
357127
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
357128
+ }
357129
+ }
357130
+ }
356359
357131
  async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
356360
357132
  const webCss = stepWebViewCss(step);
356361
357133
  if (webCss) {
356362
- await executeInWebViewContext(
357134
+ await executeInWebViewContextWithHydrationRetry(
356363
357135
  sessionId,
356364
357136
  step,
356365
357137
  webCss,
356366
- (sessionPath, css) => clickCssInCurrentContext(sessionPath, css)
357138
+ (sessionPath, css) => clickCssInCurrentContext(sessionPath, css),
357139
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356367
357140
  );
356368
357141
  return;
356369
357142
  }
356370
357143
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
356371
357144
  if (step.target) {
356372
357145
  try {
356373
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357146
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357147
+ platform: platform3,
357148
+ recordedBounds: bounds
357149
+ });
356374
357150
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356375
357151
  method: "POST",
356376
357152
  body: "{}"
@@ -356392,11 +357168,12 @@ async function executeType(sessionId, step, context) {
356392
357168
  const keysValue = keyboardSubmitOnly ? WEBDRIVER_ENTER_KEY : value;
356393
357169
  const webCss = stepWebViewCss(step);
356394
357170
  if (webCss) {
356395
- await executeInWebViewContext(
357171
+ await executeInWebViewContextWithHydrationRetry(
356396
357172
  sessionId,
356397
357173
  step,
356398
357174
  webCss,
356399
- (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value)
357175
+ (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value),
357176
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356400
357177
  );
356401
357178
  return;
356402
357179
  }
@@ -356407,7 +357184,9 @@ async function executeType(sessionId, step, context) {
356407
357184
  const locatorStrategies = buildLocatorStrategies(step.target, context.platform);
356408
357185
  if (step.target && locatorStrategies.length > 0) {
356409
357186
  const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
356410
- platform: context.platform
357187
+ platform: context.platform,
357188
+ recordedBounds: bounds,
357189
+ log: context.log
356411
357190
  });
356412
357191
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356413
357192
  method: "POST",
@@ -356448,22 +357227,28 @@ async function executeType(sessionId, step, context) {
356448
357227
  }
356449
357228
  async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
356450
357229
  const webCss = stepWebViewCss(step);
357230
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
356451
357231
  if (webCss) {
356452
357232
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356453
- const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357233
+ const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357234
+ matchIndex: step.target?.matchIndex,
357235
+ expectedMatchCount: step.target?.expectedMatchCount
357236
+ });
356454
357237
  await wdRequest(`${sessionPath}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356455
357238
  });
356456
357239
  return;
356457
357240
  }
356458
357241
  const locatorStrategies = buildLocatorStrategies(step.target, platform3);
356459
357242
  if (step.target && locatorStrategies.length > 0) {
356460
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357243
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357244
+ platform: platform3,
357245
+ recordedBounds: bounds
357246
+ });
356461
357247
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
356462
357248
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
356463
357249
  await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356464
357250
  return;
356465
357251
  }
356466
- const bounds = boundsForRun(step, platform3, isCrossPlatform);
356467
357252
  await clearField(`/session/${sessionId}`, bounds ?? void 0);
356468
357253
  }
356469
357254
  async function executeSwipe(sessionId, step) {
@@ -356524,34 +357309,47 @@ async function executeWaitForElement(sessionId, step, platform3) {
356524
357309
  sessionId,
356525
357310
  step,
356526
357311
  webCss,
356527
- (sessionPath, css) => findWebViewElement(sessionPath, css, timeout).then(() => void 0)
357312
+ (sessionPath, css) => findWebViewElement(sessionPath, css, timeout, { disambiguate: false }).then(() => void 0)
356528
357313
  );
356529
357314
  return;
356530
357315
  }
356531
- await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
357316
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3, disambiguate: false });
356532
357317
  }
356533
- async function executeAssertVisible(sessionId, step, platform3) {
357318
+ async function executeAssertVisible(sessionId, step, platform3, isCrossPlatform = false) {
356534
357319
  const webCss = stepWebViewCss(step);
356535
357320
  if (webCss) {
356536
357321
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356537
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357322
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357323
+ matchIndex: step.target?.matchIndex,
357324
+ expectedMatchCount: step.target?.expectedMatchCount,
357325
+ tolerateAmbiguity: true
357326
+ });
356538
357327
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/displayed`);
356539
357328
  if (!result2?.value) throw new Error(`WEBVIEW element found but not visible: css=${css}`);
356540
357329
  });
356541
357330
  return;
356542
357331
  }
356543
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357332
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357333
+ identityOnly: true,
357334
+ platform: platform3,
357335
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357336
+ tolerateAmbiguity: true
357337
+ });
356544
357338
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
356545
357339
  if (!result?.value) {
356546
357340
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
356547
357341
  }
356548
357342
  }
356549
- async function executeAssertText(sessionId, step, platform3) {
357343
+ async function executeAssertText(sessionId, step, platform3, isCrossPlatform = false) {
356550
357344
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
356551
357345
  const webCss = stepWebViewCss(step);
356552
357346
  if (webCss) {
356553
357347
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356554
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357348
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357349
+ matchIndex: step.target?.matchIndex,
357350
+ expectedMatchCount: step.target?.expectedMatchCount,
357351
+ tolerateAmbiguity: true
357352
+ });
356555
357353
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/text`);
356556
357354
  const actualText2 = typeof result2?.value === "string" ? result2.value : "";
356557
357355
  const textCandidates2 = await readElementTextCandidates(sessionId, elementId2, actualText2);
@@ -356563,7 +357361,12 @@ async function executeAssertText(sessionId, step, platform3) {
356563
357361
  });
356564
357362
  return;
356565
357363
  }
356566
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357364
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357365
+ identityOnly: true,
357366
+ platform: platform3,
357367
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357368
+ tolerateAmbiguity: true
357369
+ });
356567
357370
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
356568
357371
  const actualText = typeof result?.value === "string" ? result.value : "";
356569
357372
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -356603,6 +357406,8 @@ function acquireControlPortSlot(deviceId) {
356603
357406
  function releaseControlPortSlot(deviceId) {
356604
357407
  controlPortSlotsByDevice.delete(deviceId);
356605
357408
  }
357409
+ var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357410
+ var pinnedDeviceSnapshot = null;
356606
357411
  async function executeMobileTest(options) {
356607
357412
  const logLines = [];
356608
357413
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -356610,6 +357415,7 @@ async function executeMobileTest(options) {
356610
357415
  const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
356611
357416
  const runPlatform = options.target.platform;
356612
357417
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
357418
+ const captureStepScreenshots = options.captureStepScreenshots !== false;
356613
357419
  if (missingVariableKeys.length > 0) {
356614
357420
  log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
356615
357421
  }
@@ -356674,32 +357480,42 @@ async function executeMobileTest(options) {
356674
357480
  await startAppiumServer();
356675
357481
  log2("Appium server started");
356676
357482
  }
356677
- log2("Discovering devices...");
356678
- const devices = discoverAllDevices({ includeApps: true });
356679
357483
  const platformFilter = options.target.platform;
356680
- const matchingDevices = devices.filter(
356681
- (d) => d.platform === platformFilter && d.isAvailable && d.state === "BOOTED"
356682
- );
356683
- if (matchingDevices.length === 0) {
356684
- throw new Error(
356685
- `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
357484
+ const pinnedDeviceId = options.target.assignedDeviceId;
357485
+ const candidateSnapshot = pinnedDeviceSnapshot;
357486
+ const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && candidateSnapshot.deviceId === pinnedDeviceId && Date.now() - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
357487
+ if (reusablePinnedSnapshot) {
357488
+ assertDeviceNotQuarantined(reusablePinnedSnapshot.device.id);
357489
+ selectedDevice = reusablePinnedSnapshot.device;
357490
+ log2(`Reusing known device (skipped device re-scan): ${selectedDevice.name} (${selectedDevice.id})`);
357491
+ } else {
357492
+ log2("Discovering devices...");
357493
+ const devices = discoverAllDevices({ includeApps: true });
357494
+ const matchingDevices = devices.filter(
357495
+ (d) => d.platform === platformFilter && d.isAvailable && d.state === "BOOTED"
356686
357496
  );
356687
- }
356688
- if (options.target.assignedDeviceId) {
356689
- selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
356690
- if (!selectedDevice) {
357497
+ if (matchingDevices.length === 0) {
356691
357498
  throw new Error(
356692
- `Assigned ${platformFilter} device ${options.target.assignedDeviceId} is no longer booted/available (booted: ${matchingDevices.map((d) => d.id).join(", ") || "none"}). Refusing to fall back to another device.`
357499
+ `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
356693
357500
  );
356694
357501
  }
357502
+ if (options.target.assignedDeviceId) {
357503
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
357504
+ if (!selectedDevice) {
357505
+ throw new Error(
357506
+ `Assigned ${platformFilter} device ${options.target.assignedDeviceId} is no longer booted/available (booted: ${matchingDevices.map((d) => d.id).join(", ") || "none"}). Refusing to fall back to another device.`
357507
+ );
357508
+ }
357509
+ }
357510
+ if (!selectedDevice && options.target.recordedDeviceId) {
357511
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
357512
+ }
357513
+ if (!selectedDevice) {
357514
+ selectedDevice = matchingDevices[0];
357515
+ }
357516
+ log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
357517
+ pinnedDeviceSnapshot = options.target.assignedDeviceId ? { deviceId: options.target.assignedDeviceId, device: selectedDevice, at: Date.now() } : null;
356695
357518
  }
356696
- if (!selectedDevice && options.target.recordedDeviceId) {
356697
- selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
356698
- }
356699
- if (!selectedDevice) {
356700
- selectedDevice = matchingDevices[0];
356701
- }
356702
- log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
356703
357519
  try {
356704
357520
  capture = await startMobileNetworkCapture({
356705
357521
  platform: options.target.platform,
@@ -356766,7 +357582,14 @@ async function executeMobileTest(options) {
356766
357582
  const resetAllowed = shouldResetMobileAppData(options.target.platform, selectedDevice.id);
356767
357583
  if (resetAllowed) {
356768
357584
  try {
356769
- await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357585
+ if (options.target.platform === "IOS") {
357586
+ await terminateTargetApp(sessionId, "IOS", options.target.appId).catch(() => {
357587
+ });
357588
+ const wiped = await clearIosSimulatorAppData(selectedDevice.id, options.target.appId);
357589
+ if (!wiped.ok) throw new Error(wiped.detail);
357590
+ } else {
357591
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357592
+ }
356770
357593
  clearedAppDataBeforeLaunch = true;
356771
357594
  log2(resetPolicy === "CLEAR_APP_DATA" ? "App data cleared before launch (target resetPolicy=CLEAR_APP_DATA)." : "App data cleared before launch (test was recorded from a cold logged-out state).");
356772
357595
  } catch (err) {
@@ -356819,17 +357642,21 @@ async function executeMobileTest(options) {
356819
357642
  const entryStep = selectEntryDriftGateStep(runSteps, runPlatform);
356820
357643
  if (entryStep) {
356821
357644
  try {
356822
- const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
357645
+ const entryTimeout = Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS);
356823
357646
  const entryWebCss = stepWebViewCss(entryStep);
356824
357647
  if (entryWebCss) {
356825
357648
  await executeInWebViewContext(
356826
357649
  sessionId,
356827
357650
  entryStep,
356828
357651
  entryWebCss,
356829
- (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout).then(() => void 0)
357652
+ (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout, { disambiguate: false }).then(() => void 0)
356830
357653
  );
356831
357654
  } else {
356832
- await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
357655
+ await findElement(sessionId, entryStep.target, entryTimeout, {
357656
+ identityOnly: true,
357657
+ platform: runPlatform,
357658
+ disambiguate: false
357659
+ });
356833
357660
  }
356834
357661
  } catch (err) {
356835
357662
  if (isTransportError(err)) throw err;
@@ -356889,10 +357716,10 @@ async function executeMobileTest(options) {
356889
357716
  await executeWaitForElement(sessionId, step, runPlatform);
356890
357717
  break;
356891
357718
  case "assertVisible":
356892
- await executeAssertVisible(sessionId, step, runPlatform);
357719
+ await executeAssertVisible(sessionId, step, runPlatform, isCrossPlatform);
356893
357720
  break;
356894
357721
  case "assertText":
356895
- await executeAssertText(sessionId, step, runPlatform);
357722
+ await executeAssertText(sessionId, step, runPlatform, isCrossPlatform);
356896
357723
  break;
356897
357724
  default: {
356898
357725
  const _exhaustive = step.action;
@@ -356911,7 +357738,7 @@ async function executeMobileTest(options) {
356911
357738
  environmental = stepError;
356912
357739
  }
356913
357740
  }
356914
- const screenshot = await takeScreenshot(sessionId).catch(() => null);
357741
+ const screenshot = captureStepScreenshots ? await takeScreenshot(sessionId).catch(() => null) : null;
356915
357742
  screenshots.push(screenshot ?? "");
356916
357743
  if (status === "failed") {
356917
357744
  await recordFirstFailingSource();
@@ -357173,10 +358000,10 @@ async function driveHarvestStep(sessionId, step, elementId, platform3) {
357173
358000
  await executeWaitForElement(sessionId, step, platform3);
357174
358001
  return true;
357175
358002
  case "assertVisible":
357176
- await executeAssertVisible(sessionId, step, platform3);
358003
+ await executeAssertVisible(sessionId, step, platform3, true);
357177
358004
  return true;
357178
358005
  case "assertText":
357179
- await executeAssertText(sessionId, step, platform3);
358006
+ await executeAssertText(sessionId, step, platform3, true);
357180
358007
  return true;
357181
358008
  default:
357182
358009
  break;
@@ -357258,7 +358085,15 @@ async function harvestPlatformLocators(options) {
357258
358085
  const identityStrategies = webViewCss ? [] : buildIdentityLocatorStrategies(step.target);
357259
358086
  if (!webViewCss && identityStrategies.length > 0 && step.target) {
357260
358087
  try {
357261
- elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
358088
+ elementId = await findElement(
358089
+ session.sessionId,
358090
+ step.target,
358091
+ Math.max(DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS),
358092
+ {
358093
+ identityOnly: true,
358094
+ failClosedOnAmbiguity: true
358095
+ }
358096
+ );
357262
358097
  } catch (err) {
357263
358098
  if (isTransportError(err)) throw err;
357264
358099
  elementId = null;
@@ -357354,6 +358189,86 @@ function validateRunnerMobileTarget(value) {
357354
358189
  };
357355
358190
  }
357356
358191
 
358192
+ // src/services/heartbeat-keepalive.ts
358193
+ var import_node_worker_threads = require("worker_threads");
358194
+ var KEEPALIVE_INTERVAL_MS = (() => {
358195
+ const raw = Number(process.env.VALIDATEQA_HEARTBEAT_KEEPALIVE_MS);
358196
+ return Number.isFinite(raw) && raw > 0 ? raw : 2e4;
358197
+ })();
358198
+ function isHeartbeatKeepaliveEnabled(env = process.env) {
358199
+ return (env.VALIDATEQA_HEARTBEAT_KEEPALIVE ?? "").trim().toLowerCase() !== "off";
358200
+ }
358201
+ var WORKER_SOURCE = `
358202
+ const { parentPort } = require('node:worker_threads');
358203
+ let url = null;
358204
+ let headers = null;
358205
+ let body = null;
358206
+ let intervalMs = 20000;
358207
+ let timer = null;
358208
+
358209
+ async function post() {
358210
+ if (!url || !headers || !body) return; // inert until the first real heartbeat
358211
+ try {
358212
+ await fetch(url, {
358213
+ method: 'POST',
358214
+ headers,
358215
+ body,
358216
+ signal: AbortSignal.timeout(10000),
358217
+ });
358218
+ } catch {
358219
+ // Best-effort by design: the next tick retries; the main loop's own
358220
+ // heartbeat error handling owns logging/upgrade signals.
358221
+ }
358222
+ }
358223
+
358224
+ parentPort.on('message', (msg) => {
358225
+ if (!msg || typeof msg !== 'object') return;
358226
+ if (msg.type === 'update') {
358227
+ url = msg.url;
358228
+ headers = msg.headers;
358229
+ body = msg.body;
358230
+ intervalMs = msg.intervalMs || intervalMs;
358231
+ if (!timer) {
358232
+ timer = setInterval(post, intervalMs);
358233
+ if (timer.unref) timer.unref();
358234
+ }
358235
+ } else if (msg.type === 'stop') {
358236
+ if (timer) clearInterval(timer);
358237
+ timer = null;
358238
+ parentPort.close();
358239
+ }
358240
+ });
358241
+ `;
358242
+ var NOOP_KEEPALIVE = { update: () => {
358243
+ }, stop: () => {
358244
+ } };
358245
+ function startHeartbeatKeepalive(options = {}) {
358246
+ if (!isHeartbeatKeepaliveEnabled(options.env)) return NOOP_KEEPALIVE;
358247
+ const intervalMs = options.intervalMs ?? KEEPALIVE_INTERVAL_MS;
358248
+ let worker;
358249
+ try {
358250
+ worker = new import_node_worker_threads.Worker(WORKER_SOURCE, { eval: true });
358251
+ worker.unref();
358252
+ worker.on("error", () => {
358253
+ worker = null;
358254
+ });
358255
+ } catch {
358256
+ return NOOP_KEEPALIVE;
358257
+ }
358258
+ return {
358259
+ update(url, headers, body) {
358260
+ worker?.postMessage({ type: "update", url, headers, body, intervalMs });
358261
+ },
358262
+ stop() {
358263
+ try {
358264
+ worker?.postMessage({ type: "stop" });
358265
+ } catch {
358266
+ }
358267
+ worker = null;
358268
+ }
358269
+ };
358270
+ }
358271
+
357357
358272
  // src/mobile-device-queue.ts
357358
358273
  function isNativeMobileDiscoveryContext(ctx) {
357359
358274
  if (!ctx) return false;
@@ -357424,10 +358339,10 @@ function decideMobileDeviceGate(input) {
357424
358339
 
357425
358340
  // src/services/mobile-bridge.ts
357426
358341
  var import_http = require("http");
357427
- var import_child_process3 = require("child_process");
358342
+ var import_child_process4 = require("child_process");
357428
358343
  var import_crypto = require("crypto");
357429
358344
  var import_os4 = require("os");
357430
- var import_path3 = require("path");
358345
+ var import_path4 = require("path");
357431
358346
  var import_fs3 = require("fs");
357432
358347
  init_dist();
357433
358348
  var MOBILE_BRIDGE_PORT2 = 9515;
@@ -357534,7 +358449,7 @@ async function getWindowSize3() {
357534
358449
  }
357535
358450
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
357536
358451
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
357537
- var BRIDGE_PID_FILE = (0, import_path3.join)((0, import_os4.homedir)(), ".validate.qa", "mobile-bridge.pid");
358452
+ var BRIDGE_PID_FILE = (0, import_path4.join)((0, import_os4.homedir)(), ".validate.qa", "mobile-bridge.pid");
357538
358453
  function readBridgePidFile() {
357539
358454
  try {
357540
358455
  const raw = (0, import_fs3.readFileSync)(BRIDGE_PID_FILE, "utf-8").trim();
@@ -357546,7 +358461,7 @@ function readBridgePidFile() {
357546
358461
  }
357547
358462
  function writeBridgePidFile(pid) {
357548
358463
  try {
357549
- (0, import_fs3.mkdirSync)((0, import_path3.dirname)(BRIDGE_PID_FILE), { recursive: true, mode: 448 });
358464
+ (0, import_fs3.mkdirSync)((0, import_path4.dirname)(BRIDGE_PID_FILE), { recursive: true, mode: 448 });
357550
358465
  (0, import_fs3.writeFileSync)(BRIDGE_PID_FILE, String(pid), { mode: 384 });
357551
358466
  } catch {
357552
358467
  }
@@ -357561,19 +358476,19 @@ function killStaleBridge() {
357561
358476
  const pid = readBridgePidFile();
357562
358477
  if (!pid) return;
357563
358478
  try {
357564
- (0, import_child_process3.execFileSync)("kill", ["-0", String(pid)], { stdio: "ignore" });
358479
+ (0, import_child_process4.execFileSync)("kill", ["-0", String(pid)], { stdio: "ignore" });
357565
358480
  } catch {
357566
358481
  clearBridgePidFile();
357567
358482
  return;
357568
358483
  }
357569
358484
  try {
357570
- const pidsOnPort = (0, import_child_process3.execFileSync)("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
358485
+ const pidsOnPort = (0, import_child_process4.execFileSync)("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
357571
358486
  const pidList = pidsOnPort.split("\n").filter(Boolean);
357572
358487
  if (!pidList.includes(String(pid))) {
357573
358488
  return;
357574
358489
  }
357575
- (0, import_child_process3.execFileSync)("kill", ["-9", String(pid)], { stdio: "ignore" });
357576
- (0, import_child_process3.execFileSync)("sleep", ["1"], { stdio: "ignore" });
358490
+ (0, import_child_process4.execFileSync)("kill", ["-9", String(pid)], { stdio: "ignore" });
358491
+ (0, import_child_process4.execFileSync)("sleep", ["1"], { stdio: "ignore" });
357577
358492
  } catch {
357578
358493
  } finally {
357579
358494
  clearBridgePidFile();
@@ -357846,6 +358761,13 @@ async function startSession(validated, res) {
357846
358761
  ...deviceId ? { "appium:udid": deviceId } : {},
357847
358762
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
357848
358763
  "appium:noReset": true,
358764
+ // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
358765
+ // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
358766
+ // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
358767
+ // skip the GUI open. Scoped to simulator UDIDs; APPIUM_IOS_HEADLESS=false
358768
+ // overrides. Verified live: without this, recorder/mirror sessions on iOS
358769
+ // die with "Simulator.app does not exist" while executor sessions work.
358770
+ ...isIosSimulatorUdid(deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
357849
358771
  ...realCaps
357850
358772
  } : {
357851
358773
  platformName: "Android",
@@ -357864,7 +358786,12 @@ async function startSession(validated, res) {
357864
358786
  capabilities: {
357865
358787
  alwaysMatch: capabilities
357866
358788
  }
357867
- })
358789
+ }),
358790
+ // Session creation is the one legitimately slow WebDriver call (iOS WDA
358791
+ // build/launch can take minutes on a cold toolchain) — same ceiling as
358792
+ // the executor's wdRequest session-create path. Without this, the shared
358793
+ // appiumRequest 10s default aborts every iOS recorder session.
358794
+ signal: AbortSignal.timeout(WD_SESSION_CREATE_TIMEOUT_MS)
357868
358795
  });
357869
358796
  state.appiumSessionId = result?.value?.sessionId ?? result?.sessionId ?? null;
357870
358797
  if (!state.appiumSessionId) {
@@ -358642,6 +359569,9 @@ function detachMirrorSession(deviceId) {
358642
359569
  stopAllMirrorClients();
358643
359570
  stopSessionReaper();
358644
359571
  }
359572
+ function getMirrorSessionId(deviceId) {
359573
+ return runnerChannels.get(deviceId)?.sessionId;
359574
+ }
358645
359575
 
358646
359576
  // src/services/mobile/mobile-bridge-driver.ts
358647
359577
  var import_runner_core9 = __toESM(require_dist2());
@@ -358672,6 +359602,7 @@ var MobileBridgeDriver = class {
358672
359602
  sessionId;
358673
359603
  platform;
358674
359604
  appId;
359605
+ deviceId;
358675
359606
  allowAppReset;
358676
359607
  recoverSession;
358677
359608
  maxSessionRecoveries;
@@ -358692,6 +359623,7 @@ var MobileBridgeDriver = class {
358692
359623
  this.sessionId = config.sessionId;
358693
359624
  this.platform = config.platform;
358694
359625
  this.appId = config.appId;
359626
+ this.deviceId = config.deviceId;
358695
359627
  this.allowAppReset = config.allowAppReset === true;
358696
359628
  this.recoverSession = config.recoverSession;
358697
359629
  this.maxSessionRecoveries = Math.max(0, Math.floor(config.maxSessionRecoveries ?? 1));
@@ -358989,17 +359921,34 @@ var MobileBridgeDriver = class {
358989
359921
  });
358990
359922
  }
358991
359923
  /**
358992
- * Deterministic state reset: clear app data + relaunch (Android emulators
358993
- * only, per the runner-computed allowAppReset policy). Returns false when
358994
- * unsupported/disallowed or when clearApp throws — callers fall back to
358995
- * relaunch(), preserving today's behavior. On success, waits for cold-boot
358996
- * content so recording and replay agree on the true entry screen.
359924
+ * Deterministic state reset: clear app data + relaunch, per the
359925
+ * runner-computed allowAppReset policy (Android emulators / iOS simulators
359926
+ * only — a personal physical device is never wiped). Android clears via
359927
+ * `mobile: clearApp`; iOS terminates the app and wipes its simulator data
359928
+ * container + keychain through `simctl` (see ios-simulator-reset.ts).
359929
+ * Returns false when unsupported/disallowed or when the clear fails —
359930
+ * callers fall back to relaunch(), preserving today's behavior. On success,
359931
+ * waits for cold-boot content so recording and replay agree on the true
359932
+ * entry screen.
358997
359933
  */
358998
359934
  async resetAppState() {
358999
- if (!this.allowAppReset || this.platform !== "ANDROID") return false;
359935
+ if (!this.allowAppReset) return false;
359936
+ if (this.platform === "IOS" && !isIosSimulatorUdid(this.deviceId)) return false;
359000
359937
  return this.withSessionRecovery("app data reset", async () => {
359001
359938
  try {
359002
- await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359939
+ if (this.platform === "IOS") {
359940
+ try {
359941
+ await this.executeScript("mobile: terminateApp", [{ ...this.appLifecycleArgs(), timeout: 5e3 }]);
359942
+ } catch (err) {
359943
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
359944
+ }
359945
+ const wiped = await clearIosSimulatorAppData(this.deviceId ?? "", this.appId);
359946
+ if (!wiped.ok) {
359947
+ throw new Error(`iOS simulator app-data reset failed: ${wiped.detail}`);
359948
+ }
359949
+ } else {
359950
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359951
+ }
359003
359952
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
359004
359953
  try {
359005
359954
  await waitForColdBootSettle(this.sessionPath);
@@ -359138,7 +360087,7 @@ function readActiveBundleId2(result) {
359138
360087
  }
359139
360088
 
359140
360089
  // src/services/doctor.ts
359141
- var import_child_process4 = require("child_process");
360090
+ var import_child_process5 = require("child_process");
359142
360091
  var import_fs4 = require("fs");
359143
360092
  var import_os5 = require("os");
359144
360093
  var C = {
@@ -359158,7 +360107,7 @@ function computeDoctorReadiness(web, ios, android) {
359158
360107
  }
359159
360108
  function run(cmd) {
359160
360109
  try {
359161
- return (0, import_child_process4.execSync)(cmd, { encoding: "utf-8", timeout: 15e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
360110
+ return (0, import_child_process5.execSync)(cmd, { encoding: "utf-8", timeout: 15e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
359162
360111
  } catch {
359163
360112
  return null;
359164
360113
  }
@@ -359590,6 +360539,113 @@ function envInt(name, fallback) {
359590
360539
  }
359591
360540
  var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
359592
360541
  var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
360542
+ var MOBILE_RUN_WATCHDOG_MS = Math.max(0, envInt("MOBILE_RUN_WATCHDOG_MS", 30 * 6e4));
360543
+ var MOBILE_RUN_ABORT_GRACE_MS = Math.max(0, envInt("MOBILE_RUN_ABORT_GRACE_MS", 2 * 6e4));
360544
+ var MOBILE_CANCEL_POLL_MS = Math.max(0, envInt("MOBILE_CANCEL_POLL_MS", 15e3));
360545
+ var MobileRunWatchdog = class {
360546
+ constructor(options) {
360547
+ this.options = options;
360548
+ }
360549
+ phase = "idle";
360550
+ released = false;
360551
+ quarantined = false;
360552
+ cancelAbortTimer = null;
360553
+ cancelGraceTimer = null;
360554
+ /** Current phase (exposed for tests / observability). */
360555
+ getPhase() {
360556
+ return this.phase;
360557
+ }
360558
+ /** Arm the wall-clock abort timer. Call once, immediately after dispatch. */
360559
+ start() {
360560
+ if (this.phase !== "idle") return;
360561
+ this.phase = "active";
360562
+ if (this.options.abortMs > 0) {
360563
+ this.cancelAbortTimer = this.options.schedule(
360564
+ () => this.triggerAbort(`exceeded the ${Math.round(this.options.abortMs / 6e4)}min wall-clock ceiling`),
360565
+ this.options.abortMs
360566
+ );
360567
+ }
360568
+ }
360569
+ /** Stage 1. Idempotent (a no-op unless still ACTIVE). Also called by the
360570
+ * cancellation observer. Quarantines the device (blocking the run's own
360571
+ * session-recovery from resurrecting the killed session — observed live),
360572
+ * kills the run's session (best-effort), and starts the grace clock;
360573
+ * deliberately does NOT release the lease. */
360574
+ triggerAbort(reason) {
360575
+ if (this.phase !== "active") return;
360576
+ this.phase = "aborting";
360577
+ this.clearAbortTimer();
360578
+ if (this.options.deviceId) {
360579
+ this.quarantined = true;
360580
+ this.options.callbacks.quarantine(this.options.deviceId);
360581
+ this.options.callbacks.abort(this.options.deviceId, this.options.runId, reason);
360582
+ } else {
360583
+ this.options.callbacks.log("warn", `Run ${this.options.runId} ${reason} \u2014 no leased device to abort; awaiting the run's own settle.`);
360584
+ }
360585
+ if (this.options.graceMs > 0) {
360586
+ this.cancelGraceTimer = this.options.schedule(() => this.forceRelease(), this.options.graceMs);
360587
+ } else {
360588
+ this.forceRelease();
360589
+ }
360590
+ }
360591
+ /** The run's promise settled (normal completion, or after an abort). The single
360592
+ * release owner: releases at most once and lifts any quarantine. Idempotent. */
360593
+ settle() {
360594
+ if (this.phase === "settled") return;
360595
+ const wasForceReleased = this.released;
360596
+ this.phase = "settled";
360597
+ this.clearAbortTimer();
360598
+ this.clearGraceTimer();
360599
+ if (!this.released) {
360600
+ this.released = true;
360601
+ this.options.callbacks.release();
360602
+ }
360603
+ if (this.quarantined && this.options.deviceId) {
360604
+ this.quarantined = false;
360605
+ this.options.callbacks.unquarantine(this.options.deviceId);
360606
+ if (wasForceReleased) {
360607
+ this.options.callbacks.log("info", `Run ${this.options.runId} finally settled \u2014 lifted the quarantine on device ${this.options.deviceId}.`);
360608
+ }
360609
+ }
360610
+ }
360611
+ /** Stage 2. The run did not settle within the grace window after the abort. Force-
360612
+ * release the slot to unwedge capacity; the device stays quarantined (since the
360613
+ * stage-1 abort) until the promise eventually settles. Idempotent and guarded
360614
+ * against a late settle. */
360615
+ forceRelease() {
360616
+ if (this.released || this.phase === "settled") return;
360617
+ this.clearGraceTimer();
360618
+ this.released = true;
360619
+ this.options.callbacks.release();
360620
+ if (this.options.deviceId) {
360621
+ if (!this.quarantined) {
360622
+ this.quarantined = true;
360623
+ this.options.callbacks.quarantine(this.options.deviceId);
360624
+ }
360625
+ this.options.callbacks.log(
360626
+ "warn",
360627
+ `Run ${this.options.runId} did not settle ${Math.round(this.options.graceMs / 1e3)}s after abort \u2014 force-released its slot; device ${this.options.deviceId} stays quarantined from re-lease until the run's promise settles.`
360628
+ );
360629
+ } else {
360630
+ this.options.callbacks.log("warn", `Run ${this.options.runId} did not settle after abort \u2014 force-released its slot to unwedge mobile capacity.`);
360631
+ }
360632
+ }
360633
+ clearAbortTimer() {
360634
+ if (this.cancelAbortTimer) {
360635
+ this.cancelAbortTimer();
360636
+ this.cancelAbortTimer = null;
360637
+ }
360638
+ }
360639
+ clearGraceTimer() {
360640
+ if (this.cancelGraceTimer) {
360641
+ this.cancelGraceTimer();
360642
+ this.cancelGraceTimer = null;
360643
+ }
360644
+ }
360645
+ };
360646
+ function isServerCancelledRunStatus(status) {
360647
+ return status === "ERROR" || status === "CANCELLED" || status === "CANCELLING";
360648
+ }
359593
360649
  function scrubAuditMessages(messages, testVariables) {
359594
360650
  if (messages === void 0 || messages === null) return messages;
359595
360651
  const compacted = compactForAudit(messages);
@@ -359665,7 +360721,7 @@ var log = {
359665
360721
  dim: (msg) => console.log(`${C2.gray}${msg}${C2.reset}`)
359666
360722
  };
359667
360723
  function getRunsDir() {
359668
- const dir = process.env.RUNS_DIR || (0, import_path4.join)((0, import_os6.homedir)(), ".validate.qa", "runs");
360724
+ const dir = process.env.RUNS_DIR || (0, import_path5.join)((0, import_os6.homedir)(), ".validate.qa", "runs");
359669
360725
  (0, import_runner_core12.ensurePrivateDirectory)(dir);
359670
360726
  return dir;
359671
360727
  }
@@ -361151,9 +362207,15 @@ ${finalVerifyResult.logs ?? ""}`;
361151
362207
  // flip the test to needs_manual_authoring), so an environmentally
361152
362208
  // aborted pass — wrong screen, system dialog, transport flake —
361153
362209
  // must not persist step divergences it could not fairly judge.
361154
- // Harvested bundles are still posted: they were positively
361155
- // resolved before the abort and the merge is idempotent.
361156
- divergentOrders: harvest.environmentalError ? [] : harvest.divergentOrders,
362210
+ // The same applies to a harvest that ADVANCED THROUGH NOTHING
362211
+ // (reachedOrder null): it never got past the entry screen, so its
362212
+ // step judgments are cold-boot/entry noise, not app knowledge —
362213
+ // observed live: a slow WKWebView hydration marked step 1
362214
+ // divergent and permanently dead-ended the test in
362215
+ // needs_manual_authoring with no retry path. Harvested bundles
362216
+ // are still posted: they were positively resolved and the merge
362217
+ // is idempotent.
362218
+ divergentOrders: harvest.environmentalError || harvest.reachedOrder == null ? [] : harvest.divergentOrders,
361157
362219
  reachedOrder: harvest.reachedOrder,
361158
362220
  logs: harvest.logs,
361159
362221
  ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
@@ -361597,9 +362659,11 @@ ${finalVerifyResult.logs ?? ""}`;
361597
362659
  sessionId: session.sessionId,
361598
362660
  platform: platform3,
361599
362661
  appId: session.appId,
361600
- // Deterministic-state authorization: Android emulators only by
361601
- // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
361602
- // physical device must never have its app data wiped.
362662
+ deviceId: session.deviceId,
362663
+ // Deterministic-state authorization: Android emulators / iOS
362664
+ // simulators only (VALIDATEQA_MOBILE_APP_RESET overrides the
362665
+ // Android default) — a personal physical device must never have
362666
+ // its app data wiped.
361603
362667
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
361604
362668
  maxSessionRecoveries: 1,
361605
362669
  recoverSession: async (cause) => {
@@ -362115,6 +363179,7 @@ ${finalVerifyResult.logs ?? ""}`;
362115
363179
  sessionId: opened.sessionId,
362116
363180
  platform: target.platform,
362117
363181
  appId: opened.appId,
363182
+ deviceId: opened.deviceId,
362118
363183
  allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, opened.deviceId)
362119
363184
  });
362120
363185
  };
@@ -362727,6 +363792,7 @@ async function startRunner(opts) {
362727
363792
  else activeMobileOpsByPlatform.set(p, next);
362728
363793
  };
362729
363794
  const leasedDeviceIds = /* @__PURE__ */ new Map();
363795
+ const activeMobileRuns = /* @__PURE__ */ new Map();
362730
363796
  let activeMobileDiscoveries = 0;
362731
363797
  const mobileHealHold = /* @__PURE__ */ new Map();
362732
363798
  const pruneMobileHealHolds = () => {
@@ -362807,6 +363873,7 @@ async function startRunner(opts) {
362807
363873
  }
362808
363874
  }
362809
363875
  };
363876
+ const heartbeatKeepalive = startHeartbeatKeepalive();
362810
363877
  const sendHeartbeat = async () => {
362811
363878
  try {
362812
363879
  const devices = enableMobile ? getCachedDeviceSnapshot() : [];
@@ -362821,41 +363888,44 @@ async function startRunner(opts) {
362821
363888
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
362822
363889
  lastInventoryKinds.clear();
362823
363890
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
362824
- const response = await fetch(`${serverUrl}/api/runner/heartbeat`, {
363891
+ const heartbeatUrl = `${serverUrl}/api/runner/heartbeat`;
363892
+ const heartbeatBody = JSON.stringify({
363893
+ runnerName: RUNNER_NAME,
363894
+ runnerVersion: RUNNER_VERSION,
363895
+ runnerBuildId: RUNNER_BUILD_ID,
363896
+ runsCompleted,
363897
+ activeRuns,
363898
+ maxConcurrent: MAX_CONCURRENT,
363899
+ uptimeMs: Date.now() - startedAt,
363900
+ capabilities,
363901
+ devices,
363902
+ mobileBridge: bridge ? {
363903
+ online: bridge.running,
363904
+ port: bridge.port,
363905
+ tunnelUrl: bridge.tunnelUrl,
363906
+ tunnelToken: bridge.tunnelToken
363907
+ } : void 0,
363908
+ activeByType: Object.fromEntries(activeByType),
363909
+ activeByProject: Object.fromEntries(activeByProject),
363910
+ typeLimits: TYPE_LIMITS,
363911
+ maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
363912
+ resourceSnapshot: resourceStatus.snapshot,
363913
+ resourceGovernor: resourceStatus.governor,
363914
+ processInventory,
363915
+ aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
363916
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
363917
+ // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
363918
+ // Cached & cheap — see getCachedDoctor above.
363919
+ doctor: getCachedDoctor()
363920
+ });
363921
+ const response = await fetch(heartbeatUrl, {
362825
363922
  method: "POST",
362826
363923
  headers,
362827
- body: JSON.stringify({
362828
- runnerName: RUNNER_NAME,
362829
- runnerVersion: RUNNER_VERSION,
362830
- runnerBuildId: RUNNER_BUILD_ID,
362831
- runsCompleted,
362832
- activeRuns,
362833
- maxConcurrent: MAX_CONCURRENT,
362834
- uptimeMs: Date.now() - startedAt,
362835
- capabilities,
362836
- devices,
362837
- mobileBridge: bridge ? {
362838
- online: bridge.running,
362839
- port: bridge.port,
362840
- tunnelUrl: bridge.tunnelUrl,
362841
- tunnelToken: bridge.tunnelToken
362842
- } : void 0,
362843
- activeByType: Object.fromEntries(activeByType),
362844
- activeByProject: Object.fromEntries(activeByProject),
362845
- typeLimits: TYPE_LIMITS,
362846
- maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
362847
- resourceSnapshot: resourceStatus.snapshot,
362848
- resourceGovernor: resourceStatus.governor,
362849
- processInventory,
362850
- aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
362851
- liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
362852
- // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
362853
- // Cached & cheap — see getCachedDoctor above.
362854
- doctor: getCachedDoctor()
362855
- }),
363924
+ body: heartbeatBody,
362856
363925
  signal: AbortSignal.timeout(1e4)
362857
363926
  });
362858
363927
  if (response.ok) {
363928
+ heartbeatKeepalive.update(heartbeatUrl, headers, heartbeatBody);
362859
363929
  const body = await response.json().catch(() => null);
362860
363930
  if (body && Array.isArray(body.commands) && body.commands.length > 0) {
362861
363931
  void handleControlCommands(body.commands);
@@ -362926,6 +363996,7 @@ async function startRunner(opts) {
362926
363996
  process.exit(1);
362927
363997
  }, SHUTDOWN_TIMEOUT_MS).unref();
362928
363998
  clearInterval(heartbeat);
363999
+ heartbeatKeepalive.stop();
362929
364000
  if (liveBrowserHeartbeatTimer) {
362930
364001
  clearTimeout(liveBrowserHeartbeatTimer);
362931
364002
  liveBrowserHeartbeatTimer = null;
@@ -362977,6 +364048,32 @@ async function startRunner(opts) {
362977
364048
  }
362978
364049
  }, PIDS_WATCHDOG_INTERVAL_MS);
362979
364050
  pidsWatchdog.unref();
364051
+ const pollMobileRunCancellations = async () => {
364052
+ if (shuttingDown || activeMobileRuns.size === 0) return;
364053
+ const runIds = [...activeMobileRuns.keys()];
364054
+ try {
364055
+ const res = await fetch(`${serverUrl}/api/runner/runs/status`, {
364056
+ method: "POST",
364057
+ headers: { ...headers, "Content-Type": "application/json" },
364058
+ body: JSON.stringify({ runIds }),
364059
+ signal: AbortSignal.timeout(1e4)
364060
+ });
364061
+ if (!res.ok) return;
364062
+ const body = await res.json().catch(() => null);
364063
+ if (!body || !Array.isArray(body.runs)) return;
364064
+ for (const row of body.runs) {
364065
+ if (!row || typeof row.id !== "string" || typeof row.status !== "string") continue;
364066
+ if (!isServerCancelledRunStatus(row.status)) continue;
364067
+ const watchdog = activeMobileRuns.get(row.id);
364068
+ if (watchdog) watchdog.triggerAbort(`was cancelled server-side (status ${row.status})`);
364069
+ }
364070
+ } catch {
364071
+ }
364072
+ };
364073
+ const mobileCancelPoll = enableMobile && MOBILE_CANCEL_POLL_MS > 0 ? setInterval(() => {
364074
+ void pollMobileRunCancellations();
364075
+ }, MOBILE_CANCEL_POLL_MS) : null;
364076
+ mobileCancelPoll?.unref?.();
362980
364077
  const deferralLogged = /* @__PURE__ */ new Map();
362981
364078
  const logDeferredOnce = (runId, line) => {
362982
364079
  if (deferralLogged.get(runId) === line) return;
@@ -363059,14 +364156,17 @@ async function startRunner(opts) {
363059
364156
  });
363060
364157
  const capacity = MOBILE_DEVICE_CEILING > 0 ? Math.min(derived, MOBILE_DEVICE_CEILING) : derived;
363061
364158
  const leaseTarget = run2.target ?? run2.deviceRequirements;
363062
- const leaseSelection = selectFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot, {
364159
+ const leaseExclusions = {
364160
+ has: (id) => leasedDeviceIds.has(id) || isDeviceQuarantined(id)
364161
+ };
364162
+ const leaseSelection = selectFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot, {
363063
364163
  appId: typeof leaseTarget?.appId === "string" ? leaseTarget.appId : void 0,
363064
364164
  recordedDeviceId: typeof leaseTarget?.recordedDeviceId === "string" ? leaseTarget.recordedDeviceId : void 0,
363065
364165
  platformVersion: typeof leaseTarget?.platformVersion === "string" ? leaseTarget.platformVersion : void 0,
363066
364166
  deviceName: typeof run2.deviceRequirements?.deviceName === "string" ? run2.deviceRequirements.deviceName : void 0,
363067
364167
  resetPolicy: leaseTarget?.resetPolicy
363068
364168
  });
363069
- const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot) : null;
364169
+ const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot) : null;
363070
364170
  if (leaseSelection.kind === "unsatisfiable") {
363071
364171
  mobileLeaseError = leaseSelection.reason;
363072
364172
  log.warn(` Mobile requirements unsatisfiable for ${run2.runId}: ${leaseSelection.reason}; claiming once to report the configuration error`);
@@ -363124,6 +364224,54 @@ async function startRunner(opts) {
363124
364224
  await new Promise((r) => setTimeout(r, staggerMs));
363125
364225
  }
363126
364226
  claimedThisPoll++;
364227
+ const applyRelease = () => {
364228
+ activeRuns--;
364229
+ decrementType(runMode);
364230
+ decrementProject(runProject);
364231
+ if (isMobileOp) {
364232
+ if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
364233
+ if (isDiscovery) activeMobileDiscoveries--;
364234
+ if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
364235
+ activeMobileRuns.delete(run2.runId);
364236
+ }
364237
+ };
364238
+ const runWatchdog = isMobileOp ? new MobileRunWatchdog({
364239
+ runId: run2.runId,
364240
+ deviceId: leasedDeviceId ?? void 0,
364241
+ abortMs: MOBILE_RUN_WATCHDOG_MS,
364242
+ graceMs: MOBILE_RUN_ABORT_GRACE_MS,
364243
+ // Real timers, unref'd so a pending watchdog never keeps the process
364244
+ // alive on shutdown.
364245
+ schedule: (fn, ms) => {
364246
+ const t = setTimeout(fn, ms);
364247
+ if (typeof t.unref === "function") t.unref();
364248
+ return () => clearTimeout(t);
364249
+ },
364250
+ callbacks: {
364251
+ abort: (deviceId, rid, reason) => {
364252
+ const sessionId = getMirrorSessionId(deviceId);
364253
+ log.warn(
364254
+ `Run ${rid} ${reason} \u2014 aborting its in-flight Appium session${sessionId ? ` ${sessionId}` : " (no live session found)"} on device ${deviceId} so it fails fast and frees the lease.`
364255
+ );
364256
+ if (sessionId) void deleteAppiumSession(sessionId);
364257
+ },
364258
+ release: applyRelease,
364259
+ quarantine: (deviceId) => {
364260
+ quarantineDevice(deviceId);
364261
+ },
364262
+ unquarantine: (deviceId) => {
364263
+ unquarantineDevice(deviceId);
364264
+ },
364265
+ log: (level, message) => {
364266
+ if (level === "warn") log.warn(message);
364267
+ else log.info(message);
364268
+ }
364269
+ }
364270
+ }) : null;
364271
+ if (runWatchdog) {
364272
+ activeMobileRuns.set(run2.runId, runWatchdog);
364273
+ runWatchdog.start();
364274
+ }
363127
364275
  executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, (testCaseId) => {
363128
364276
  if (mobilePlatform) {
363129
364277
  mobileHealHold.set(testCaseId, { expiry: Date.now() + MOBILE_HEAL_HOLD_MS, platform: mobilePlatform });
@@ -363134,14 +364282,8 @@ async function startRunner(opts) {
363134
364282
  const msg = err instanceof Error ? err.message : String(err);
363135
364283
  log.warn(`Run ${run2.runId} crashed: ${msg}`);
363136
364284
  }).finally(() => {
363137
- activeRuns--;
363138
- decrementType(runMode);
363139
- decrementProject(runProject);
363140
- if (isMobileOp) {
363141
- if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
363142
- if (isDiscovery) activeMobileDiscoveries--;
363143
- if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
363144
- }
364285
+ if (runWatchdog) runWatchdog.settle();
364286
+ else applyRelease();
363145
364287
  });
363146
364288
  }
363147
364289
  }