@validate.qa/runner 1.0.70 → 1.0.71

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 +1377 -351
  2. package/dist/cli.mjs +1374 -348
  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) {
@@ -11293,7 +11492,7 @@ var require_thread_stream = __commonJS({
11293
11492
  var { version } = require_package();
11294
11493
  var { EventEmitter: EventEmitter2 } = require("events");
11295
11494
  var { Worker } = require("worker_threads");
11296
- var { join: join7 } = require("path");
11495
+ var { join: join8 } = require("path");
11297
11496
  var { pathToFileURL } = require("url");
11298
11497
  var { wait } = require_wait();
11299
11498
  var {
@@ -11344,7 +11543,7 @@ 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");
11546
+ const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11348
11547
  const worker = new Worker(toExecute, {
11349
11548
  ...opts.workerOpts,
11350
11549
  name: opts.workerOpts?.name || "thread-stream",
@@ -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) => {
@@ -81167,10 +81366,14 @@ var require_mobile_generator = __commonJS({
81167
81366
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
81168
81367
  "use strict";
81169
81368
  Object.defineProperty(exports2, "__esModule", { value: true });
81369
+ exports2.structuralScreenKey = structuralScreenKey;
81170
81370
  exports2.collapseConsecutiveBacks = collapseConsecutiveBacks;
81171
81371
  exports2.scenarioIntentMismatchReason = scenarioIntentMismatchReason;
81172
81372
  exports2.dedupeConsecutiveIdenticalActions = dedupeConsecutiveIdenticalActions;
81173
81373
  exports2.collapseRetryCycles = collapseRetryCycles;
81374
+ exports2.coalesceSameFieldTypes = coalesceSameFieldTypes;
81375
+ exports2.coalesceRepeatedTaps = coalesceRepeatedTaps;
81376
+ exports2.elideNoOpDetours = elideNoOpDetours;
81174
81377
  exports2.mobileActionsToPseudoPlaywright = mobileActionsToPseudoPlaywright;
81175
81378
  exports2.evaluateMobileAssertionStrength = evaluateMobileAssertionStrength;
81176
81379
  exports2.prepareMobileScenarioAppState = prepareMobileScenarioAppState;
@@ -81379,6 +81582,22 @@ var require_mobile_generator = __commonJS({
81379
81582
  MOBILE_ASSERT_TEXT_TOOL,
81380
81583
  FINISH_GENERATE_TOOL
81381
81584
  ];
81585
+ var STRUCTURAL_KEY_MAX_ELEMENTS = 16;
81586
+ function structuralScreenKey(resolved) {
81587
+ if (!resolved)
81588
+ return null;
81589
+ const nativeElements = resolved.screen.elements;
81590
+ const hybridElements = resolved.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81591
+ if (nativeElements.length + hybridElements.length < 2)
81592
+ return null;
81593
+ 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 ?? ""}`);
81594
+ 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 ?? ""}`);
81595
+ const activityOrBundle = resolved.signal.activity ?? resolved.signal.bundleId ?? "";
81596
+ const payload = `${activityOrBundle}
81597
+ ${nativeSkeleton.join("\n")}
81598
+ ${hybridSkeleton.join("\n")}`;
81599
+ return (0, crypto_1.createHash)("sha1").update(payload).digest("hex");
81600
+ }
81382
81601
  function platformForMedium(medium) {
81383
81602
  if (medium === "IOS_NATIVE")
81384
81603
  return "IOS";
@@ -81540,18 +81759,39 @@ var require_mobile_generator = __commonJS({
81540
81759
  }
81541
81760
  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
81761
  }
81543
- function metadataForElement(element, screenName, platform3) {
81762
+ function alreadyFocusedWithEqualValue(element, value) {
81763
+ return element.focused === true && typeof element.text === "string" && value.length > 0 && element.text === value;
81764
+ }
81765
+ function nativeElementsOf(resolved) {
81766
+ return resolved?.screen.elements ?? [];
81767
+ }
81768
+ function hybridElementsOf(resolved) {
81769
+ return resolved?.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81770
+ }
81771
+ function metadataForElement(element, screenName, platform3, elements = []) {
81772
+ const match = (0, mobile_source_parser_js_2.identityMatchInfo)(element, elements);
81544
81773
  return {
81545
81774
  screenName,
81546
81775
  targetLabel: labelForElement(element),
81547
81776
  locatorBundle: (0, mobile_source_parser_js_2.buildLocatorBundle)(element, platform3),
81548
- bounds: element.bounds
81777
+ bounds: element.bounds,
81778
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81549
81779
  };
81550
81780
  }
81551
- function metadataForHybridElement(element, screenName) {
81781
+ function hybridIdentityMatchInfo(element, elements) {
81782
+ const hybridIdentityValue = (candidate) => nonEmpty3(candidate.id) ?? (candidate.editable === true ? void 0 : nonEmpty3(candidate.text));
81783
+ const chosenValue = hybridIdentityValue(element);
81784
+ if (!chosenValue)
81785
+ return void 0;
81786
+ const sameValue = elements.filter((candidate) => hybridIdentityValue(candidate) === chosenValue);
81787
+ const matchIndex = sameValue.indexOf(element);
81788
+ return matchIndex >= 0 && sameValue.length > 1 ? { matchIndex, expectedMatchCount: sameValue.length } : void 0;
81789
+ }
81790
+ function metadataForHybridElement(element, screenName, elements = []) {
81552
81791
  const editable = isEditableHybridElement(element);
81553
81792
  const label = labelForHybridElement(element);
81554
81793
  const safeText = editable ? void 0 : nonEmpty3(element.text);
81794
+ const match = hybridIdentityMatchInfo(element, elements);
81555
81795
  return {
81556
81796
  screenName,
81557
81797
  targetLabel: label,
@@ -81560,11 +81800,16 @@ var require_mobile_generator = __commonJS({
81560
81800
  webviewContext: element.webviewContext,
81561
81801
  bounds: element.bounds,
81562
81802
  locatorBundle: {
81563
- xpath: `css=${element.css}`,
81803
+ // WebView controls resolve via `webCss` (above) in the WEBVIEW context, not
81804
+ // by xpath. `LocatorBundle.xpath` is contractually an XPath, so never store a
81805
+ // `css=` selector here — an empty xpath keeps the fallback chain honest and
81806
+ // prevents the css selector being misread as an xpath at replay.
81807
+ xpath: "",
81564
81808
  className: element.tag || "webview",
81565
81809
  ...element.id ? { accessibilityId: element.id } : {},
81566
81810
  ...safeText ? { text: safeText } : {}
81567
- }
81811
+ },
81812
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81568
81813
  };
81569
81814
  }
81570
81815
  function isEditableHybridElement(element) {
@@ -82282,7 +82527,15 @@ ${statePacket}`, redactionCredentials) },
82282
82527
  screenName: () => lastScreenName || "UnknownScreen",
82283
82528
  recordAction: (action) => {
82284
82529
  const screenHash = getLatest()?.signal.screenHash;
82285
- const stamped = screenHash && !action.metadata.screenHash ? { ...action, metadata: { ...action.metadata, screenHash } } : action;
82530
+ const structuralKey = structuralScreenKey(getLatest());
82531
+ const stamped = screenHash && !action.metadata.screenHash || structuralKey && !action.metadata.structuralKey ? {
82532
+ ...action,
82533
+ metadata: {
82534
+ ...action.metadata,
82535
+ ...screenHash && !action.metadata.screenHash ? { screenHash } : {},
82536
+ ...structuralKey && !action.metadata.structuralKey ? { structuralKey } : {}
82537
+ }
82538
+ } : action;
82286
82539
  actions.push((0, shared_1.placeholderizeRecordedMobileAction)(finalizeAction(stamped), credentials));
82287
82540
  if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
82288
82541
  assertCount++;
@@ -82388,7 +82641,7 @@ ${statePacket}`, redactionCredentials) },
82388
82641
  const result2 = await driver.webTap(hybridTap.webviewContext, hybridTap.css);
82389
82642
  const absorbed2 = await absorbActionResult(result2, "mobile_tap(webview)", opensAuthenticationSurface(hybridTap));
82390
82643
  if (shouldRecordAction(result2, absorbed2)) {
82391
- const metadata = metadataForHybridElement(hybridTap, beforeScreenName2);
82644
+ const metadata = metadataForHybridElement(hybridTap, beforeScreenName2, hybridElementsOf(before2));
82392
82645
  const label = metadata.targetLabel ?? hybridTap.css;
82393
82646
  const tapAction = {
82394
82647
  type: "TAP",
@@ -82440,7 +82693,7 @@ ${statePacket}`, redactionCredentials) },
82440
82693
  const tapAction = {
82441
82694
  type: "TAP",
82442
82695
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82443
- metadata: metadataForElement(element, beforeScreenName, platform3),
82696
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before)),
82444
82697
  innerText: null,
82445
82698
  url: "",
82446
82699
  value: null
@@ -82480,10 +82733,11 @@ ${statePacket}`, redactionCredentials) },
82480
82733
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82481
82734
  }
82482
82735
  const beforeScreenName2 = screenName();
82736
+ const before2 = latest();
82483
82737
  const result2 = await driver.webType(hybridType.webviewContext, hybridType.css, value);
82484
82738
  const absorbed2 = await absorbActionResult(result2, "mobile_type(webview)");
82485
82739
  if (shouldRecordAction(result2, absorbed2)) {
82486
- const metadata = metadataForHybridElement(hybridType, beforeScreenName2);
82740
+ const metadata = metadataForHybridElement(hybridType, beforeScreenName2, hybridElementsOf(before2));
82487
82741
  const label = metadata.targetLabel ?? hybridType.css;
82488
82742
  recordAction({
82489
82743
  type: "TYPE",
@@ -82498,7 +82752,14 @@ ${statePacket}`, redactionCredentials) },
82498
82752
  const element = resolveRef(toolArgs.ref);
82499
82753
  if (!element)
82500
82754
  return unknownRef(toolArgs.ref);
82755
+ if (alreadyFocusedWithEqualValue(element, value)) {
82756
+ return {
82757
+ ok: false,
82758
+ 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.`
82759
+ };
82760
+ }
82501
82761
  const beforeScreenName = screenName();
82762
+ const before = latest();
82502
82763
  const result = await driver.type(value, element.bounds);
82503
82764
  const absorbed = await absorbActionResult(result, "mobile_type");
82504
82765
  if (shouldRecordAction(result, absorbed)) {
@@ -82506,7 +82767,7 @@ ${statePacket}`, redactionCredentials) },
82506
82767
  type: "TYPE",
82507
82768
  value,
82508
82769
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82509
- metadata: metadataForElement(element, beforeScreenName, platform3)
82770
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82510
82771
  });
82511
82772
  }
82512
82773
  const payload = actionPayload(result, absorbed);
@@ -82524,6 +82785,7 @@ ${statePacket}`, redactionCredentials) },
82524
82785
  return { ok: false, error: "This driver does not support non-clearing WEBVIEW keyboard submit." };
82525
82786
  }
82526
82787
  const beforeScreenName2 = screenName();
82788
+ const before2 = latest();
82527
82789
  const result2 = await driver.webSubmitKeyboard(hybridField.webviewContext, hybridField.css);
82528
82790
  const absorbed2 = await absorbActionResult(result2, "mobile_submit_keyboard(webview)");
82529
82791
  if (shouldRecordAction(result2, absorbed2)) {
@@ -82531,7 +82793,7 @@ ${statePacket}`, redactionCredentials) },
82531
82793
  type: "TYPE",
82532
82794
  value: "\n",
82533
82795
  selector: `css=${hybridField.css}`,
82534
- metadata: metadataForHybridElement(hybridField, beforeScreenName2)
82796
+ metadata: metadataForHybridElement(hybridField, beforeScreenName2, hybridElementsOf(before2))
82535
82797
  });
82536
82798
  }
82537
82799
  return actionPayload(result2, absorbed2);
@@ -82543,6 +82805,7 @@ ${statePacket}`, redactionCredentials) },
82543
82805
  return { ok: false, error: "This driver does not support non-clearing keyboard submit." };
82544
82806
  }
82545
82807
  const beforeScreenName = screenName();
82808
+ const before = latest();
82546
82809
  const result = await driver.submitKeyboard(element.bounds);
82547
82810
  const absorbed = await absorbActionResult(result, "mobile_submit_keyboard");
82548
82811
  if (shouldRecordAction(result, absorbed)) {
@@ -82550,7 +82813,7 @@ ${statePacket}`, redactionCredentials) },
82550
82813
  type: "TYPE",
82551
82814
  value: "\n",
82552
82815
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82553
- metadata: metadataForElement(element, beforeScreenName, platform3)
82816
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82554
82817
  });
82555
82818
  }
82556
82819
  return actionPayload(result, absorbed);
@@ -82575,10 +82838,11 @@ ${statePacket}`, redactionCredentials) },
82575
82838
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82576
82839
  }
82577
82840
  const beforeScreenName2 = screenName();
82841
+ const before2 = latest();
82578
82842
  const result2 = await driver.webType(hybridFill.webviewContext, hybridFill.css, secret);
82579
82843
  const absorbed2 = await absorbActionResult(result2, "mobile_fill_credential(webview)");
82580
82844
  if (shouldRecordAction(result2, absorbed2)) {
82581
- const metadata = metadataForHybridElement(hybridFill, beforeScreenName2);
82845
+ const metadata = metadataForHybridElement(hybridFill, beforeScreenName2, hybridElementsOf(before2));
82582
82846
  const label = metadata.targetLabel ?? hybridFill.css;
82583
82847
  recordAction({
82584
82848
  type: "TYPE",
@@ -82593,7 +82857,14 @@ ${statePacket}`, redactionCredentials) },
82593
82857
  const element = resolveRef(toolArgs.ref);
82594
82858
  if (!element)
82595
82859
  return unknownRef(toolArgs.ref);
82860
+ if (alreadyFocusedWithEqualValue(element, secret)) {
82861
+ return {
82862
+ ok: false,
82863
+ 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.`
82864
+ };
82865
+ }
82596
82866
  const beforeScreenName = screenName();
82867
+ const before = latest();
82597
82868
  const result = await driver.type(secret, element.bounds);
82598
82869
  const absorbed = await absorbActionResult(result, "mobile_fill_credential");
82599
82870
  if (shouldRecordAction(result, absorbed)) {
@@ -82601,7 +82872,7 @@ ${statePacket}`, redactionCredentials) },
82601
82872
  type: "TYPE",
82602
82873
  value: (0, shared_1.mobileCredentialPlaceholder)(key),
82603
82874
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82604
- metadata: metadataForElement(element, beforeScreenName, platform3)
82875
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82605
82876
  });
82606
82877
  log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
82607
82878
  }
@@ -82629,13 +82900,14 @@ ${statePacket}`, redactionCredentials) },
82629
82900
  if (!element)
82630
82901
  return unknownRef(toolArgs.ref);
82631
82902
  const beforeScreenName = screenName();
82903
+ const before = latest();
82632
82904
  const result = await driver.clear(element.bounds);
82633
82905
  const absorbed = await absorbActionResult(result, "mobile_clear");
82634
82906
  if (shouldRecordAction(result, absorbed)) {
82635
82907
  recordAction({
82636
82908
  type: "CLEAR",
82637
82909
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82638
- metadata: metadataForElement(element, beforeScreenName, platform3)
82910
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82639
82911
  });
82640
82912
  }
82641
82913
  return actionPayload(result, absorbed);
@@ -82698,7 +82970,7 @@ ${statePacket}`, redactionCredentials) },
82698
82970
  case "mobile_assert_visible": {
82699
82971
  const hybrid = resolveHybridRef(toolArgs.ref);
82700
82972
  if (hybrid) {
82701
- const metadata = metadataForHybridElement(hybrid, screenName());
82973
+ const metadata = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82702
82974
  recordAction({
82703
82975
  type: "ASSERT_VISIBLE",
82704
82976
  selector: `css=${hybrid.css}`,
@@ -82738,7 +83010,7 @@ ${statePacket}`, redactionCredentials) },
82738
83010
  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
83011
  };
82740
83012
  }
82741
- const metadata2 = metadataForHybridElement(hybrid, screenName());
83013
+ const metadata2 = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82742
83014
  const stableWebIdentity = Boolean(nonEmpty3(hybrid.id) || nonEmpty3(hybrid.name));
82743
83015
  if (stableWebIdentity) {
82744
83016
  recordAction({
@@ -82791,7 +83063,7 @@ ${statePacket}`, redactionCredentials) },
82791
83063
  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
83064
  };
82793
83065
  }
82794
- const metadata = metadataForElement(element, screenName(), platform3);
83066
+ const metadata = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82795
83067
  const expectedNeedle = expected.trim().toLowerCase();
82796
83068
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
82797
83069
  const visibleText = (element.text ?? "").trim().toLowerCase();
@@ -82837,7 +83109,7 @@ ${statePacket}`, redactionCredentials) },
82837
83109
  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
83110
  };
82839
83111
  }
82840
- const metadata2 = metadataForElement(element, screenName(), platform3);
83112
+ const metadata2 = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82841
83113
  recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata: metadata2 });
82842
83114
  return { ok: true, label: metadata2.targetLabel ?? "element" };
82843
83115
  }
@@ -82887,7 +83159,7 @@ ${statePacket}`, redactionCredentials) },
82887
83159
  error: `The live element matching ${JSON.stringify(accessibilityId ?? text)} is not a durable assertion target (${weakReason}).`
82888
83160
  };
82889
83161
  }
82890
- const metadata = metadataForElement(liveElement, screenName(), platform3);
83162
+ const metadata = metadataForElement(liveElement, screenName(), platform3, live.screen.elements);
82891
83163
  recordAction({
82892
83164
  type: "ASSERT_VISIBLE",
82893
83165
  selector: (0, mobile_source_parser_js_2.preferredLocator)(liveElement),
@@ -83015,7 +83287,7 @@ ${statePacket}`, redactionCredentials) },
83015
83287
  actions.push(finalizeAction({
83016
83288
  type: "ASSERT_VISIBLE",
83017
83289
  selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83018
- metadata: metadataForElement(anchor, screen, platform3)
83290
+ metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83019
83291
  }));
83020
83292
  return true;
83021
83293
  }
@@ -83358,6 +83630,153 @@ ${statePacket}`, redactionCredentials) },
83358
83630
  }
83359
83631
  return out;
83360
83632
  }
83633
+ function isDurableMutation(action) {
83634
+ if (action.type === "TYPE" || action.type === "CLEAR")
83635
+ return true;
83636
+ if (action.type === "TAP")
83637
+ return (0, shared_1.isStateValueMobileClassName)(action.metadata.locatorBundle?.className);
83638
+ return false;
83639
+ }
83640
+ function mobileWanderTraceEnabled2() {
83641
+ const v = (process.env.VALIDATEQA_MOBILE_WANDER_TRACE || "").trim().toLowerCase();
83642
+ return v === "true" || v === "1";
83643
+ }
83644
+ function wanderTraceLabel(action) {
83645
+ const identity = action.metadata.targetLabel || action.selector || action.metadata.locatorBundle?.accessibilityId || action.metadata.locatorBundle?.resourceId || "(no identity)";
83646
+ return `${action.type} ${identity}`;
83647
+ }
83648
+ function logWanderDiff(pass, before, after, log2) {
83649
+ if (after.length >= before.length)
83650
+ return;
83651
+ log2(` ${pass}: collapsed ${before.length - after.length} step(s) (${before.length} \u2192 ${after.length}).`);
83652
+ if (!mobileWanderTraceEnabled2())
83653
+ return;
83654
+ const survivors = new Set(after);
83655
+ for (const action of before) {
83656
+ if (!survivors.has(action)) {
83657
+ log2(` [wander] ${pass}: dropped ${wanderTraceLabel(action)}`);
83658
+ }
83659
+ }
83660
+ }
83661
+ function coalesceSameFieldTypes(actions) {
83662
+ if (actions.length <= 1)
83663
+ return actions;
83664
+ const toDrop = /* @__PURE__ */ new Set();
83665
+ const lastTypeIndexBySelector = /* @__PURE__ */ new Map();
83666
+ const refocusTapIndexesBySelector = /* @__PURE__ */ new Map();
83667
+ for (let i = 0; i < actions.length; i++) {
83668
+ const current = actions[i];
83669
+ if (current.type === "ASSERT_VISIBLE" || current.type === "ASSERT_TEXT") {
83670
+ lastTypeIndexBySelector.clear();
83671
+ refocusTapIndexesBySelector.clear();
83672
+ continue;
83673
+ }
83674
+ if (current.type === "CLEAR") {
83675
+ lastTypeIndexBySelector.delete(current.selector);
83676
+ refocusTapIndexesBySelector.delete(current.selector);
83677
+ continue;
83678
+ }
83679
+ if (isFormCommitAction(current)) {
83680
+ lastTypeIndexBySelector.clear();
83681
+ refocusTapIndexesBySelector.clear();
83682
+ continue;
83683
+ }
83684
+ if (current.type === "TAP") {
83685
+ if (current.selector) {
83686
+ const list = refocusTapIndexesBySelector.get(current.selector) ?? [];
83687
+ list.push(i);
83688
+ refocusTapIndexesBySelector.set(current.selector, list);
83689
+ }
83690
+ continue;
83691
+ }
83692
+ if (current.type !== "TYPE" || !current.selector)
83693
+ continue;
83694
+ const priorIndex = lastTypeIndexBySelector.get(current.selector);
83695
+ if (priorIndex !== void 0) {
83696
+ toDrop.add(priorIndex);
83697
+ for (const tapIndex of refocusTapIndexesBySelector.get(current.selector) ?? []) {
83698
+ if (tapIndex > priorIndex && tapIndex < i)
83699
+ toDrop.add(tapIndex);
83700
+ }
83701
+ }
83702
+ lastTypeIndexBySelector.set(current.selector, i);
83703
+ refocusTapIndexesBySelector.set(current.selector, []);
83704
+ }
83705
+ if (toDrop.size === 0)
83706
+ return actions;
83707
+ return actions.filter((_, index) => !toDrop.has(index));
83708
+ }
83709
+ function coalesceRepeatedTaps(actions) {
83710
+ if (actions.length <= 1)
83711
+ return actions;
83712
+ const toDrop = /* @__PURE__ */ new Set();
83713
+ const lastTapIndexBySelector = /* @__PURE__ */ new Map();
83714
+ for (let i = 0; i < actions.length; i++) {
83715
+ const action = actions[i];
83716
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT") {
83717
+ lastTapIndexBySelector.clear();
83718
+ continue;
83719
+ }
83720
+ if (isDurableMutation(action)) {
83721
+ lastTapIndexBySelector.clear();
83722
+ continue;
83723
+ }
83724
+ if (action.type !== "TAP" || !action.selector)
83725
+ continue;
83726
+ const key = action.metadata.structuralKey;
83727
+ const priorIndex = lastTapIndexBySelector.get(action.selector);
83728
+ if (priorIndex !== void 0 && key && actions[priorIndex].metadata.structuralKey === key) {
83729
+ const priorHash = actions[priorIndex].metadata.screenHash;
83730
+ const currentHash = action.metadata.screenHash;
83731
+ const screenAdvanced = !!priorHash && !!currentHash && priorHash !== currentHash;
83732
+ if (!screenAdvanced) {
83733
+ toDrop.add(priorIndex);
83734
+ }
83735
+ }
83736
+ lastTapIndexBySelector.set(action.selector, i);
83737
+ }
83738
+ if (toDrop.size === 0)
83739
+ return actions;
83740
+ return actions.filter((_, index) => !toDrop.has(index));
83741
+ }
83742
+ var MAX_DETOUR_LENGTH = 6;
83743
+ function elideNoOpDetours(actions) {
83744
+ if (actions.length < 2)
83745
+ return actions;
83746
+ const toDrop = /* @__PURE__ */ new Set();
83747
+ for (let j = 0; j < actions.length; j++) {
83748
+ if (toDrop.has(j))
83749
+ continue;
83750
+ if (actions[j].type !== "BACK" && actions[j].type !== "HOME")
83751
+ continue;
83752
+ const keyAfter = actions[j].metadata.structuralKey;
83753
+ if (!keyAfter)
83754
+ continue;
83755
+ let bestStart = null;
83756
+ const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83757
+ for (let i = j; i >= earliestStart; i--) {
83758
+ if (i < j) {
83759
+ const inside = actions[i];
83760
+ if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83761
+ break;
83762
+ }
83763
+ }
83764
+ if (i === 0)
83765
+ continue;
83766
+ const keyBefore = actions[i - 1].metadata.structuralKey;
83767
+ if (i < j && keyBefore && keyBefore === keyAfter) {
83768
+ bestStart = i;
83769
+ }
83770
+ }
83771
+ if (bestStart !== null) {
83772
+ for (let k = bestStart; k <= j; k++)
83773
+ toDrop.add(k);
83774
+ }
83775
+ }
83776
+ if (toDrop.size === 0)
83777
+ return actions;
83778
+ return actions.filter((_, index) => !toDrop.has(index));
83779
+ }
83361
83780
  function mobileActionsToPseudoPlaywright(actions) {
83362
83781
  const escape = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
83363
83782
  const lines = ["test('mobile', async ({ page }) => {"];
@@ -83445,6 +83864,15 @@ ${statePacket}`, redactionCredentials) },
83445
83864
  if (actions.length < beforeCycleCollapse) {
83446
83865
  log2(` collapsed ${beforeCycleCollapse - actions.length} retry-cycle step(s) out of the recorded trace (${beforeCycleCollapse} \u2192 ${actions.length}).`);
83447
83866
  }
83867
+ const beforeTypeCoalesce = actions;
83868
+ actions = coalesceSameFieldTypes(actions);
83869
+ logWanderDiff("coalesceSameFieldTypes", beforeTypeCoalesce, actions, log2);
83870
+ const beforeTapCoalesce = actions;
83871
+ actions = coalesceRepeatedTaps(actions);
83872
+ logWanderDiff("coalesceRepeatedTaps", beforeTapCoalesce, actions, log2);
83873
+ const beforeDetourElision = actions;
83874
+ actions = elideNoOpDetours(actions);
83875
+ logWanderDiff("elideNoOpDetours", beforeDetourElision, actions, log2);
83448
83876
  const beforeContainerDrop = actions.length;
83449
83877
  actions = actions.filter((a) => !isBareContainerTap(a));
83450
83878
  if (actions.length < beforeContainerDrop) {
@@ -83629,48 +84057,29 @@ ${statePacket}`, redactionCredentials) },
83629
84057
  log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
83630
84058
  return applyScenarioEntryDeepLink(driver, target, { appDataCleared: false, coldResetUnavailable: false, refuseGenerate: false, reason: "preserve_policy" }, log2);
83631
84059
  }
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);
84060
+ const appDataCleared = await driver.resetAppState?.().catch((err) => {
84061
+ log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
84062
+ return false;
84063
+ }) ?? false;
84064
+ if (appDataCleared) {
84065
+ return applyScenarioEntryDeepLink(driver, target, { appDataCleared: true, coldResetUnavailable: false, refuseGenerate: false }, log2);
83657
84066
  }
83658
84067
  await driver.relaunch();
83659
84068
  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.");
84069
+ 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
84070
  return {
83662
84071
  appDataCleared: false,
83663
84072
  coldResetUnavailable: true,
83664
84073
  refuseGenerate: true,
83665
- reason: "ios_clear_app_data_unavailable"
84074
+ reason: platform3 === "IOS" ? "ios_clear_app_data_unavailable" : "clear_app_data_unavailable"
83666
84075
  };
83667
84076
  }
83668
- log2(" iOS has no clearApp \u2014 scenario records after relaunch (warm-state risk for auth flows).");
84077
+ 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
84078
  return applyScenarioEntryDeepLink(driver, target, {
83670
84079
  appDataCleared: false,
83671
84080
  coldResetUnavailable: true,
83672
84081
  refuseGenerate: false,
83673
- reason: "ios_no_clear_app"
84082
+ reason: platform3 === "IOS" ? "ios_no_clear_app" : platform3 === "ANDROID" ? "android_reset_unavailable" : "reset_unavailable"
83674
84083
  }, log2);
83675
84084
  }
83676
84085
  async function runMobileGeneratePhase(args) {
@@ -84273,6 +84682,8 @@ var require_mobile_heal_loop = __commonJS({
84273
84682
  "use strict";
84274
84683
  Object.defineProperty(exports2, "__esModule", { value: true });
84275
84684
  exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = void 0;
84685
+ exports2.timed = timed;
84686
+ exports2.timedSync = timedSync;
84276
84687
  exports2.executeMobileScriptDrivenHeal = executeMobileScriptDrivenHeal;
84277
84688
  exports2.executeMobileTestWithHealing = executeMobileTestWithHealing2;
84278
84689
  exports2.executeMobileLiveHeal = executeMobileLiveHeal2;
@@ -84482,6 +84893,32 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84482
84893
  function detectInvalidMobileAuthSetup(steps, error2) {
84483
84894
  return detectInvalidExternalAuthSetup(steps, error2) ?? detectInvalidAuthValidationSetup(steps, error2);
84484
84895
  }
84896
+ function formatHealTimingLine(category, label, ms, failed, errMessage) {
84897
+ const base2 = `\u{1FA7A}\u23F1 [${category}] ${label} \u2014 ${ms}ms`;
84898
+ return failed ? `${base2} (FAILED: ${errMessage ?? "unknown error"})` : base2;
84899
+ }
84900
+ async function timed(category, label, onProgress, fn) {
84901
+ const start = Date.now();
84902
+ try {
84903
+ const result = await fn();
84904
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84905
+ return result;
84906
+ } catch (err) {
84907
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84908
+ throw err;
84909
+ }
84910
+ }
84911
+ function timedSync(category, label, onProgress, fn) {
84912
+ const start = Date.now();
84913
+ try {
84914
+ const result = fn();
84915
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84916
+ return result;
84917
+ } catch (err) {
84918
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84919
+ throw err;
84920
+ }
84921
+ }
84485
84922
  var IDENTITY_GATE_ERROR_RE = /No identity locator \(accessibility id \/ resource id \/ text\) available for this step/;
84486
84923
  function isIdentityEqualityXpath(value) {
84487
84924
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
@@ -84699,21 +85136,23 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84699
85136
  return JSON.stringify(base2?.target) !== JSON.stringify(healed.target);
84700
85137
  }
84701
85138
  function findMissingChangedLocators(baseSteps, healedSteps, screen) {
84702
- const missing = [];
85139
+ const results = [];
84703
85140
  healedSteps.forEach((healed, index) => {
84704
85141
  if (!stepTargetChanged(baseSteps[index], healed))
84705
85142
  return;
84706
85143
  const candidate = stepToLocatorCandidate(healed);
84707
85144
  if (!candidate)
84708
85145
  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
- });
85146
+ const matchCount = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length;
85147
+ const label = healed.target?.primary ?? JSON.stringify(candidate);
85148
+ const expressions = stepIdentityExpressions(healed);
85149
+ if (matchCount === 0) {
85150
+ results.push({ label, expressions, kind: "missing" });
85151
+ } else if (matchCount > 1) {
85152
+ results.push({ label, expressions, kind: "ambiguous", matchCount });
84714
85153
  }
84715
85154
  });
84716
- return missing;
85155
+ return results;
84717
85156
  }
84718
85157
  function changedStepExpressions(baseSteps, healedSteps) {
84719
85158
  const out = [];
@@ -84942,10 +85381,10 @@ ${args.directive}`);
84942
85381
  let content;
84943
85382
  let finishReason;
84944
85383
  try {
84945
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
85384
+ 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
85385
  arm();
84947
- return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
84948
- }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
85386
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85387
+ }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true }));
84949
85388
  content = response?.choices?.[0]?.message?.content ?? "";
84950
85389
  finishReason = response?.choices?.[0]?.finish_reason ?? void 0;
84951
85390
  } catch {
@@ -84988,14 +85427,18 @@ ${args.directive}`);
84988
85427
  continue;
84989
85428
  }
84990
85429
  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);
85430
+ const failingSourceSnapshot = activeFailingSource;
85431
+ const missing = timedSync("device-op", `phase1-attempt-${attempt}-static-verify`, options.onProgress, () => {
85432
+ const screen = (0, mobile_source_parser_js_1.parseMobileSource)(failingSourceSnapshot, platform3);
85433
+ return findMissingChangedLocators(baseSteps, healedSteps, screen);
85434
+ });
84993
85435
  if (missing.length > 0) {
84994
85436
  for (const entry of missing)
84995
85437
  pushUnique(triedLocators, entry.expressions);
84996
85438
  markRecoverableTurn();
84997
85439
  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.`;
85440
+ const first = missing[0];
85441
+ 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
85442
  continue;
85000
85443
  }
85001
85444
  }
@@ -85009,9 +85452,10 @@ ${args.directive}`);
85009
85452
  pendingDirective = "The proposed fix could not be device-verified here. If it is not clearly correct, revise it.";
85010
85453
  continue;
85011
85454
  }
85455
+ const runNativeTest = options.runNativeTest;
85012
85456
  let verify;
85013
85457
  try {
85014
- verify = await options.runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
85458
+ verify = await timed("reexec", `phase1-attempt-${attempt}-device-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables }));
85015
85459
  } catch {
85016
85460
  return {
85017
85461
  passed: false,
@@ -85118,6 +85562,9 @@ ${args.directive}`);
85118
85562
  }
85119
85563
  }
85120
85564
  async function executeMobileTestWithHealing2(steps, target, ctx, options) {
85565
+ return timed("total", "heal-total", options.onProgress, () => executeMobileTestWithHealingImpl(steps, target, ctx, options));
85566
+ }
85567
+ async function executeMobileTestWithHealingImpl(steps, target, ctx, options) {
85121
85568
  const invalidAuthSetup = detectInvalidMobileAuthSetup(steps, ctx.error);
85122
85569
  if (invalidAuthSetup) {
85123
85570
  return { passed: false, verdict: "test_invalid", healReason: invalidAuthSetup };
@@ -85244,9 +85691,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85244
85691
  const candidate = partsToCandidate(parts);
85245
85692
  if (!hasIdentitySignal(candidate))
85246
85693
  return;
85247
- const first = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen)[0];
85248
- if (first)
85249
- await driver.tap(first.bounds);
85694
+ const matches = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen);
85695
+ const target = matches.find((el) => el.bounds.width > 0 && el.bounds.height > 0) ?? matches[0];
85696
+ if (target)
85697
+ await driver.tap(target.bounds);
85250
85698
  }
85251
85699
  function buildLiveUserContent(args) {
85252
85700
  const parts = [];
@@ -85311,7 +85759,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85311
85759
  options.onProgress?.(`Phase 2 pass ${pass}/${maxPasses} \u2014 observing the live screen...`);
85312
85760
  let snap;
85313
85761
  try {
85314
- snap = await liveDriver.snapshot();
85762
+ snap = await timed("device-op", `phase2-pass-${pass}-snapshot`, options.onProgress, () => liveDriver.snapshot());
85315
85763
  } catch {
85316
85764
  return {
85317
85765
  passed: false,
@@ -85337,10 +85785,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85337
85785
  ];
85338
85786
  let content;
85339
85787
  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)(() => {
85788
+ 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
85789
  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 }));
85790
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85791
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true })));
85344
85792
  content = response?.choices?.[0]?.message?.content ?? "";
85345
85793
  } catch {
85346
85794
  continue;
@@ -85372,7 +85820,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85372
85820
  const healedSteps = parsed.steps;
85373
85821
  let verify;
85374
85822
  try {
85375
- verify = await runNativeTest({ steps: healedSteps, target, testVariables });
85823
+ verify = await timed("reexec", `phase2-pass-${pass}-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables }));
85376
85824
  } catch {
85377
85825
  return {
85378
85826
  passed: false,
@@ -89226,7 +89674,7 @@ var require_kill_tree = __commonJS({
89226
89674
  var node_child_process_1 = require("child_process");
89227
89675
  var node_util_1 = require("util");
89228
89676
  var node_fs_1 = require("fs");
89229
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89677
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89230
89678
  var PS_TIMEOUT_MS = 2e3;
89231
89679
  var PS_BUFFER = 1024 * 1024;
89232
89680
  async function gracefulCloseWithTreeKill(options) {
@@ -89280,7 +89728,7 @@ var require_kill_tree = __commonJS({
89280
89728
  timer.unref?.();
89281
89729
  let stdout;
89282
89730
  try {
89283
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89731
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89284
89732
  maxBuffer: PS_BUFFER,
89285
89733
  signal: controller.signal
89286
89734
  });
@@ -89318,7 +89766,7 @@ var require_kill_tree = __commonJS({
89318
89766
  timer.unref?.();
89319
89767
  let stdout;
89320
89768
  try {
89321
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89769
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89322
89770
  maxBuffer: PS_BUFFER,
89323
89771
  signal: controller.signal
89324
89772
  });
@@ -89395,7 +89843,7 @@ var require_kill_tree = __commonJS({
89395
89843
  timer.unref?.();
89396
89844
  let stdout;
89397
89845
  try {
89398
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid="], {
89846
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid="], {
89399
89847
  maxBuffer: PS_BUFFER,
89400
89848
  signal: controller.signal
89401
89849
  });
@@ -99274,8 +99722,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99274
99722
  onRetry: (attempt, reason, delayMs) => logs2.push(` \u26A0 scenario-gen AI call retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
99275
99723
  });
99276
99724
  } catch (aiErr) {
99277
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99278
- logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg2}`);
99725
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99726
+ logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg3}`);
99279
99727
  throw (0, ai_retry_js_1.markAIProviderError)(aiErr);
99280
99728
  }
99281
99729
  const iterDuration = Date.now() - iterStart;
@@ -99318,8 +99766,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99318
99766
  try {
99319
99767
  toolArgs = JSON.parse(toolCall.function.arguments);
99320
99768
  } catch (parseErr) {
99321
- const errMsg2 = `Invalid tool arguments: ${parseErr.message}`;
99322
- toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg2 });
99769
+ const errMsg3 = `Invalid tool arguments: ${parseErr.message}`;
99770
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg3 });
99323
99771
  continue;
99324
99772
  }
99325
99773
  if (toolName === "finish_generate") {
@@ -100577,7 +101025,7 @@ var require_util4 = __commonJS({
100577
101025
  }
100578
101026
  path = url.path;
100579
101027
  }
100580
- var isAbsolute = exports2.isAbsolute(path);
101028
+ var isAbsolute2 = exports2.isAbsolute(path);
100581
101029
  var parts = path.split(/\/+/);
100582
101030
  for (var part, up = 0, i = parts.length - 1; i >= 0; i--) {
100583
101031
  part = parts[i];
@@ -100597,7 +101045,7 @@ var require_util4 = __commonJS({
100597
101045
  }
100598
101046
  path = parts.join("/");
100599
101047
  if (path === "") {
100600
- path = isAbsolute ? "/" : ".";
101048
+ path = isAbsolute2 ? "/" : ".";
100601
101049
  }
100602
101050
  if (url) {
100603
101051
  url.path = path;
@@ -100606,7 +101054,7 @@ var require_util4 = __commonJS({
100606
101054
  return path;
100607
101055
  }
100608
101056
  exports2.normalize = normalize;
100609
- function join7(aRoot, aPath) {
101057
+ function join8(aRoot, aPath) {
100610
101058
  if (aRoot === "") {
100611
101059
  aRoot = ".";
100612
101060
  }
@@ -100638,7 +101086,7 @@ var require_util4 = __commonJS({
100638
101086
  }
100639
101087
  return joined;
100640
101088
  }
100641
- exports2.join = join7;
101089
+ exports2.join = join8;
100642
101090
  exports2.isAbsolute = function(aPath) {
100643
101091
  return aPath.charAt(0) === "/" || urlRegexp.test(aPath);
100644
101092
  };
@@ -100811,7 +101259,7 @@ var require_util4 = __commonJS({
100811
101259
  parsed.path = parsed.path.substring(0, index + 1);
100812
101260
  }
100813
101261
  }
100814
- sourceURL = join7(urlGenerate(parsed), sourceURL);
101262
+ sourceURL = join8(urlGenerate(parsed), sourceURL);
100815
101263
  }
100816
101264
  return normalize(sourceURL);
100817
101265
  }
@@ -314524,8 +314972,8 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314524
314972
  ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel)
314525
314973
  })), { label: `script-heal-${attempt}` });
314526
314974
  } catch (aiErr) {
314527
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314528
- logs2.push(`\u26A0 AI call failed: ${errMsg2}`);
314975
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314976
+ logs2.push(`\u26A0 AI call failed: ${errMsg3}`);
314529
314977
  attemptHistory.push({
314530
314978
  phase: "script",
314531
314979
  attempt,
@@ -314533,13 +314981,13 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314533
314981
  passed: false,
314534
314982
  changedCode: false,
314535
314983
  summary: "AI call failed before returning a heal result",
314536
- errorSummary: truncateForPrompt(errMsg2),
314984
+ errorSummary: truncateForPrompt(errMsg3),
314537
314985
  failedStep: summarizeFailedStep(currentStepResults)
314538
314986
  });
314539
314987
  if (feedbackEnabled) {
314540
- lastTransportFailure = truncateForPrompt(errMsg2, 200) ?? "the AI request failed";
314988
+ lastTransportFailure = truncateForPrompt(errMsg3, 200) ?? "the AI request failed";
314541
314989
  } else {
314542
- currentError = `AI call failed on attempt ${attempt}: ${errMsg2}`;
314990
+ currentError = `AI call failed on attempt ${attempt}: ${errMsg3}`;
314543
314991
  }
314544
314992
  markRecoverableTurn();
314545
314993
  continue;
@@ -314823,8 +315271,8 @@ ${currentError.slice(0, 2e3)}`
314823
315271
  // item 5b
314824
315272
  });
314825
315273
  } catch (runErr) {
314826
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
314827
- logs2.push(`\u{1F4A5} Native execution threw: ${errMsg2}`);
315274
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
315275
+ logs2.push(`\u{1F4A5} Native execution threw: ${errMsg3}`);
314828
315276
  attemptHistory.push({
314829
315277
  phase: "script",
314830
315278
  attempt,
@@ -314832,9 +315280,9 @@ ${currentError.slice(0, 2e3)}`
314832
315280
  passed: false,
314833
315281
  changedCode: true,
314834
315282
  summary: truncateForPrompt(changeDesc) ?? "Applied code fix, but native execution threw before verification completed",
314835
- errorSummary: truncateForPrompt(errMsg2)
315283
+ errorSummary: truncateForPrompt(errMsg3)
314836
315284
  });
314837
- currentError = `Native execution threw: ${errMsg2}`;
315285
+ currentError = `Native execution threw: ${errMsg3}`;
314838
315286
  currentStepResults = [];
314839
315287
  continue;
314840
315288
  }
@@ -315590,9 +316038,9 @@ Last error (after heal attempts): ${lastErrorSnippet}` : `Heal aborted: same err
315590
316038
  previousErrorSignature = errorSig;
315591
316039
  }
315592
316040
  } catch (runErr) {
315593
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
315594
- logs2.push(`\u{1F4A5} Native run threw: ${errMsg2}`);
315595
- currentError = errMsg2;
316041
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
316042
+ logs2.push(`\u{1F4A5} Native run threw: ${errMsg3}`);
316043
+ currentError = errMsg3;
315596
316044
  currentStepResults = [];
315597
316045
  }
315598
316046
  } else {
@@ -316154,8 +316602,8 @@ When you call finish_fix, preserve locators only when they are stable structural
316154
316602
  }
316155
316603
  }
316156
316604
  } catch (toolErr) {
316157
- const errMsg2 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316158
- let errorContent = `Error: ${errMsg2}`;
316605
+ const errMsg3 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316606
+ let errorContent = `Error: ${errMsg3}`;
316159
316607
  try {
316160
316608
  const consoleResult = await mcpClient.callTool({ name: "browser_console_messages", arguments: {} });
316161
316609
  const consoleText = Array.isArray(consoleResult.content) ? consoleResult.content.map((c) => c.type === "text" ? c.text ?? "" : "").join("\n") : String(consoleResult.content ?? "");
@@ -316176,8 +316624,8 @@ ${errorContent}`;
316176
316624
  }
316177
316625
  }
316178
316626
  } catch (mcpErr) {
316179
- const errMsg2 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316180
- logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg2}`);
316627
+ const errMsg3 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316628
+ logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg3}`);
316181
316629
  }
316182
316630
  if (verdict === "app_bug") {
316183
316631
  phase2AttemptHistory.push({
@@ -316771,9 +317219,9 @@ ${testBlocks}
316771
317219
  }
316772
317220
  emit(` \u2713 ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
316773
317221
  } catch (err) {
316774
- const errMsg2 = err instanceof Error ? err.message : String(err);
316775
- toolResultContent = `ERROR: ${errMsg2}`;
316776
- emit(` \u2717 ${errMsg2}`);
317222
+ const errMsg3 = err instanceof Error ? err.message : String(err);
317223
+ toolResultContent = `ERROR: ${errMsg3}`;
317224
+ emit(` \u2717 ${errMsg3}`);
316777
317225
  }
316778
317226
  toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: toolResultContent });
316779
317227
  }
@@ -323057,8 +323505,8 @@ var require_capture_page_normalizer = __commonJS({
323057
323505
  const snapItems = Array.isArray(snapResult.content) ? snapResult.content : [];
323058
323506
  snapshotText = extractInlineSnapshotYaml(snapItems.find((c) => c.type === "text")?.text || null);
323059
323507
  } catch (err) {
323060
- const errMsg2 = err instanceof Error ? err.message : String(err);
323061
- const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg2);
323508
+ const errMsg3 = err instanceof Error ? err.message : String(err);
323509
+ const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg3);
323062
323510
  return {
323063
323511
  snapshotText: null,
323064
323512
  meta: {
@@ -327134,9 +327582,9 @@ ${currentSummary}`;
327134
327582
  });
327135
327583
  }
327136
327584
  } 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}` });
327585
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327586
+ logs2.push(` Error persisting page: ${errMsg3}`);
327587
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: `ERROR persisting page: ${errMsg3}` });
327140
327588
  }
327141
327589
  continue;
327142
327590
  }
@@ -327338,12 +327786,12 @@ ${currentSummary}`;
327338
327786
  })
327339
327787
  });
327340
327788
  } catch (err) {
327341
- const errMsg2 = err instanceof Error ? err.message : String(err);
327342
- logs2.push(` Error persisting page: ${errMsg2}`);
327789
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327790
+ logs2.push(` Error persisting page: ${errMsg3}`);
327343
327791
  toolResults.push({
327344
327792
  role: "tool",
327345
327793
  tool_call_id: toolCall.id,
327346
- content: `ERROR persisting page: ${errMsg2}`
327794
+ content: `ERROR persisting page: ${errMsg3}`
327347
327795
  });
327348
327796
  }
327349
327797
  continue;
@@ -327378,12 +327826,12 @@ ${currentSummary}`;
327378
327826
  content: JSON.stringify({ acknowledged: true })
327379
327827
  });
327380
327828
  } catch (err) {
327381
- const errMsg2 = err instanceof Error ? err.message : String(err);
327382
- logs2.push(` Error persisting transition: ${errMsg2}`);
327829
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327830
+ logs2.push(` Error persisting transition: ${errMsg3}`);
327383
327831
  toolResults.push({
327384
327832
  role: "tool",
327385
327833
  tool_call_id: toolCall.id,
327386
- content: `ERROR persisting transition: ${errMsg2}`
327834
+ content: `ERROR persisting transition: ${errMsg3}`
327387
327835
  });
327388
327836
  }
327389
327837
  continue;
@@ -328079,15 +328527,15 @@ Exploration complete: ${summary}`);
328079
328527
  toolResultContent = (0, snapshot_context_compactor_js_1.formatMCPToolResult)(toolResultContent, MAX_TOOL_RESULT_CHARS);
328080
328528
  logs2.push(` OK: ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
328081
328529
  } catch (err) {
328082
- const errMsg2 = err instanceof Error ? err.message : String(err);
328083
- toolResultContent = `ERROR: ${errMsg2}`;
328084
- logs2.push(` Error: ${errMsg2}`);
328530
+ const errMsg3 = err instanceof Error ? err.message : String(err);
328531
+ toolResultContent = `ERROR: ${errMsg3}`;
328532
+ logs2.push(` Error: ${errMsg3}`);
328085
328533
  if (useV2) {
328086
328534
  (0, exploration_state_js_1.reduceEvent)(state2, {
328087
328535
  type: "ACTION_FAILED",
328088
328536
  route: state2.currentRoute,
328089
328537
  action: `${toolName}(${JSON.stringify(toolArgs).slice(0, 100)})`,
328090
- error: errMsg2
328538
+ error: errMsg3
328091
328539
  });
328092
328540
  }
328093
328541
  }
@@ -330936,10 +331384,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
330936
331384
  onLog: options.onLog,
330937
331385
  onProvenCommands: recordProvenCommands
330938
331386
  });
330939
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
331387
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
330940
331388
  const fixPrompt = [
330941
331389
  "Your previous attempt did not produce a valid result file.",
330942
- `Error: ${errMsg2}`,
331390
+ `Error: ${errMsg3}`,
330943
331391
  `Re-read BRIEF.md and write a valid SurveyorOutput JSON to ${resultFilePath}.`,
330944
331392
  "Single JSON object. No prose. Then exit."
330945
331393
  ].join("\n");
@@ -333080,7 +333528,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333080
333528
  log2(`First-pass discovery-result invalid or missing: ${firstError.message}`);
333081
333529
  }
333082
333530
  if (!parsedResult) {
333083
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333531
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333084
333532
  if (planDebounceTimerRef.current) {
333085
333533
  clearTimeout(planDebounceTimerRef.current);
333086
333534
  planDebounceTimerRef.current = void 0;
@@ -333105,7 +333553,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333105
333553
  ""
333106
333554
  ].join("\n");
333107
333555
  const fixPrompt = [
333108
- `Your previous attempt did not produce a valid result file. Error: ${errMsg2}`,
333556
+ `Your previous attempt did not produce a valid result file. Error: ${errMsg3}`,
333109
333557
  "Re-read BRIEF.md.",
333110
333558
  phaseContext,
333111
333559
  `Continue from where you left off \u2014 already verified tests are in ${testsDirPath}/`,
@@ -334734,10 +335182,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
334734
335182
  onLog: parserLog,
334735
335183
  onProvenCommands: recordProvenCommands
334736
335184
  });
334737
- const errMsg2 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
335185
+ const errMsg3 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
334738
335186
  const fixPrompt = [
334739
335187
  "Your previous attempt did not leave a valid result file.",
334740
- `Error: ${errMsg2}`,
335188
+ `Error: ${errMsg3}`,
334741
335189
  `Writing a valid SurveyorOutput JSON object to ${resultFilePath} is your ONLY priority now.`,
334742
335190
  "Procedure: navigate to at most 6-8 of the main pages, then IMMEDIATELY use",
334743
335191
  `the Write tool to save the SurveyorOutput JSON to ${resultFilePath}. Do this`,
@@ -338618,8 +339066,8 @@ var require_resource_governor = __commonJS({
338618
339066
  function isDiscoveryLike(mode) {
338619
339067
  return mode === "discovery";
338620
339068
  }
338621
- function evaluateRunAdmission2(options, request) {
338622
- const snapshot = getResourceSnapshot2();
339069
+ function evaluateRunAdmission2(options, request, snapshotOverride) {
339070
+ const snapshot = snapshotOverride ?? getResourceSnapshot2();
338623
339071
  const candidateWeight = modeWeight(request.mode, options.weights);
338624
339072
  const currentUnits = activeUnits(request.activeByType, options.weights);
338625
339073
  const memory = pressureFactor(snapshot.memoryPct, options.memorySoftPct, options.memoryHardPct);
@@ -341117,8 +341565,8 @@ var require_adm_zip = __commonJS({
341117
341565
  return null;
341118
341566
  }
341119
341567
  function fixPath(zipPath) {
341120
- const { join: join7, normalize, sep } = pth.posix;
341121
- return join7(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341568
+ const { join: join8, normalize, sep } = pth.posix;
341569
+ return join8(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341122
341570
  }
341123
341571
  function filenameFilter(filterfn) {
341124
341572
  if (filterfn instanceof RegExp) {
@@ -343953,11 +344401,11 @@ var require_winchars = __commonJS({
343953
344401
  var require_strip_absolute_path = __commonJS({
343954
344402
  "../../node_modules/tar/lib/strip-absolute-path.js"(exports2, module2) {
343955
344403
  "use strict";
343956
- var { isAbsolute, parse } = require("path").win32;
344404
+ var { isAbsolute: isAbsolute2, parse } = require("path").win32;
343957
344405
  module2.exports = (path) => {
343958
344406
  let r = "";
343959
344407
  let parsed = parse(path);
343960
- while (isAbsolute(path) || parsed.root) {
344408
+ while (isAbsolute2(path) || parsed.root) {
343961
344409
  const root = path.charAt(0) === "/" && path.slice(0, 4) !== "//?/" ? "/" : parsed.root;
343962
344410
  path = path.slice(root.length);
343963
344411
  r += root;
@@ -346469,7 +346917,7 @@ var require_update = __commonJS({
346469
346917
  var require_opts_arg = __commonJS({
346470
346918
  "../../node_modules/tar/node_modules/mkdirp/lib/opts-arg.js"(exports2, module2) {
346471
346919
  "use strict";
346472
- var { promisify: promisify3 } = require("util");
346920
+ var { promisify: promisify4 } = require("util");
346473
346921
  var fs = require("fs");
346474
346922
  var optsArg = (opts) => {
346475
346923
  if (!opts)
@@ -346483,9 +346931,9 @@ var require_opts_arg = __commonJS({
346483
346931
  else
346484
346932
  throw new TypeError("invalid options argument");
346485
346933
  opts.mkdir = opts.mkdir || opts.fs.mkdir || fs.mkdir;
346486
- opts.mkdirAsync = promisify3(opts.mkdir);
346934
+ opts.mkdirAsync = promisify4(opts.mkdir);
346487
346935
  opts.stat = opts.stat || opts.fs.stat || fs.stat;
346488
- opts.statAsync = promisify3(opts.stat);
346936
+ opts.statAsync = promisify4(opts.stat);
346489
346937
  opts.statSync = opts.statSync || opts.fs.statSync || fs.statSync;
346490
346938
  opts.mkdirSync = opts.mkdirSync || opts.fs.mkdirSync || fs.mkdirSync;
346491
346939
  return opts;
@@ -346738,10 +347186,10 @@ var require_chownr = __commonJS({
346738
347186
  }
346739
347187
  } : (path2, uid, gid) => lchownSync(path2, uid, gid);
346740
347188
  var nodeVersion = process.version;
346741
- var readdir2 = (path2, options, cb) => fs.readdir(path2, options, cb);
347189
+ var readdir3 = (path2, options, cb) => fs.readdir(path2, options, cb);
346742
347190
  var readdirSync = (path2, options) => fs.readdirSync(path2, options);
346743
347191
  if (/^v4\./.test(nodeVersion))
346744
- readdir2 = (path2, options, cb) => fs.readdir(path2, cb);
347192
+ readdir3 = (path2, options, cb) => fs.readdir(path2, cb);
346745
347193
  var chown = (cpath, uid, gid, cb) => {
346746
347194
  fs[LCHOWN](cpath, uid, gid, handleEISDIR(cpath, uid, gid, (er) => {
346747
347195
  cb(er && er.code !== "ENOENT" ? er : null);
@@ -346768,7 +347216,7 @@ var require_chownr = __commonJS({
346768
347216
  }
346769
347217
  };
346770
347218
  var chownr = (p, uid, gid, cb) => {
346771
- readdir2(p, { withFileTypes: true }, (er, children) => {
347219
+ readdir3(p, { withFileTypes: true }, (er, children) => {
346772
347220
  if (er) {
346773
347221
  if (er.code === "ENOENT")
346774
347222
  return cb();
@@ -347042,7 +347490,7 @@ var require_path_reservations = __commonJS({
347042
347490
  var assert = require("assert");
347043
347491
  var normalize = require_normalize_unicode();
347044
347492
  var stripSlashes = require_strip_trailing_slashes();
347045
- var { join: join7 } = require("path");
347493
+ var { join: join8 } = require("path");
347046
347494
  var platform3 = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform;
347047
347495
  var isWindows = platform3 === "win32";
347048
347496
  module2.exports = () => {
@@ -347051,7 +347499,7 @@ var require_path_reservations = __commonJS({
347051
347499
  const getDirs = (path) => {
347052
347500
  const dirs = path.split("/").slice(0, -1).reduce((set, path2) => {
347053
347501
  if (set.length) {
347054
- path2 = join7(set[set.length - 1], path2);
347502
+ path2 = join8(set[set.length - 1], path2);
347055
347503
  }
347056
347504
  set.push(path2 || "/");
347057
347505
  return set;
@@ -347119,7 +347567,7 @@ var require_path_reservations = __commonJS({
347119
347567
  };
347120
347568
  const reserve = (paths, fn) => {
347121
347569
  paths = isWindows ? ["win32 parallelization disabled"] : paths.map((p) => {
347122
- return stripSlashes(join7(normalize(p))).toLowerCase();
347570
+ return stripSlashes(join8(normalize(p))).toLowerCase();
347123
347571
  });
347124
347572
  const dirs = new Set(
347125
347573
  paths.map((path) => getDirs(path)).reduce((a, b) => a.concat(b))
@@ -347174,7 +347622,7 @@ var require_unpack = __commonJS({
347174
347622
  var fs = require("fs");
347175
347623
  var fsm = require_fs_minipass();
347176
347624
  var path = require("path");
347177
- var mkdir2 = require_mkdir();
347625
+ var mkdir3 = require_mkdir();
347178
347626
  var wc = require_winchars();
347179
347627
  var pathReservations = require_path_reservations();
347180
347628
  var stripAbsolutePath = require_strip_absolute_path();
@@ -347425,7 +347873,7 @@ var require_unpack = __commonJS({
347425
347873
  }
347426
347874
  }
347427
347875
  [MKDIR](dir, mode, cb) {
347428
- mkdir2(normPath(dir), {
347876
+ mkdir3(normPath(dir), {
347429
347877
  uid: this.uid,
347430
347878
  gid: this.gid,
347431
347879
  processUid: this.processUid,
@@ -347828,7 +348276,7 @@ var require_unpack = __commonJS({
347828
348276
  }
347829
348277
  [MKDIR](dir, mode) {
347830
348278
  try {
347831
- return mkdir2.sync(normPath(dir), {
348279
+ return mkdir3.sync(normPath(dir), {
347832
348280
  uid: this.uid,
347833
348281
  gid: this.gid,
347834
348282
  processUid: this.processUid,
@@ -351334,7 +351782,7 @@ var require_process_inventory = __commonJS({
351334
351782
  var node_child_process_1 = require("child_process");
351335
351783
  var node_os_1 = __importDefault(require("os"));
351336
351784
  var node_util_1 = require("util");
351337
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351785
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351338
351786
  var DEFAULT_LIMIT = 60;
351339
351787
  var DEFAULT_TIMEOUT_MS2 = 800;
351340
351788
  var MAX_COMMAND_LENGTH = 320;
@@ -351396,7 +351844,7 @@ var require_process_inventory = __commonJS({
351396
351844
  const timer = setTimeout(() => controller.abort(), timeoutMs);
351397
351845
  timer.unref?.();
351398
351846
  try {
351399
- const { stdout } = await execFileAsync3(command, args, {
351847
+ const { stdout } = await execFileAsync4(command, args, {
351400
351848
  maxBuffer: 1024 * 1024,
351401
351849
  signal: controller.signal
351402
351850
  });
@@ -352542,7 +352990,7 @@ var require_package4 = __commonJS({
352542
352990
  "package.json"(exports2, module2) {
352543
352991
  module2.exports = {
352544
352992
  name: "@validate.qa/runner",
352545
- version: "1.0.70",
352993
+ version: "1.0.71",
352546
352994
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
352547
352995
  bin: {
352548
352996
  "validate-runner": "dist/cli.js"
@@ -352551,7 +352999,7 @@ var require_package4 = __commonJS({
352551
352999
  build: "tsup",
352552
353000
  dev: "tsx src/cli.ts",
352553
353001
  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",
353002
+ "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
353003
  prepublishOnly: "npm run build"
352556
353004
  },
352557
353005
  files: [
@@ -352629,7 +353077,7 @@ function getConfigDir() {
352629
353077
  }
352630
353078
 
352631
353079
  // src/runner.ts
352632
- var import_path4 = require("path");
353080
+ var import_path5 = require("path");
352633
353081
  var import_os6 = require("os");
352634
353082
  var import_node_crypto2 = require("crypto");
352635
353083
 
@@ -353224,6 +353672,69 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
353224
353672
  };
353225
353673
  }
353226
353674
 
353675
+ // src/services/mobile/ios-simulator-reset.ts
353676
+ var import_child_process2 = require("child_process");
353677
+ var import_util = require("util");
353678
+ var import_promises = require("fs/promises");
353679
+ var import_path3 = require("path");
353680
+ var execFileAsync = (0, import_util.promisify)(import_child_process2.execFile);
353681
+ var SIMCTL_TIMEOUT_MS = 15e3;
353682
+ 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;
353683
+ function isIosSimulatorUdid(udid) {
353684
+ return typeof udid === "string" && SIMULATOR_UDID_RE.test(udid.trim());
353685
+ }
353686
+ function errMsg(err) {
353687
+ return err instanceof Error ? err.message : String(err);
353688
+ }
353689
+ async function simctl(args) {
353690
+ const { stdout } = await execFileAsync("xcrun", ["simctl", ...args], {
353691
+ timeout: SIMCTL_TIMEOUT_MS,
353692
+ encoding: "utf-8"
353693
+ });
353694
+ return stdout.trim();
353695
+ }
353696
+ async function clearIosSimulatorAppData(udid, bundleId, env = process.env) {
353697
+ if (!isIosSimulatorUdid(udid)) {
353698
+ return { ok: false, detail: `'${udid}' is not a simulator UDID \u2014 physical iOS devices cannot be cleared` };
353699
+ }
353700
+ let container;
353701
+ try {
353702
+ container = await simctl(["get_app_container", udid, bundleId, "data"]);
353703
+ } catch (err) {
353704
+ return { ok: false, detail: `simctl get_app_container failed (app not installed?): ${errMsg(err)}` };
353705
+ }
353706
+ if (!(0, import_path3.isAbsolute)(container) || !container.includes("/Containers/Data/Application/")) {
353707
+ return { ok: false, detail: `unexpected app container path: ${container}` };
353708
+ }
353709
+ try {
353710
+ await simctl(["spawn", udid, "defaults", "delete", bundleId]);
353711
+ } catch {
353712
+ }
353713
+ try {
353714
+ const entries = await (0, import_promises.readdir)(container);
353715
+ for (const entry of entries) {
353716
+ await (0, import_promises.rm)((0, import_path3.join)(container, entry), { recursive: true, force: true });
353717
+ }
353718
+ for (const dir of ["Documents", "Library/Caches", "Library/Preferences", "tmp"]) {
353719
+ await (0, import_promises.mkdir)((0, import_path3.join)(container, dir), { recursive: true });
353720
+ }
353721
+ } catch (err) {
353722
+ return { ok: false, detail: `app container wipe failed: ${errMsg(err)}` };
353723
+ }
353724
+ const keychainPolicy = (env.VALIDATEQA_MOBILE_IOS_KEYCHAIN_RESET ?? "").trim().toLowerCase();
353725
+ if (keychainPolicy !== "never") {
353726
+ try {
353727
+ await simctl(["keychain", udid, "reset"]);
353728
+ } catch (err) {
353729
+ return {
353730
+ ok: false,
353731
+ 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.`
353732
+ };
353733
+ }
353734
+ }
353735
+ return { ok: true };
353736
+ }
353737
+
353227
353738
  // src/services/mobile/appium-actions.ts
353228
353739
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
353229
353740
  var APPIUM_WEBVIEW_CONTEXT_TIMEOUT_MS = 12e4;
@@ -353406,9 +353917,12 @@ async function tapAt(sessionPath, bounds) {
353406
353917
  }
353407
353918
  }
353408
353919
  function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
353409
- if (platform3 !== "ANDROID") return false;
353410
353920
  const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
353411
353921
  if (policy === "never") return false;
353922
+ if (platform3 === "IOS") {
353923
+ return isIosSimulatorUdid(deviceId);
353924
+ }
353925
+ if (platform3 !== "ANDROID") return false;
353412
353926
  if (policy === "always") return true;
353413
353927
  return !!deviceId && deviceId.startsWith("emulator-");
353414
353928
  }
@@ -354287,6 +354801,31 @@ async function pressHome(sessionPath, platform3) {
354287
354801
  });
354288
354802
  }
354289
354803
 
354804
+ // src/services/device-quarantine.ts
354805
+ var quarantinedDevices = /* @__PURE__ */ new Set();
354806
+ var DeviceQuarantinedError = class extends Error {
354807
+ constructor(deviceId) {
354808
+ super(
354809
+ `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.`
354810
+ );
354811
+ this.name = "DeviceQuarantinedError";
354812
+ }
354813
+ };
354814
+ function quarantineDevice(deviceId) {
354815
+ quarantinedDevices.add(deviceId);
354816
+ }
354817
+ function unquarantineDevice(deviceId) {
354818
+ quarantinedDevices.delete(deviceId);
354819
+ }
354820
+ function isDeviceQuarantined(deviceId) {
354821
+ return typeof deviceId === "string" && quarantinedDevices.has(deviceId);
354822
+ }
354823
+ function assertDeviceNotQuarantined(deviceId) {
354824
+ if (isDeviceQuarantined(deviceId)) {
354825
+ throw new DeviceQuarantinedError(deviceId);
354826
+ }
354827
+ }
354828
+
354290
354829
  // src/services/appium-executor.ts
354291
354830
  var import_node_child_process2 = require("child_process");
354292
354831
  var import_node_util2 = require("util");
@@ -354295,11 +354834,11 @@ var import_node_path2 = require("path");
354295
354834
  var import_runner_core8 = __toESM(require_dist2());
354296
354835
 
354297
354836
  // src/services/device-discovery.ts
354298
- var import_child_process2 = require("child_process");
354837
+ var import_child_process3 = require("child_process");
354299
354838
  var import_os3 = require("os");
354300
354839
  function runCommand(command, args, timeout = 1e4) {
354301
354840
  try {
354302
- return (0, import_child_process2.execFileSync)(command, args, {
354841
+ return (0, import_child_process3.execFileSync)(command, args, {
354303
354842
  encoding: "utf-8",
354304
354843
  timeout,
354305
354844
  stdio: ["pipe", "pipe", "pipe"]
@@ -354599,9 +355138,6 @@ function countReadyDevicesByKind(platform3, snapshot = getCachedDeviceSnapshot()
354599
355138
  function selectFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(), requirements = {}) {
354600
355139
  const ready = snapshot.filter((d) => d.platform === platform3 && d.isAvailable && d.state === "BOOTED");
354601
355140
  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
355141
  let candidates = requirements.resetPolicy === "CLEAR_APP_DATA" ? ready.filter((d) => !d.isPhysical) : [...ready];
354606
355142
  const appId = requirements.appId?.trim();
354607
355143
  if (appId) {
@@ -354651,13 +355187,13 @@ function pickFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(),
354651
355187
  // src/services/mobile/mobile-network-capture.ts
354652
355188
  var import_node_child_process = require("child_process");
354653
355189
  var import_node_crypto = require("crypto");
354654
- var import_promises = require("fs/promises");
355190
+ var import_promises2 = require("fs/promises");
354655
355191
  var import_node_os = require("os");
354656
355192
  var import_node_path = require("path");
354657
355193
  var import_node_util = require("util");
354658
355194
  var import_mockttp = require("mockttp");
354659
355195
  var import_runner_core7 = __toESM(require_dist2());
354660
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
355196
+ var execFileAsync2 = (0, import_node_util.promisify)(import_node_child_process.execFile);
354661
355197
  var MAX_CALLS = 1e3;
354662
355198
  var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
354663
355199
  var DEVICE_CMD_TIMEOUT_MS = 15e3;
@@ -354845,7 +355381,7 @@ var MitmProxy = class {
354845
355381
  try {
354846
355382
  await server3.stop();
354847
355383
  } catch (err) {
354848
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
355384
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg2(err)}`);
354849
355385
  }
354850
355386
  this.onLog?.(
354851
355387
  `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
@@ -354870,7 +355406,7 @@ var MitmProxy = class {
354870
355406
  isHttps
354871
355407
  });
354872
355408
  } catch (err) {
354873
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
355409
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg2(err)}`);
354874
355410
  }
354875
355411
  }
354876
355412
  // ── response → finalize the matched request ───────────────────────────────
@@ -354913,7 +355449,7 @@ var MitmProxy = class {
354913
355449
  intercepted: true
354914
355450
  });
354915
355451
  } catch (err) {
354916
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
355452
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg2(err)}`);
354917
355453
  }
354918
355454
  }
354919
355455
  // ── tls-client-error → record WHY interception failed ─────────────────────
@@ -354991,16 +355527,16 @@ async function generateCaCert(dir, onLog) {
354991
355527
  // rather than on a short expiry.
354992
355528
  });
354993
355529
  const caCertPath = (0, import_node_path.join)(dir, "validateqa-mobile-ca.pem");
354994
- await (0, import_promises.writeFile)(caCertPath, cert, "utf-8");
355530
+ await (0, import_promises2.writeFile)(caCertPath, cert, "utf-8");
354995
355531
  return { caCertPath, caKeyPem: key, caCertPem: cert };
354996
355532
  } catch (err) {
354997
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
355533
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg2(err)}`);
354998
355534
  return null;
354999
355535
  }
355000
355536
  }
355001
355537
  async function androidReadProxy(deviceId) {
355002
355538
  try {
355003
- const { stdout } = await execFileAsync(
355539
+ const { stdout } = await execFileAsync2(
355004
355540
  "adb",
355005
355541
  ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
355006
355542
  { timeout: DEVICE_CMD_TIMEOUT_MS }
@@ -355013,33 +355549,33 @@ async function androidReadProxy(deviceId) {
355013
355549
  }
355014
355550
  async function androidSetProxy(deviceId, hostPort, onLog) {
355015
355551
  try {
355016
- await execFileAsync(
355552
+ await execFileAsync2(
355017
355553
  "adb",
355018
355554
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
355019
355555
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355020
355556
  );
355021
355557
  return true;
355022
355558
  } catch (err) {
355023
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
355559
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg2(err)}`);
355024
355560
  return false;
355025
355561
  }
355026
355562
  }
355027
355563
  async function androidClearProxy(deviceId, originalProxy, onLog) {
355028
355564
  const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
355029
355565
  try {
355030
- await execFileAsync(
355566
+ await execFileAsync2(
355031
355567
  "adb",
355032
355568
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
355033
355569
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355034
355570
  );
355035
355571
  } catch (err) {
355036
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
355572
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg2(err)}`);
355037
355573
  }
355038
355574
  }
355039
355575
  async function androidInstallCa(deviceId, caCertPath) {
355040
355576
  const devicePath = "/sdcard/validateqa-mobile-ca.pem";
355041
355577
  try {
355042
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355578
+ await execFileAsync2("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355043
355579
  timeout: DEVICE_CMD_TIMEOUT_MS
355044
355580
  });
355045
355581
  return {
@@ -355047,23 +355583,23 @@ async function androidInstallCa(deviceId, caCertPath) {
355047
355583
  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
355584
  };
355049
355585
  } catch (err) {
355050
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
355586
+ return { installed: false, note: `CA push failed: ${errMsg2(err)}` };
355051
355587
  }
355052
355588
  }
355053
355589
  async function androidRemoveCa(deviceId, onLog) {
355054
355590
  try {
355055
- await execFileAsync(
355591
+ await execFileAsync2(
355056
355592
  "adb",
355057
355593
  ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
355058
355594
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355059
355595
  );
355060
355596
  } catch (err) {
355061
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
355597
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg2(err)}`);
355062
355598
  }
355063
355599
  }
355064
355600
  async function iosSimTrustCa(deviceId, caCertPath) {
355065
355601
  try {
355066
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355602
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355067
355603
  timeout: DEVICE_CMD_TIMEOUT_MS
355068
355604
  });
355069
355605
  return {
@@ -355071,18 +355607,18 @@ async function iosSimTrustCa(deviceId, caCertPath) {
355071
355607
  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
355608
  };
355073
355609
  } catch (err) {
355074
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
355610
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg2(err)}` };
355075
355611
  }
355076
355612
  }
355077
355613
  async function iosSimRemoveCa(deviceId, onLog) {
355078
355614
  if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
355079
355615
  try {
355080
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355616
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355081
355617
  timeout: DEVICE_CMD_TIMEOUT_MS
355082
355618
  });
355083
355619
  onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
355084
355620
  } catch (err) {
355085
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
355621
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg2(err)}`);
355086
355622
  }
355087
355623
  return;
355088
355624
  }
@@ -355090,7 +355626,7 @@ async function iosSimRemoveCa(deviceId, onLog) {
355090
355626
  `[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
355627
  );
355092
355628
  }
355093
- function errMsg(err) {
355629
+ function errMsg2(err) {
355094
355630
  return err instanceof Error ? err.message : String(err);
355095
355631
  }
355096
355632
  function isProcessAlive(pid) {
@@ -355181,7 +355717,7 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355181
355717
  let service;
355182
355718
  let prior;
355183
355719
  try {
355184
- const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
355720
+ const { stdout: order } = await execFileAsync2("networksetup", ["-listnetworkserviceorder"], {
355185
355721
  timeout: DEVICE_CMD_TIMEOUT_MS
355186
355722
  });
355187
355723
  const svc = parsePrimaryNetworkService(order);
@@ -355190,26 +355726,26 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355190
355726
  }
355191
355727
  service = svc;
355192
355728
  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 })
355729
+ execFileAsync2("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
355730
+ execFileAsync2("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
355195
355731
  ]);
355196
355732
  prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
355197
355733
  for (const args of buildHostProxySetCommands(service, host, port)) {
355198
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355734
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355199
355735
  }
355200
355736
  onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
355201
355737
  return { configured: true, service, prior };
355202
355738
  } catch (err) {
355203
355739
  if (service && prior) {
355204
- onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
355740
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg2(err)}`);
355205
355741
  for (const args of buildHostProxyRestoreCommands(service, prior)) {
355206
355742
  try {
355207
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355743
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355208
355744
  } catch {
355209
355745
  }
355210
355746
  }
355211
355747
  }
355212
- return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
355748
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg2(err)}` };
355213
355749
  }
355214
355750
  }
355215
355751
  async function iosSimRestoreHostProxy(service, prior, onLog) {
@@ -355219,9 +355755,9 @@ async function iosSimRestoreHostProxy(service, prior, onLog) {
355219
355755
  };
355220
355756
  for (const args of buildHostProxyRestoreCommands(service, restore)) {
355221
355757
  try {
355222
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355758
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355223
355759
  } catch (err) {
355224
- onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
355760
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg2(err)}`);
355225
355761
  }
355226
355762
  }
355227
355763
  onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
@@ -355233,14 +355769,14 @@ function markerPath(sessionKey) {
355233
355769
  }
355234
355770
  async function writePendingCleanup(sessionKey, marker) {
355235
355771
  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");
355772
+ await (0, import_promises2.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
355773
+ await (0, import_promises2.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
355238
355774
  } catch {
355239
355775
  }
355240
355776
  }
355241
355777
  async function removePendingCleanup(sessionKey) {
355242
355778
  try {
355243
- await (0, import_promises.unlink)(markerPath(sessionKey));
355779
+ await (0, import_promises2.unlink)(markerPath(sessionKey));
355244
355780
  } catch {
355245
355781
  }
355246
355782
  }
@@ -355273,7 +355809,7 @@ function restoreDeviceSync(marker, onLog) {
355273
355809
  async function reconcilePendingCleanups(onLog) {
355274
355810
  let files;
355275
355811
  try {
355276
- files = await (0, import_promises.readdir)(PENDING_CLEANUP_DIR);
355812
+ files = await (0, import_promises2.readdir)(PENDING_CLEANUP_DIR);
355277
355813
  } catch {
355278
355814
  return;
355279
355815
  }
@@ -355283,7 +355819,7 @@ async function reconcilePendingCleanups(onLog) {
355283
355819
  if (activeCleanups.has(sessionKey)) continue;
355284
355820
  const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
355285
355821
  try {
355286
- const raw = await (0, import_promises.readFile)(full, "utf-8");
355822
+ const raw = await (0, import_promises2.readFile)(full, "utf-8");
355287
355823
  const marker = JSON.parse(raw);
355288
355824
  if (marker && typeof marker.deviceId === "string" && marker.platform) {
355289
355825
  if (!shouldReconcilePendingCleanup(marker)) {
@@ -355296,7 +355832,7 @@ async function reconcilePendingCleanups(onLog) {
355296
355832
  } catch {
355297
355833
  }
355298
355834
  try {
355299
- await (0, import_promises.unlink)(full);
355835
+ await (0, import_promises2.unlink)(full);
355300
355836
  } catch {
355301
355837
  }
355302
355838
  }
@@ -355334,7 +355870,7 @@ async function startMobileNetworkCapture(options) {
355334
355870
  let caKeyPem = null;
355335
355871
  let caCertPem = null;
355336
355872
  try {
355337
- tmpDir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
355873
+ tmpDir = await (0, import_promises2.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
355338
355874
  const ca = await generateCaCert(tmpDir, onLog);
355339
355875
  if (ca) {
355340
355876
  caCertPath = ca.caCertPath;
@@ -355342,7 +355878,7 @@ async function startMobileNetworkCapture(options) {
355342
355878
  caCertPem = ca.caCertPem;
355343
355879
  }
355344
355880
  } catch (err) {
355345
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
355881
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg2(err)}`);
355346
355882
  }
355347
355883
  if (!caKeyPem || !caCertPem) {
355348
355884
  try {
@@ -355350,9 +355886,9 @@ async function startMobileNetworkCapture(options) {
355350
355886
  caKeyPem = fallback.key;
355351
355887
  caCertPem = fallback.cert;
355352
355888
  } catch (err) {
355353
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
355889
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg2(err)}`);
355354
355890
  if (tmpDir) {
355355
- await (0, import_promises.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355891
+ await (0, import_promises2.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355356
355892
  }
355357
355893
  throw err instanceof Error ? err : new Error(String(err));
355358
355894
  }
@@ -355407,7 +355943,7 @@ async function startMobileNetworkCapture(options) {
355407
355943
  }
355408
355944
  }
355409
355945
  } catch (err) {
355410
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
355946
+ wiringReason = `device wiring degraded: ${errMsg2(err)}`;
355411
355947
  onLog?.(`[mobile-net] ${wiringReason}`);
355412
355948
  }
355413
355949
  if (proxySet || caInstalledOnDevice || hostProxySet) {
@@ -355496,9 +356032,9 @@ async function startMobileNetworkCapture(options) {
355496
356032
  await proxy.close();
355497
356033
  if (capturedTmpDir) {
355498
356034
  try {
355499
- await (0, import_promises.rm)(capturedTmpDir, { recursive: true, force: true });
356035
+ await (0, import_promises2.rm)(capturedTmpDir, { recursive: true, force: true });
355500
356036
  } catch (err) {
355501
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
356037
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg2(err)}`);
355502
356038
  }
355503
356039
  }
355504
356040
  const apiCount = finalCalls.filter((c) => c.isApi).length;
@@ -355531,11 +356067,11 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
355531
356067
  })();
355532
356068
  var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
355533
356069
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
355534
- var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
356070
+ var WD_SESSION_CREATE_TIMEOUT_MS = Number(process.env.VALIDATEQA_WD_SESSION_CREATE_TIMEOUT_MS) || 3e5;
355535
356071
  var WD_ACTION_TIMEOUT_MS = 3e4;
355536
356072
  var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
355537
356073
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
355538
- var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
356074
+ var execFileAsync3 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
355539
356075
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
355540
356076
  var WebDriverTransportError = class extends Error {
355541
356077
  isTransport = true;
@@ -355547,6 +356083,13 @@ var WebDriverTransportError = class extends Error {
355547
356083
  function isTransportError(err) {
355548
356084
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
355549
356085
  }
356086
+ var MobileAmbiguousMatchError = class extends Error {
356087
+ isAmbiguousMatch = true;
356088
+ constructor(message) {
356089
+ super(message);
356090
+ this.name = "MobileAmbiguousMatchError";
356091
+ }
356092
+ };
355550
356093
  function crossPlatformRunReadinessError(steps, authoringPlatform, runPlatform) {
355551
356094
  if (!authoringPlatform || authoringPlatform === runPlatform) return null;
355552
356095
  const missing = stepsMissingPlatformLocators(steps, runPlatform);
@@ -355692,6 +356235,7 @@ function androidSystemPortForwardRemovalArgs(target, error2, env = process.env)
355692
356235
  return ["-s", deviceId, "forward", "--remove", `tcp:${systemPort}`];
355693
356236
  }
355694
356237
  async function createSession(target) {
356238
+ assertDeviceNotQuarantined(target.deviceId);
355695
356239
  const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
355696
356240
  const parallelCaps = mobileParallelSessionCapabilities(
355697
356241
  target.platform,
@@ -355713,6 +356257,13 @@ async function createSession(target) {
355713
356257
  // whole survey. The runner owns session teardown explicitly, so a long
355714
356258
  // idle window is safe. Env-overridable.
355715
356259
  "appium:newCommandTimeout": MOBILE_NEW_COMMAND_TIMEOUT_SECONDS,
356260
+ // Xcode 27+ relocates Simulator.app out of <DEVELOPER_DIR>/Applications, so
356261
+ // XCUITest's default "open Simulator UI" step fails ("Simulator.app does not
356262
+ // exist") on this toolchain. The simulator is already booted and driven
356263
+ // headlessly via CoreSimulator, so tell XCUITest to skip opening the GUI.
356264
+ // Scoped to simulator UDIDs (a physical iOS device has no such step);
356265
+ // override with APPIUM_IOS_HEADLESS=false to force a visible Simulator run.
356266
+ ...isIosSimulatorUdid(target.deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
355716
356267
  ...parallelCaps,
355717
356268
  ...realCaps
355718
356269
  } : {
@@ -355752,7 +356303,7 @@ async function removeStaleAndroidSystemPortForward(target, error2) {
355752
356303
  const args = androidSystemPortForwardRemovalArgs(target, error2);
355753
356304
  if (!args) return false;
355754
356305
  try {
355755
- await execFileAsync2("adb", args, { timeout: 1e4, windowsHide: true });
356306
+ await execFileAsync3("adb", args, { timeout: 1e4, windowsHide: true });
355756
356307
  return true;
355757
356308
  } catch {
355758
356309
  return false;
@@ -355919,6 +356470,9 @@ async function deleteSession(sessionId) {
355919
356470
  } catch {
355920
356471
  }
355921
356472
  }
356473
+ async function deleteAppiumSession(sessionId) {
356474
+ await deleteSession(sessionId);
356475
+ }
355922
356476
  async function takeScreenshot(sessionId) {
355923
356477
  try {
355924
356478
  const result = await wdRequest(`/session/${sessionId}/screenshot`);
@@ -355990,10 +356544,9 @@ async function stopScreenRecording(sessionId, log2) {
355990
356544
  return void 0;
355991
356545
  }
355992
356546
  }
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;
356547
+ function parseMobileBoundsLike(value) {
356548
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.x === "number" && typeof value.y === "number" && typeof value.width === "number" && typeof value.height === "number") {
356549
+ const parsed = value;
355997
356550
  if ([parsed.x, parsed.y, parsed.width, parsed.height].every(Number.isFinite)) {
355998
356551
  return {
355999
356552
  x: parsed.x,
@@ -356005,6 +356558,9 @@ function boundsFromStep(step) {
356005
356558
  }
356006
356559
  return null;
356007
356560
  }
356561
+ function boundsFromStep(step) {
356562
+ return parseMobileBoundsLike(step.metadata?.bounds);
356563
+ }
356008
356564
  function boundsForRun(step, platform3, isCrossPlatform) {
356009
356565
  if (isCrossPlatform) return null;
356010
356566
  if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
@@ -356156,6 +356712,131 @@ async function revealLikelyOffscreenElement(sessionId) {
356156
356712
  });
356157
356713
  await new Promise((resolve2) => setTimeout(resolve2, 250));
356158
356714
  }
356715
+ function strictMatchMode() {
356716
+ const raw = (process.env.VALIDATEQA_MOBILE_STRICT_MATCH ?? "").trim().toLowerCase();
356717
+ if (raw === "strict") return "strict";
356718
+ if (raw === "off") return "off";
356719
+ return "lenient";
356720
+ }
356721
+ var MOBILE_MAX_DISAMBIG_CANDIDATES = 20;
356722
+ var COINCIDENT_CENTER_EPSILON_PX = 2;
356723
+ function isRealElementRect(rect) {
356724
+ return rect.width > 1 && rect.height > 1;
356725
+ }
356726
+ function rectContainsPoint(rect, x, y) {
356727
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
356728
+ }
356729
+ function rectCenter(rect) {
356730
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
356731
+ }
356732
+ function centerDistance(rect, point) {
356733
+ const center = rectCenter(rect);
356734
+ return Math.hypot(center.x - point.x, center.y - point.y);
356735
+ }
356736
+ function chooseRecordedMatch(candidates, opts) {
356737
+ if (candidates.length === 0) return { kind: "ambiguous", count: 0 };
356738
+ if (candidates.length === 1) {
356739
+ return { kind: "matched", elementId: candidates[0].elementId, reason: "single" };
356740
+ }
356741
+ if (typeof opts.matchIndex === "number" && Number.isInteger(opts.matchIndex) && opts.matchIndex >= 0 && opts.matchIndex < candidates.length && typeof opts.expectedMatchCount === "number" && opts.expectedMatchCount === candidates.length) {
356742
+ return { kind: "matched", elementId: candidates[opts.matchIndex].elementId, reason: "matchIndex" };
356743
+ }
356744
+ {
356745
+ const withRect = candidates.filter(
356746
+ (candidate) => candidate.rect != null
356747
+ );
356748
+ if (withRect.length === candidates.length) {
356749
+ const real = withRect.filter((candidate) => isRealElementRect(candidate.rect));
356750
+ if (real.length === 1) {
356751
+ return { kind: "matched", elementId: real[0].elementId, reason: "visible" };
356752
+ }
356753
+ const centers = withRect.map((candidate) => rectCenter(candidate.rect));
356754
+ 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);
356755
+ if (coincident) {
356756
+ return { kind: "matched", elementId: withRect[0].elementId, reason: "coincident" };
356757
+ }
356758
+ }
356759
+ }
356760
+ if (opts.recordedBounds) {
356761
+ const bounds = opts.recordedBounds;
356762
+ const center = rectCenter(bounds);
356763
+ const withRect = candidates.filter(
356764
+ (candidate) => candidate.rect != null
356765
+ );
356766
+ const containing = withRect.filter((candidate) => rectContainsPoint(candidate.rect, center.x, center.y));
356767
+ if (containing.length > 0) {
356768
+ const best = [...containing].sort(
356769
+ (a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height || centerDistance(a.rect, center) - centerDistance(b.rect, center)
356770
+ )[0];
356771
+ return { kind: "matched", elementId: best.elementId, reason: "bounds" };
356772
+ }
356773
+ if (withRect.length > 0) {
356774
+ const tolerance = Math.max(bounds.height, 1);
356775
+ const nearest = [...withRect].sort(
356776
+ (a, b) => centerDistance(a.rect, center) - centerDistance(b.rect, center)
356777
+ )[0];
356778
+ if (centerDistance(nearest.rect, center) <= tolerance) {
356779
+ return { kind: "matched", elementId: nearest.elementId, reason: "bounds" };
356780
+ }
356781
+ }
356782
+ }
356783
+ return opts.strict === "strict" ? { kind: "ambiguous", count: candidates.length } : { kind: "fallback", elementId: candidates[0].elementId, count: candidates.length };
356784
+ }
356785
+ function extractElementIdsFromPluralResponse(result) {
356786
+ const rawList = result?.value;
356787
+ if (!Array.isArray(rawList)) return [];
356788
+ const ids = [];
356789
+ for (const entry of rawList) {
356790
+ const record = entry;
356791
+ const id = validateElementId(record?.ELEMENT ?? record?.["element-6066-11e4-a52e-4f735466cecf"]);
356792
+ if (id) ids.push(id);
356793
+ }
356794
+ return ids;
356795
+ }
356796
+ async function findElements(sessionId, strategy) {
356797
+ const result = await wdRequest(`/session/${sessionId}/elements`, {
356798
+ method: "POST",
356799
+ body: JSON.stringify({ using: strategy.using, value: strategy.value })
356800
+ });
356801
+ return extractElementIdsFromPluralResponse(result);
356802
+ }
356803
+ async function findWebViewElements(sessionPath, css) {
356804
+ const result = await wdRequest(`${sessionPath}/elements`, {
356805
+ method: "POST",
356806
+ body: JSON.stringify({ using: "css selector", value: css })
356807
+ });
356808
+ return extractElementIdsFromPluralResponse(result);
356809
+ }
356810
+ async function readElementRect(sessionId, elementId) {
356811
+ try {
356812
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/rect`);
356813
+ return parseMobileBoundsLike(result?.value);
356814
+ } catch (err) {
356815
+ if (isTransportError(err)) throw err;
356816
+ return null;
356817
+ }
356818
+ }
356819
+ async function disambiguateElementIds(sessionId, candidateIds, opts) {
356820
+ const indexOnly = candidateIds.map((elementId) => ({ elementId }));
356821
+ const indexResult = chooseRecordedMatch(indexOnly, {
356822
+ recordedBounds: null,
356823
+ matchIndex: opts.matchIndex,
356824
+ expectedMatchCount: opts.expectedMatchCount,
356825
+ strict: opts.strict
356826
+ });
356827
+ if (indexResult.kind === "matched" || candidateIds.length > MOBILE_MAX_DISAMBIG_CANDIDATES) {
356828
+ return indexResult;
356829
+ }
356830
+ const withRects = await Promise.all(
356831
+ candidateIds.map(async (elementId) => ({ elementId, rect: await readElementRect(sessionId, elementId) }))
356832
+ );
356833
+ return chooseRecordedMatch(withRects, {
356834
+ recordedBounds: opts.recordedBounds ?? null,
356835
+ matchIndex: opts.matchIndex,
356836
+ expectedMatchCount: opts.expectedMatchCount,
356837
+ strict: opts.strict
356838
+ });
356839
+ }
356159
356840
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
356160
356841
  const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
356161
356842
  if (strategies.length === 0) {
@@ -356163,22 +356844,45 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356163
356844
  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
356845
  );
356165
356846
  }
356847
+ const mode = strictMatchMode();
356848
+ const matchIndex = typeof target?.matchIndex === "number" ? target.matchIndex : void 0;
356849
+ const expectedMatchCount = typeof target?.expectedMatchCount === "number" ? target.expectedMatchCount : void 0;
356166
356850
  const startTime = Date.now();
356167
356851
  let lastError = "";
356852
+ let lastAmbiguity = null;
356168
356853
  let revealAttempted = false;
356169
356854
  while (Date.now() - startTime < timeoutMs) {
356170
356855
  for (const strategy of strategies) {
356171
356856
  try {
356172
- const result = await wdRequest(`/session/${sessionId}/element`, {
356173
- method: "POST",
356174
- body: JSON.stringify({ using: strategy.using, value: strategy.value })
356857
+ const ids = await findElements(sessionId, strategy);
356858
+ if (ids.length === 0) continue;
356859
+ if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
356860
+ return ids[0];
356861
+ }
356862
+ const result = await disambiguateElementIds(sessionId, ids, {
356863
+ recordedBounds: opts?.recordedBounds,
356864
+ matchIndex,
356865
+ expectedMatchCount,
356866
+ strict: mode
356175
356867
  });
356176
- const elementId = validateElementId(
356177
- result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
356868
+ if (result.kind === "matched") return result.elementId;
356869
+ const shouldFailClosed = opts?.failClosedOnAmbiguity || result.kind === "ambiguous" && !opts?.tolerateAmbiguity;
356870
+ if (shouldFailClosed) {
356871
+ throw new MobileAmbiguousMatchError(
356872
+ `ambiguous locator: ${result.count} matches, cannot identify the recorded instance (strategy=${strategy.using} value=${strategy.value})`
356873
+ );
356874
+ }
356875
+ opts?.log?.(
356876
+ `\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
356877
  );
356179
- if (elementId) return elementId;
356878
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
356180
356879
  } catch (err) {
356181
356880
  if (isTransportError(err)) throw err;
356881
+ if (err instanceof MobileAmbiguousMatchError) {
356882
+ lastAmbiguity = err;
356883
+ lastError = err.message;
356884
+ continue;
356885
+ }
356182
356886
  lastError = err instanceof Error ? err.message : String(err);
356183
356887
  }
356184
356888
  }
@@ -356193,6 +356897,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356193
356897
  }
356194
356898
  await new Promise((resolve2) => setTimeout(resolve2, 500));
356195
356899
  }
356900
+ if (lastAmbiguity) throw lastAmbiguity;
356196
356901
  throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
356197
356902
  }
356198
356903
  async function tapCoordinates(sessionId, bounds) {
@@ -356226,7 +356931,7 @@ async function prepareAndroidInputEnvironment(deviceId) {
356226
356931
  ];
356227
356932
  for (const [namespace, key, value] of settingsToDisable) {
356228
356933
  try {
356229
- await execFileAsync2("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356934
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356230
356935
  timeout: 8e3,
356231
356936
  windowsHide: true
356232
356937
  });
@@ -356238,14 +356943,14 @@ async function sendAndroidTextToFocusedInput(deviceId, value) {
356238
356943
  if (!deviceId) {
356239
356944
  throw new Error("Android keyboard fallback requires a device id");
356240
356945
  }
356241
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356946
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356242
356947
  timeout: 1e4,
356243
356948
  windowsHide: true
356244
356949
  });
356245
356950
  }
356246
356951
  async function pressAndroidKeyboardEnter(deviceId) {
356247
356952
  if (!deviceId) throw new Error("Android keyboard Enter fallback requires a device id");
356248
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356953
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356249
356954
  timeout: 1e4,
356250
356955
  windowsHide: true
356251
356956
  });
@@ -356333,21 +357038,40 @@ async function executeInWebViewContext(sessionId, step, css, body) {
356333
357038
  const cleanCss = css.startsWith("css=") ? css.slice(4) : css;
356334
357039
  await withWebViewContext(sessionPath, webview, () => body(sessionPath, cleanCss));
356335
357040
  }
356336
- async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
357041
+ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
357042
+ const mode = strictMatchMode();
356337
357043
  const startedAt = Date.now();
356338
357044
  let lastError = "";
356339
357045
  while (Date.now() - startedAt < timeoutMs) {
356340
357046
  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;
357047
+ const ids = await findWebViewElements(sessionPath, css);
357048
+ if (ids.length === 0) {
357049
+ lastError = `no match for css=${css}`;
357050
+ } else if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
357051
+ return ids[0];
357052
+ } else {
357053
+ const candidates = ids.map((elementId) => ({ elementId }));
357054
+ const result = chooseRecordedMatch(candidates, {
357055
+ recordedBounds: null,
357056
+ matchIndex: opts?.matchIndex,
357057
+ expectedMatchCount: opts?.expectedMatchCount,
357058
+ strict: mode
357059
+ });
357060
+ if (result.kind === "matched") {
357061
+ return result.elementId;
357062
+ }
357063
+ if (result.kind === "ambiguous" && !opts?.tolerateAmbiguity) {
357064
+ throw new MobileAmbiguousMatchError(
357065
+ `ambiguous WEBVIEW locator: ${result.count} matches for css=${css}, cannot identify the recorded instance`
357066
+ );
357067
+ }
357068
+ opts?.log?.(
357069
+ `\u26A0 ambiguous WEBVIEW locator (${result.count} matches for css=${css}) \u2014 using the first match. Set VALIDATEQA_MOBILE_STRICT_MATCH=strict to fail instead.`
357070
+ );
357071
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
357072
+ }
356349
357073
  } catch (err) {
356350
- if (isTransportError(err)) throw err;
357074
+ if (isTransportError(err) || err instanceof MobileAmbiguousMatchError) throw err;
356351
357075
  lastError = err instanceof Error ? err.message : String(err);
356352
357076
  }
356353
357077
  await new Promise((resolve2) => setTimeout(resolve2, 250));
@@ -356356,21 +357080,42 @@ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIM
356356
357080
  `WEBVIEW element not found for css=${css} after ${timeoutMs}ms` + (lastError ? `. Last error: ${lastError}` : "")
356357
357081
  );
356358
357082
  }
357083
+ function isWebViewHydrationTransient(err) {
357084
+ const message = err instanceof Error ? err.message : String(err);
357085
+ 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);
357086
+ }
357087
+ var WEBVIEW_HYDRATION_TIMEOUT_MS = Number(process.env.VALIDATEQA_WEBVIEW_HYDRATION_TIMEOUT_MS) || 3e4;
357088
+ async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, body, timeoutMs) {
357089
+ const deadline = Date.now() + Math.max(1e3, timeoutMs);
357090
+ for (; ; ) {
357091
+ try {
357092
+ await executeInWebViewContext(sessionId, step, css, body);
357093
+ return;
357094
+ } catch (err) {
357095
+ if (!isWebViewHydrationTransient(err) || Date.now() >= deadline) throw err;
357096
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
357097
+ }
357098
+ }
357099
+ }
356359
357100
  async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
356360
357101
  const webCss = stepWebViewCss(step);
356361
357102
  if (webCss) {
356362
- await executeInWebViewContext(
357103
+ await executeInWebViewContextWithHydrationRetry(
356363
357104
  sessionId,
356364
357105
  step,
356365
357106
  webCss,
356366
- (sessionPath, css) => clickCssInCurrentContext(sessionPath, css)
357107
+ (sessionPath, css) => clickCssInCurrentContext(sessionPath, css),
357108
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356367
357109
  );
356368
357110
  return;
356369
357111
  }
356370
357112
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
356371
357113
  if (step.target) {
356372
357114
  try {
356373
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357115
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357116
+ platform: platform3,
357117
+ recordedBounds: bounds
357118
+ });
356374
357119
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356375
357120
  method: "POST",
356376
357121
  body: "{}"
@@ -356392,11 +357137,12 @@ async function executeType(sessionId, step, context) {
356392
357137
  const keysValue = keyboardSubmitOnly ? WEBDRIVER_ENTER_KEY : value;
356393
357138
  const webCss = stepWebViewCss(step);
356394
357139
  if (webCss) {
356395
- await executeInWebViewContext(
357140
+ await executeInWebViewContextWithHydrationRetry(
356396
357141
  sessionId,
356397
357142
  step,
356398
357143
  webCss,
356399
- (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value)
357144
+ (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value),
357145
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356400
357146
  );
356401
357147
  return;
356402
357148
  }
@@ -356407,7 +357153,9 @@ async function executeType(sessionId, step, context) {
356407
357153
  const locatorStrategies = buildLocatorStrategies(step.target, context.platform);
356408
357154
  if (step.target && locatorStrategies.length > 0) {
356409
357155
  const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
356410
- platform: context.platform
357156
+ platform: context.platform,
357157
+ recordedBounds: bounds,
357158
+ log: context.log
356411
357159
  });
356412
357160
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356413
357161
  method: "POST",
@@ -356448,22 +357196,28 @@ async function executeType(sessionId, step, context) {
356448
357196
  }
356449
357197
  async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
356450
357198
  const webCss = stepWebViewCss(step);
357199
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
356451
357200
  if (webCss) {
356452
357201
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356453
- const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357202
+ const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357203
+ matchIndex: step.target?.matchIndex,
357204
+ expectedMatchCount: step.target?.expectedMatchCount
357205
+ });
356454
357206
  await wdRequest(`${sessionPath}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356455
357207
  });
356456
357208
  return;
356457
357209
  }
356458
357210
  const locatorStrategies = buildLocatorStrategies(step.target, platform3);
356459
357211
  if (step.target && locatorStrategies.length > 0) {
356460
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357212
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357213
+ platform: platform3,
357214
+ recordedBounds: bounds
357215
+ });
356461
357216
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
356462
357217
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
356463
357218
  await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356464
357219
  return;
356465
357220
  }
356466
- const bounds = boundsForRun(step, platform3, isCrossPlatform);
356467
357221
  await clearField(`/session/${sessionId}`, bounds ?? void 0);
356468
357222
  }
356469
357223
  async function executeSwipe(sessionId, step) {
@@ -356524,34 +357278,47 @@ async function executeWaitForElement(sessionId, step, platform3) {
356524
357278
  sessionId,
356525
357279
  step,
356526
357280
  webCss,
356527
- (sessionPath, css) => findWebViewElement(sessionPath, css, timeout).then(() => void 0)
357281
+ (sessionPath, css) => findWebViewElement(sessionPath, css, timeout, { disambiguate: false }).then(() => void 0)
356528
357282
  );
356529
357283
  return;
356530
357284
  }
356531
- await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
357285
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3, disambiguate: false });
356532
357286
  }
356533
- async function executeAssertVisible(sessionId, step, platform3) {
357287
+ async function executeAssertVisible(sessionId, step, platform3, isCrossPlatform = false) {
356534
357288
  const webCss = stepWebViewCss(step);
356535
357289
  if (webCss) {
356536
357290
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356537
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357291
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357292
+ matchIndex: step.target?.matchIndex,
357293
+ expectedMatchCount: step.target?.expectedMatchCount,
357294
+ tolerateAmbiguity: true
357295
+ });
356538
357296
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/displayed`);
356539
357297
  if (!result2?.value) throw new Error(`WEBVIEW element found but not visible: css=${css}`);
356540
357298
  });
356541
357299
  return;
356542
357300
  }
356543
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357301
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357302
+ identityOnly: true,
357303
+ platform: platform3,
357304
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357305
+ tolerateAmbiguity: true
357306
+ });
356544
357307
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
356545
357308
  if (!result?.value) {
356546
357309
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
356547
357310
  }
356548
357311
  }
356549
- async function executeAssertText(sessionId, step, platform3) {
357312
+ async function executeAssertText(sessionId, step, platform3, isCrossPlatform = false) {
356550
357313
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
356551
357314
  const webCss = stepWebViewCss(step);
356552
357315
  if (webCss) {
356553
357316
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356554
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357317
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357318
+ matchIndex: step.target?.matchIndex,
357319
+ expectedMatchCount: step.target?.expectedMatchCount,
357320
+ tolerateAmbiguity: true
357321
+ });
356555
357322
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/text`);
356556
357323
  const actualText2 = typeof result2?.value === "string" ? result2.value : "";
356557
357324
  const textCandidates2 = await readElementTextCandidates(sessionId, elementId2, actualText2);
@@ -356563,7 +357330,12 @@ async function executeAssertText(sessionId, step, platform3) {
356563
357330
  });
356564
357331
  return;
356565
357332
  }
356566
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357333
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357334
+ identityOnly: true,
357335
+ platform: platform3,
357336
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357337
+ tolerateAmbiguity: true
357338
+ });
356567
357339
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
356568
357340
  const actualText = typeof result?.value === "string" ? result.value : "";
356569
357341
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -356603,6 +357375,8 @@ function acquireControlPortSlot(deviceId) {
356603
357375
  function releaseControlPortSlot(deviceId) {
356604
357376
  controlPortSlotsByDevice.delete(deviceId);
356605
357377
  }
357378
+ var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357379
+ var pinnedDeviceSnapshot = null;
356606
357380
  async function executeMobileTest(options) {
356607
357381
  const logLines = [];
356608
357382
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -356610,6 +357384,7 @@ async function executeMobileTest(options) {
356610
357384
  const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
356611
357385
  const runPlatform = options.target.platform;
356612
357386
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
357387
+ const captureStepScreenshots = options.captureStepScreenshots !== false;
356613
357388
  if (missingVariableKeys.length > 0) {
356614
357389
  log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
356615
357390
  }
@@ -356674,32 +357449,42 @@ async function executeMobileTest(options) {
356674
357449
  await startAppiumServer();
356675
357450
  log2("Appium server started");
356676
357451
  }
356677
- log2("Discovering devices...");
356678
- const devices = discoverAllDevices({ includeApps: true });
356679
357452
  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.`
357453
+ const pinnedDeviceId = options.target.assignedDeviceId;
357454
+ const candidateSnapshot = pinnedDeviceSnapshot;
357455
+ const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && candidateSnapshot.deviceId === pinnedDeviceId && Date.now() - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
357456
+ if (reusablePinnedSnapshot) {
357457
+ assertDeviceNotQuarantined(reusablePinnedSnapshot.device.id);
357458
+ selectedDevice = reusablePinnedSnapshot.device;
357459
+ log2(`Reusing known device (skipped device re-scan): ${selectedDevice.name} (${selectedDevice.id})`);
357460
+ } else {
357461
+ log2("Discovering devices...");
357462
+ const devices = discoverAllDevices({ includeApps: true });
357463
+ const matchingDevices = devices.filter(
357464
+ (d) => d.platform === platformFilter && d.isAvailable && d.state === "BOOTED"
356686
357465
  );
356687
- }
356688
- if (options.target.assignedDeviceId) {
356689
- selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
356690
- if (!selectedDevice) {
357466
+ if (matchingDevices.length === 0) {
356691
357467
  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.`
357468
+ `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
356693
357469
  );
356694
357470
  }
357471
+ if (options.target.assignedDeviceId) {
357472
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
357473
+ if (!selectedDevice) {
357474
+ throw new Error(
357475
+ `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.`
357476
+ );
357477
+ }
357478
+ }
357479
+ if (!selectedDevice && options.target.recordedDeviceId) {
357480
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
357481
+ }
357482
+ if (!selectedDevice) {
357483
+ selectedDevice = matchingDevices[0];
357484
+ }
357485
+ log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
357486
+ pinnedDeviceSnapshot = options.target.assignedDeviceId ? { deviceId: options.target.assignedDeviceId, device: selectedDevice, at: Date.now() } : null;
356695
357487
  }
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
357488
  try {
356704
357489
  capture = await startMobileNetworkCapture({
356705
357490
  platform: options.target.platform,
@@ -356766,7 +357551,14 @@ async function executeMobileTest(options) {
356766
357551
  const resetAllowed = shouldResetMobileAppData(options.target.platform, selectedDevice.id);
356767
357552
  if (resetAllowed) {
356768
357553
  try {
356769
- await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357554
+ if (options.target.platform === "IOS") {
357555
+ await terminateTargetApp(sessionId, "IOS", options.target.appId).catch(() => {
357556
+ });
357557
+ const wiped = await clearIosSimulatorAppData(selectedDevice.id, options.target.appId);
357558
+ if (!wiped.ok) throw new Error(wiped.detail);
357559
+ } else {
357560
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357561
+ }
356770
357562
  clearedAppDataBeforeLaunch = true;
356771
357563
  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
357564
  } catch (err) {
@@ -356819,17 +357611,21 @@ async function executeMobileTest(options) {
356819
357611
  const entryStep = selectEntryDriftGateStep(runSteps, runPlatform);
356820
357612
  if (entryStep) {
356821
357613
  try {
356822
- const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
357614
+ const entryTimeout = Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS);
356823
357615
  const entryWebCss = stepWebViewCss(entryStep);
356824
357616
  if (entryWebCss) {
356825
357617
  await executeInWebViewContext(
356826
357618
  sessionId,
356827
357619
  entryStep,
356828
357620
  entryWebCss,
356829
- (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout).then(() => void 0)
357621
+ (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout, { disambiguate: false }).then(() => void 0)
356830
357622
  );
356831
357623
  } else {
356832
- await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
357624
+ await findElement(sessionId, entryStep.target, entryTimeout, {
357625
+ identityOnly: true,
357626
+ platform: runPlatform,
357627
+ disambiguate: false
357628
+ });
356833
357629
  }
356834
357630
  } catch (err) {
356835
357631
  if (isTransportError(err)) throw err;
@@ -356889,10 +357685,10 @@ async function executeMobileTest(options) {
356889
357685
  await executeWaitForElement(sessionId, step, runPlatform);
356890
357686
  break;
356891
357687
  case "assertVisible":
356892
- await executeAssertVisible(sessionId, step, runPlatform);
357688
+ await executeAssertVisible(sessionId, step, runPlatform, isCrossPlatform);
356893
357689
  break;
356894
357690
  case "assertText":
356895
- await executeAssertText(sessionId, step, runPlatform);
357691
+ await executeAssertText(sessionId, step, runPlatform, isCrossPlatform);
356896
357692
  break;
356897
357693
  default: {
356898
357694
  const _exhaustive = step.action;
@@ -356911,7 +357707,7 @@ async function executeMobileTest(options) {
356911
357707
  environmental = stepError;
356912
357708
  }
356913
357709
  }
356914
- const screenshot = await takeScreenshot(sessionId).catch(() => null);
357710
+ const screenshot = captureStepScreenshots ? await takeScreenshot(sessionId).catch(() => null) : null;
356915
357711
  screenshots.push(screenshot ?? "");
356916
357712
  if (status === "failed") {
356917
357713
  await recordFirstFailingSource();
@@ -357173,10 +357969,10 @@ async function driveHarvestStep(sessionId, step, elementId, platform3) {
357173
357969
  await executeWaitForElement(sessionId, step, platform3);
357174
357970
  return true;
357175
357971
  case "assertVisible":
357176
- await executeAssertVisible(sessionId, step, platform3);
357972
+ await executeAssertVisible(sessionId, step, platform3, true);
357177
357973
  return true;
357178
357974
  case "assertText":
357179
- await executeAssertText(sessionId, step, platform3);
357975
+ await executeAssertText(sessionId, step, platform3, true);
357180
357976
  return true;
357181
357977
  default:
357182
357978
  break;
@@ -357258,7 +358054,15 @@ async function harvestPlatformLocators(options) {
357258
358054
  const identityStrategies = webViewCss ? [] : buildIdentityLocatorStrategies(step.target);
357259
358055
  if (!webViewCss && identityStrategies.length > 0 && step.target) {
357260
358056
  try {
357261
- elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
358057
+ elementId = await findElement(
358058
+ session.sessionId,
358059
+ step.target,
358060
+ Math.max(DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS),
358061
+ {
358062
+ identityOnly: true,
358063
+ failClosedOnAmbiguity: true
358064
+ }
358065
+ );
357262
358066
  } catch (err) {
357263
358067
  if (isTransportError(err)) throw err;
357264
358068
  elementId = null;
@@ -357424,10 +358228,10 @@ function decideMobileDeviceGate(input) {
357424
358228
 
357425
358229
  // src/services/mobile-bridge.ts
357426
358230
  var import_http = require("http");
357427
- var import_child_process3 = require("child_process");
358231
+ var import_child_process4 = require("child_process");
357428
358232
  var import_crypto = require("crypto");
357429
358233
  var import_os4 = require("os");
357430
- var import_path3 = require("path");
358234
+ var import_path4 = require("path");
357431
358235
  var import_fs3 = require("fs");
357432
358236
  init_dist();
357433
358237
  var MOBILE_BRIDGE_PORT2 = 9515;
@@ -357534,7 +358338,7 @@ async function getWindowSize3() {
357534
358338
  }
357535
358339
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
357536
358340
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
357537
- var BRIDGE_PID_FILE = (0, import_path3.join)((0, import_os4.homedir)(), ".validate.qa", "mobile-bridge.pid");
358341
+ var BRIDGE_PID_FILE = (0, import_path4.join)((0, import_os4.homedir)(), ".validate.qa", "mobile-bridge.pid");
357538
358342
  function readBridgePidFile() {
357539
358343
  try {
357540
358344
  const raw = (0, import_fs3.readFileSync)(BRIDGE_PID_FILE, "utf-8").trim();
@@ -357546,7 +358350,7 @@ function readBridgePidFile() {
357546
358350
  }
357547
358351
  function writeBridgePidFile(pid) {
357548
358352
  try {
357549
- (0, import_fs3.mkdirSync)((0, import_path3.dirname)(BRIDGE_PID_FILE), { recursive: true, mode: 448 });
358353
+ (0, import_fs3.mkdirSync)((0, import_path4.dirname)(BRIDGE_PID_FILE), { recursive: true, mode: 448 });
357550
358354
  (0, import_fs3.writeFileSync)(BRIDGE_PID_FILE, String(pid), { mode: 384 });
357551
358355
  } catch {
357552
358356
  }
@@ -357561,19 +358365,19 @@ function killStaleBridge() {
357561
358365
  const pid = readBridgePidFile();
357562
358366
  if (!pid) return;
357563
358367
  try {
357564
- (0, import_child_process3.execFileSync)("kill", ["-0", String(pid)], { stdio: "ignore" });
358368
+ (0, import_child_process4.execFileSync)("kill", ["-0", String(pid)], { stdio: "ignore" });
357565
358369
  } catch {
357566
358370
  clearBridgePidFile();
357567
358371
  return;
357568
358372
  }
357569
358373
  try {
357570
- const pidsOnPort = (0, import_child_process3.execFileSync)("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
358374
+ const pidsOnPort = (0, import_child_process4.execFileSync)("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
357571
358375
  const pidList = pidsOnPort.split("\n").filter(Boolean);
357572
358376
  if (!pidList.includes(String(pid))) {
357573
358377
  return;
357574
358378
  }
357575
- (0, import_child_process3.execFileSync)("kill", ["-9", String(pid)], { stdio: "ignore" });
357576
- (0, import_child_process3.execFileSync)("sleep", ["1"], { stdio: "ignore" });
358379
+ (0, import_child_process4.execFileSync)("kill", ["-9", String(pid)], { stdio: "ignore" });
358380
+ (0, import_child_process4.execFileSync)("sleep", ["1"], { stdio: "ignore" });
357577
358381
  } catch {
357578
358382
  } finally {
357579
358383
  clearBridgePidFile();
@@ -357846,6 +358650,13 @@ async function startSession(validated, res) {
357846
358650
  ...deviceId ? { "appium:udid": deviceId } : {},
357847
358651
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
357848
358652
  "appium:noReset": true,
358653
+ // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
358654
+ // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
358655
+ // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
358656
+ // skip the GUI open. Scoped to simulator UDIDs; APPIUM_IOS_HEADLESS=false
358657
+ // overrides. Verified live: without this, recorder/mirror sessions on iOS
358658
+ // die with "Simulator.app does not exist" while executor sessions work.
358659
+ ...isIosSimulatorUdid(deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
357849
358660
  ...realCaps
357850
358661
  } : {
357851
358662
  platformName: "Android",
@@ -357864,7 +358675,12 @@ async function startSession(validated, res) {
357864
358675
  capabilities: {
357865
358676
  alwaysMatch: capabilities
357866
358677
  }
357867
- })
358678
+ }),
358679
+ // Session creation is the one legitimately slow WebDriver call (iOS WDA
358680
+ // build/launch can take minutes on a cold toolchain) — same ceiling as
358681
+ // the executor's wdRequest session-create path. Without this, the shared
358682
+ // appiumRequest 10s default aborts every iOS recorder session.
358683
+ signal: AbortSignal.timeout(WD_SESSION_CREATE_TIMEOUT_MS)
357868
358684
  });
357869
358685
  state.appiumSessionId = result?.value?.sessionId ?? result?.sessionId ?? null;
357870
358686
  if (!state.appiumSessionId) {
@@ -358642,6 +359458,9 @@ function detachMirrorSession(deviceId) {
358642
359458
  stopAllMirrorClients();
358643
359459
  stopSessionReaper();
358644
359460
  }
359461
+ function getMirrorSessionId(deviceId) {
359462
+ return runnerChannels.get(deviceId)?.sessionId;
359463
+ }
358645
359464
 
358646
359465
  // src/services/mobile/mobile-bridge-driver.ts
358647
359466
  var import_runner_core9 = __toESM(require_dist2());
@@ -358672,6 +359491,7 @@ var MobileBridgeDriver = class {
358672
359491
  sessionId;
358673
359492
  platform;
358674
359493
  appId;
359494
+ deviceId;
358675
359495
  allowAppReset;
358676
359496
  recoverSession;
358677
359497
  maxSessionRecoveries;
@@ -358692,6 +359512,7 @@ var MobileBridgeDriver = class {
358692
359512
  this.sessionId = config.sessionId;
358693
359513
  this.platform = config.platform;
358694
359514
  this.appId = config.appId;
359515
+ this.deviceId = config.deviceId;
358695
359516
  this.allowAppReset = config.allowAppReset === true;
358696
359517
  this.recoverSession = config.recoverSession;
358697
359518
  this.maxSessionRecoveries = Math.max(0, Math.floor(config.maxSessionRecoveries ?? 1));
@@ -358989,17 +359810,34 @@ var MobileBridgeDriver = class {
358989
359810
  });
358990
359811
  }
358991
359812
  /**
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.
359813
+ * Deterministic state reset: clear app data + relaunch, per the
359814
+ * runner-computed allowAppReset policy (Android emulators / iOS simulators
359815
+ * only — a personal physical device is never wiped). Android clears via
359816
+ * `mobile: clearApp`; iOS terminates the app and wipes its simulator data
359817
+ * container + keychain through `simctl` (see ios-simulator-reset.ts).
359818
+ * Returns false when unsupported/disallowed or when the clear fails —
359819
+ * callers fall back to relaunch(), preserving today's behavior. On success,
359820
+ * waits for cold-boot content so recording and replay agree on the true
359821
+ * entry screen.
358997
359822
  */
358998
359823
  async resetAppState() {
358999
- if (!this.allowAppReset || this.platform !== "ANDROID") return false;
359824
+ if (!this.allowAppReset) return false;
359825
+ if (this.platform === "IOS" && !isIosSimulatorUdid(this.deviceId)) return false;
359000
359826
  return this.withSessionRecovery("app data reset", async () => {
359001
359827
  try {
359002
- await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359828
+ if (this.platform === "IOS") {
359829
+ try {
359830
+ await this.executeScript("mobile: terminateApp", [{ ...this.appLifecycleArgs(), timeout: 5e3 }]);
359831
+ } catch (err) {
359832
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
359833
+ }
359834
+ const wiped = await clearIosSimulatorAppData(this.deviceId ?? "", this.appId);
359835
+ if (!wiped.ok) {
359836
+ throw new Error(`iOS simulator app-data reset failed: ${wiped.detail}`);
359837
+ }
359838
+ } else {
359839
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359840
+ }
359003
359841
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
359004
359842
  try {
359005
359843
  await waitForColdBootSettle(this.sessionPath);
@@ -359138,7 +359976,7 @@ function readActiveBundleId2(result) {
359138
359976
  }
359139
359977
 
359140
359978
  // src/services/doctor.ts
359141
- var import_child_process4 = require("child_process");
359979
+ var import_child_process5 = require("child_process");
359142
359980
  var import_fs4 = require("fs");
359143
359981
  var import_os5 = require("os");
359144
359982
  var C = {
@@ -359158,7 +359996,7 @@ function computeDoctorReadiness(web, ios, android) {
359158
359996
  }
359159
359997
  function run(cmd) {
359160
359998
  try {
359161
- return (0, import_child_process4.execSync)(cmd, { encoding: "utf-8", timeout: 15e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
359999
+ return (0, import_child_process5.execSync)(cmd, { encoding: "utf-8", timeout: 15e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
359162
360000
  } catch {
359163
360001
  return null;
359164
360002
  }
@@ -359590,6 +360428,113 @@ function envInt(name, fallback) {
359590
360428
  }
359591
360429
  var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
359592
360430
  var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
360431
+ var MOBILE_RUN_WATCHDOG_MS = Math.max(0, envInt("MOBILE_RUN_WATCHDOG_MS", 30 * 6e4));
360432
+ var MOBILE_RUN_ABORT_GRACE_MS = Math.max(0, envInt("MOBILE_RUN_ABORT_GRACE_MS", 2 * 6e4));
360433
+ var MOBILE_CANCEL_POLL_MS = Math.max(0, envInt("MOBILE_CANCEL_POLL_MS", 15e3));
360434
+ var MobileRunWatchdog = class {
360435
+ constructor(options) {
360436
+ this.options = options;
360437
+ }
360438
+ phase = "idle";
360439
+ released = false;
360440
+ quarantined = false;
360441
+ cancelAbortTimer = null;
360442
+ cancelGraceTimer = null;
360443
+ /** Current phase (exposed for tests / observability). */
360444
+ getPhase() {
360445
+ return this.phase;
360446
+ }
360447
+ /** Arm the wall-clock abort timer. Call once, immediately after dispatch. */
360448
+ start() {
360449
+ if (this.phase !== "idle") return;
360450
+ this.phase = "active";
360451
+ if (this.options.abortMs > 0) {
360452
+ this.cancelAbortTimer = this.options.schedule(
360453
+ () => this.triggerAbort(`exceeded the ${Math.round(this.options.abortMs / 6e4)}min wall-clock ceiling`),
360454
+ this.options.abortMs
360455
+ );
360456
+ }
360457
+ }
360458
+ /** Stage 1. Idempotent (a no-op unless still ACTIVE). Also called by the
360459
+ * cancellation observer. Quarantines the device (blocking the run's own
360460
+ * session-recovery from resurrecting the killed session — observed live),
360461
+ * kills the run's session (best-effort), and starts the grace clock;
360462
+ * deliberately does NOT release the lease. */
360463
+ triggerAbort(reason) {
360464
+ if (this.phase !== "active") return;
360465
+ this.phase = "aborting";
360466
+ this.clearAbortTimer();
360467
+ if (this.options.deviceId) {
360468
+ this.quarantined = true;
360469
+ this.options.callbacks.quarantine(this.options.deviceId);
360470
+ this.options.callbacks.abort(this.options.deviceId, this.options.runId, reason);
360471
+ } else {
360472
+ this.options.callbacks.log("warn", `Run ${this.options.runId} ${reason} \u2014 no leased device to abort; awaiting the run's own settle.`);
360473
+ }
360474
+ if (this.options.graceMs > 0) {
360475
+ this.cancelGraceTimer = this.options.schedule(() => this.forceRelease(), this.options.graceMs);
360476
+ } else {
360477
+ this.forceRelease();
360478
+ }
360479
+ }
360480
+ /** The run's promise settled (normal completion, or after an abort). The single
360481
+ * release owner: releases at most once and lifts any quarantine. Idempotent. */
360482
+ settle() {
360483
+ if (this.phase === "settled") return;
360484
+ const wasForceReleased = this.released;
360485
+ this.phase = "settled";
360486
+ this.clearAbortTimer();
360487
+ this.clearGraceTimer();
360488
+ if (!this.released) {
360489
+ this.released = true;
360490
+ this.options.callbacks.release();
360491
+ }
360492
+ if (this.quarantined && this.options.deviceId) {
360493
+ this.quarantined = false;
360494
+ this.options.callbacks.unquarantine(this.options.deviceId);
360495
+ if (wasForceReleased) {
360496
+ this.options.callbacks.log("info", `Run ${this.options.runId} finally settled \u2014 lifted the quarantine on device ${this.options.deviceId}.`);
360497
+ }
360498
+ }
360499
+ }
360500
+ /** Stage 2. The run did not settle within the grace window after the abort. Force-
360501
+ * release the slot to unwedge capacity; the device stays quarantined (since the
360502
+ * stage-1 abort) until the promise eventually settles. Idempotent and guarded
360503
+ * against a late settle. */
360504
+ forceRelease() {
360505
+ if (this.released || this.phase === "settled") return;
360506
+ this.clearGraceTimer();
360507
+ this.released = true;
360508
+ this.options.callbacks.release();
360509
+ if (this.options.deviceId) {
360510
+ if (!this.quarantined) {
360511
+ this.quarantined = true;
360512
+ this.options.callbacks.quarantine(this.options.deviceId);
360513
+ }
360514
+ this.options.callbacks.log(
360515
+ "warn",
360516
+ `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.`
360517
+ );
360518
+ } else {
360519
+ this.options.callbacks.log("warn", `Run ${this.options.runId} did not settle after abort \u2014 force-released its slot to unwedge mobile capacity.`);
360520
+ }
360521
+ }
360522
+ clearAbortTimer() {
360523
+ if (this.cancelAbortTimer) {
360524
+ this.cancelAbortTimer();
360525
+ this.cancelAbortTimer = null;
360526
+ }
360527
+ }
360528
+ clearGraceTimer() {
360529
+ if (this.cancelGraceTimer) {
360530
+ this.cancelGraceTimer();
360531
+ this.cancelGraceTimer = null;
360532
+ }
360533
+ }
360534
+ };
360535
+ function isServerCancelledRunStatus(status) {
360536
+ return status === "ERROR" || status === "CANCELLED" || status === "CANCELLING";
360537
+ }
359593
360538
  function scrubAuditMessages(messages, testVariables) {
359594
360539
  if (messages === void 0 || messages === null) return messages;
359595
360540
  const compacted = compactForAudit(messages);
@@ -359665,7 +360610,7 @@ var log = {
359665
360610
  dim: (msg) => console.log(`${C2.gray}${msg}${C2.reset}`)
359666
360611
  };
359667
360612
  function getRunsDir() {
359668
- const dir = process.env.RUNS_DIR || (0, import_path4.join)((0, import_os6.homedir)(), ".validate.qa", "runs");
360613
+ const dir = process.env.RUNS_DIR || (0, import_path5.join)((0, import_os6.homedir)(), ".validate.qa", "runs");
359669
360614
  (0, import_runner_core12.ensurePrivateDirectory)(dir);
359670
360615
  return dir;
359671
360616
  }
@@ -361151,9 +362096,15 @@ ${finalVerifyResult.logs ?? ""}`;
361151
362096
  // flip the test to needs_manual_authoring), so an environmentally
361152
362097
  // aborted pass — wrong screen, system dialog, transport flake —
361153
362098
  // 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,
362099
+ // The same applies to a harvest that ADVANCED THROUGH NOTHING
362100
+ // (reachedOrder null): it never got past the entry screen, so its
362101
+ // step judgments are cold-boot/entry noise, not app knowledge —
362102
+ // observed live: a slow WKWebView hydration marked step 1
362103
+ // divergent and permanently dead-ended the test in
362104
+ // needs_manual_authoring with no retry path. Harvested bundles
362105
+ // are still posted: they were positively resolved and the merge
362106
+ // is idempotent.
362107
+ divergentOrders: harvest.environmentalError || harvest.reachedOrder == null ? [] : harvest.divergentOrders,
361157
362108
  reachedOrder: harvest.reachedOrder,
361158
362109
  logs: harvest.logs,
361159
362110
  ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
@@ -361597,9 +362548,11 @@ ${finalVerifyResult.logs ?? ""}`;
361597
362548
  sessionId: session.sessionId,
361598
362549
  platform: platform3,
361599
362550
  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.
362551
+ deviceId: session.deviceId,
362552
+ // Deterministic-state authorization: Android emulators / iOS
362553
+ // simulators only (VALIDATEQA_MOBILE_APP_RESET overrides the
362554
+ // Android default) — a personal physical device must never have
362555
+ // its app data wiped.
361603
362556
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
361604
362557
  maxSessionRecoveries: 1,
361605
362558
  recoverSession: async (cause) => {
@@ -362115,6 +363068,7 @@ ${finalVerifyResult.logs ?? ""}`;
362115
363068
  sessionId: opened.sessionId,
362116
363069
  platform: target.platform,
362117
363070
  appId: opened.appId,
363071
+ deviceId: opened.deviceId,
362118
363072
  allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, opened.deviceId)
362119
363073
  });
362120
363074
  };
@@ -362727,6 +363681,7 @@ async function startRunner(opts) {
362727
363681
  else activeMobileOpsByPlatform.set(p, next);
362728
363682
  };
362729
363683
  const leasedDeviceIds = /* @__PURE__ */ new Map();
363684
+ const activeMobileRuns = /* @__PURE__ */ new Map();
362730
363685
  let activeMobileDiscoveries = 0;
362731
363686
  const mobileHealHold = /* @__PURE__ */ new Map();
362732
363687
  const pruneMobileHealHolds = () => {
@@ -362977,6 +363932,32 @@ async function startRunner(opts) {
362977
363932
  }
362978
363933
  }, PIDS_WATCHDOG_INTERVAL_MS);
362979
363934
  pidsWatchdog.unref();
363935
+ const pollMobileRunCancellations = async () => {
363936
+ if (shuttingDown || activeMobileRuns.size === 0) return;
363937
+ const runIds = [...activeMobileRuns.keys()];
363938
+ try {
363939
+ const res = await fetch(`${serverUrl}/api/runner/runs/status`, {
363940
+ method: "POST",
363941
+ headers: { ...headers, "Content-Type": "application/json" },
363942
+ body: JSON.stringify({ runIds }),
363943
+ signal: AbortSignal.timeout(1e4)
363944
+ });
363945
+ if (!res.ok) return;
363946
+ const body = await res.json().catch(() => null);
363947
+ if (!body || !Array.isArray(body.runs)) return;
363948
+ for (const row of body.runs) {
363949
+ if (!row || typeof row.id !== "string" || typeof row.status !== "string") continue;
363950
+ if (!isServerCancelledRunStatus(row.status)) continue;
363951
+ const watchdog = activeMobileRuns.get(row.id);
363952
+ if (watchdog) watchdog.triggerAbort(`was cancelled server-side (status ${row.status})`);
363953
+ }
363954
+ } catch {
363955
+ }
363956
+ };
363957
+ const mobileCancelPoll = enableMobile && MOBILE_CANCEL_POLL_MS > 0 ? setInterval(() => {
363958
+ void pollMobileRunCancellations();
363959
+ }, MOBILE_CANCEL_POLL_MS) : null;
363960
+ mobileCancelPoll?.unref?.();
362980
363961
  const deferralLogged = /* @__PURE__ */ new Map();
362981
363962
  const logDeferredOnce = (runId, line) => {
362982
363963
  if (deferralLogged.get(runId) === line) return;
@@ -363059,14 +364040,17 @@ async function startRunner(opts) {
363059
364040
  });
363060
364041
  const capacity = MOBILE_DEVICE_CEILING > 0 ? Math.min(derived, MOBILE_DEVICE_CEILING) : derived;
363061
364042
  const leaseTarget = run2.target ?? run2.deviceRequirements;
363062
- const leaseSelection = selectFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot, {
364043
+ const leaseExclusions = {
364044
+ has: (id) => leasedDeviceIds.has(id) || isDeviceQuarantined(id)
364045
+ };
364046
+ const leaseSelection = selectFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot, {
363063
364047
  appId: typeof leaseTarget?.appId === "string" ? leaseTarget.appId : void 0,
363064
364048
  recordedDeviceId: typeof leaseTarget?.recordedDeviceId === "string" ? leaseTarget.recordedDeviceId : void 0,
363065
364049
  platformVersion: typeof leaseTarget?.platformVersion === "string" ? leaseTarget.platformVersion : void 0,
363066
364050
  deviceName: typeof run2.deviceRequirements?.deviceName === "string" ? run2.deviceRequirements.deviceName : void 0,
363067
364051
  resetPolicy: leaseTarget?.resetPolicy
363068
364052
  });
363069
- const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot) : null;
364053
+ const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot) : null;
363070
364054
  if (leaseSelection.kind === "unsatisfiable") {
363071
364055
  mobileLeaseError = leaseSelection.reason;
363072
364056
  log.warn(` Mobile requirements unsatisfiable for ${run2.runId}: ${leaseSelection.reason}; claiming once to report the configuration error`);
@@ -363124,6 +364108,54 @@ async function startRunner(opts) {
363124
364108
  await new Promise((r) => setTimeout(r, staggerMs));
363125
364109
  }
363126
364110
  claimedThisPoll++;
364111
+ const applyRelease = () => {
364112
+ activeRuns--;
364113
+ decrementType(runMode);
364114
+ decrementProject(runProject);
364115
+ if (isMobileOp) {
364116
+ if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
364117
+ if (isDiscovery) activeMobileDiscoveries--;
364118
+ if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
364119
+ activeMobileRuns.delete(run2.runId);
364120
+ }
364121
+ };
364122
+ const runWatchdog = isMobileOp ? new MobileRunWatchdog({
364123
+ runId: run2.runId,
364124
+ deviceId: leasedDeviceId ?? void 0,
364125
+ abortMs: MOBILE_RUN_WATCHDOG_MS,
364126
+ graceMs: MOBILE_RUN_ABORT_GRACE_MS,
364127
+ // Real timers, unref'd so a pending watchdog never keeps the process
364128
+ // alive on shutdown.
364129
+ schedule: (fn, ms) => {
364130
+ const t = setTimeout(fn, ms);
364131
+ if (typeof t.unref === "function") t.unref();
364132
+ return () => clearTimeout(t);
364133
+ },
364134
+ callbacks: {
364135
+ abort: (deviceId, rid, reason) => {
364136
+ const sessionId = getMirrorSessionId(deviceId);
364137
+ log.warn(
364138
+ `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.`
364139
+ );
364140
+ if (sessionId) void deleteAppiumSession(sessionId);
364141
+ },
364142
+ release: applyRelease,
364143
+ quarantine: (deviceId) => {
364144
+ quarantineDevice(deviceId);
364145
+ },
364146
+ unquarantine: (deviceId) => {
364147
+ unquarantineDevice(deviceId);
364148
+ },
364149
+ log: (level, message) => {
364150
+ if (level === "warn") log.warn(message);
364151
+ else log.info(message);
364152
+ }
364153
+ }
364154
+ }) : null;
364155
+ if (runWatchdog) {
364156
+ activeMobileRuns.set(run2.runId, runWatchdog);
364157
+ runWatchdog.start();
364158
+ }
363127
364159
  executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, (testCaseId) => {
363128
364160
  if (mobilePlatform) {
363129
364161
  mobileHealHold.set(testCaseId, { expiry: Date.now() + MOBILE_HEAL_HOLD_MS, platform: mobilePlatform });
@@ -363134,14 +364166,8 @@ async function startRunner(opts) {
363134
364166
  const msg = err instanceof Error ? err.message : String(err);
363135
364167
  log.warn(`Run ${run2.runId} crashed: ${msg}`);
363136
364168
  }).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
- }
364169
+ if (runWatchdog) runWatchdog.settle();
364170
+ else applyRelease();
363145
364171
  });
363146
364172
  }
363147
364173
  }