@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.mjs CHANGED
@@ -740,6 +740,7 @@ var require_mobile_source_parser = __commonJS({
740
740
  exports.computeScreenHash = computeScreenHash;
741
741
  exports.buildLocatorBundle = buildLocatorBundle;
742
742
  exports.preferredLocator = preferredLocator;
743
+ exports.identityMatchInfo = identityMatchInfo;
743
744
  exports.parseMobileSource = parseMobileSource2;
744
745
  exports.resolveLocatorAgainstScreenDetailed = resolveLocatorAgainstScreenDetailed;
745
746
  exports.resolveLocatorAgainstScreen = resolveLocatorAgainstScreen;
@@ -1354,16 +1355,34 @@ var require_mobile_source_parser = __commonJS({
1354
1355
  bundle.contentDesc = el.accessibilityId;
1355
1356
  return bundle;
1356
1357
  }
1357
- function preferredLocator(el) {
1358
- const durableText = el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1358
+ function identityFieldValue(el, field) {
1359
+ if (field === "text") {
1360
+ return el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1361
+ }
1362
+ return el[field];
1363
+ }
1364
+ function choosePreferredIdentityField(el) {
1359
1365
  const candidates = [
1360
- { value: el.accessibilityId, unique: el.identityUnique?.accessibilityId },
1361
- { value: el.resourceId, unique: el.identityUnique?.resourceId },
1362
- { value: durableText, unique: el.identityUnique?.text }
1366
+ { field: "accessibilityId", value: identityFieldValue(el, "accessibilityId"), unique: el.identityUnique?.accessibilityId },
1367
+ { field: "resourceId", value: identityFieldValue(el, "resourceId"), unique: el.identityUnique?.resourceId },
1368
+ { field: "text", value: identityFieldValue(el, "text"), unique: el.identityUnique?.text }
1363
1369
  ];
1364
1370
  const uniqueFirst = candidates.find((c) => c.value && c.unique !== false);
1365
- const chosen = (uniqueFirst ?? candidates.find((c) => c.value))?.value;
1366
- return chosen ?? el.xpath;
1371
+ const chosen = uniqueFirst ?? candidates.find((c) => c.value);
1372
+ return chosen?.value ? { field: chosen.field, value: chosen.value } : void 0;
1373
+ }
1374
+ function preferredLocator(el) {
1375
+ return choosePreferredIdentityField(el)?.value ?? el.xpath;
1376
+ }
1377
+ function identityMatchInfo(el, elements) {
1378
+ const chosen = choosePreferredIdentityField(el);
1379
+ if (!chosen)
1380
+ return void 0;
1381
+ if (el.identityUnique?.[chosen.field] !== false)
1382
+ return void 0;
1383
+ const sameValue = elements.filter((candidate) => identityFieldValue(candidate, chosen.field) === chosen.value);
1384
+ const matchIndex = sameValue.indexOf(el);
1385
+ return matchIndex >= 0 && sameValue.length > 1 ? { matchIndex, expectedMatchCount: sameValue.length } : void 0;
1367
1386
  }
1368
1387
  function buildScreenSignal(elements, root, platform3) {
1369
1388
  const signal = {
@@ -2117,6 +2136,9 @@ function readMobileMetadata(value) {
2117
2136
  return { screenName: "UnknownScreen" };
2118
2137
  }
2119
2138
  const locatorBundle = isPlainObject(value.locatorBundle) && typeof value.locatorBundle.xpath === "string" && typeof value.locatorBundle.className === "string" ? value.locatorBundle : void 0;
2139
+ const rawMatchIndex = value.matchIndex;
2140
+ const rawExpectedMatchCount = value.expectedMatchCount;
2141
+ const hasValidMatchPair = typeof rawMatchIndex === "number" && Number.isInteger(rawMatchIndex) && rawMatchIndex >= 0 && typeof rawExpectedMatchCount === "number" && Number.isInteger(rawExpectedMatchCount) && rawExpectedMatchCount > rawMatchIndex;
2120
2142
  return {
2121
2143
  screenName: typeof value.screenName === "string" && value.screenName.trim() ? value.screenName.trim() : "UnknownScreen",
2122
2144
  targetLabel: typeof value.targetLabel === "string" && value.targetLabel.trim() ? value.targetLabel.trim() : void 0,
@@ -2131,7 +2153,9 @@ function readMobileMetadata(value) {
2131
2153
  distance: typeof value.distance === "number" && Number.isFinite(value.distance) ? value.distance : void 0,
2132
2154
  appContext: value.appContext === "WEBVIEW" ? "WEBVIEW" : value.appContext === "NATIVE" ? "NATIVE" : void 0,
2133
2155
  webCss: typeof value.webCss === "string" && value.webCss.trim() ? value.webCss.trim() : void 0,
2134
- webviewContext: typeof value.webviewContext === "string" && /^WEBVIEW/i.test(value.webviewContext.trim()) ? value.webviewContext.trim() : void 0
2156
+ webviewContext: typeof value.webviewContext === "string" && /^WEBVIEW/i.test(value.webviewContext.trim()) ? value.webviewContext.trim() : void 0,
2157
+ matchIndex: hasValidMatchPair ? rawMatchIndex : void 0,
2158
+ expectedMatchCount: hasValidMatchPair ? rawExpectedMatchCount : void 0
2135
2159
  };
2136
2160
  }
2137
2161
  function nonEmpty(value) {
@@ -2140,6 +2164,9 @@ function nonEmpty(value) {
2140
2164
  const trimmed = value.trim();
2141
2165
  return trimmed.length > 0 ? trimmed : void 0;
2142
2166
  }
2167
+ function matchInstanceFields(metadata) {
2168
+ return metadata.matchIndex !== void 0 && metadata.expectedMatchCount !== void 0 ? { matchIndex: metadata.matchIndex, expectedMatchCount: metadata.expectedMatchCount } : {};
2169
+ }
2143
2170
  function buildStepTarget(metadata, selector) {
2144
2171
  const locatorBundle = metadata.locatorBundle;
2145
2172
  const trimmedSelector = nonEmpty(selector);
@@ -2150,7 +2177,8 @@ function buildStepTarget(metadata, selector) {
2150
2177
  return {
2151
2178
  primary: trimmedSelector,
2152
2179
  fallbacks: [],
2153
- screenName: metadata.screenName
2180
+ screenName: metadata.screenName,
2181
+ ...matchInstanceFields(metadata)
2154
2182
  };
2155
2183
  }
2156
2184
  const webCss = nonEmpty(metadata.webCss) ?? (trimmedSelector?.startsWith("css=") ? trimmedSelector.slice(4) : void 0) ?? (nonEmpty(locatorBundle.xpath)?.startsWith("css=") ? locatorBundle.xpath.slice(4) : void 0);
@@ -2173,8 +2201,9 @@ function buildStepTarget(metadata, selector) {
2173
2201
  primary,
2174
2202
  fallbacks,
2175
2203
  screenName: metadata.screenName,
2176
- locatorBundle
2204
+ locatorBundle,
2177
2205
  // Include the locatorBundle for better selector generation later
2206
+ ...matchInstanceFields(metadata)
2178
2207
  };
2179
2208
  }
2180
2209
  function labelForAction(action, metadata) {
@@ -2621,6 +2650,165 @@ function buildAppiumCode(testName, target, steps) {
2621
2650
  `main().catch((err) => { console.error(err); process.exitCode = 1; });`
2622
2651
  ].join("\n");
2623
2652
  }
2653
+ function webviewStepLogicalId(step) {
2654
+ if (step.metadata?.appContext !== "WEBVIEW")
2655
+ return null;
2656
+ 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) : "";
2657
+ const match = String(webCss ?? "").match(/^#([A-Za-z][\w-]*)$/);
2658
+ return match ? match[1].toLowerCase() : null;
2659
+ }
2660
+ function nativeStepResourceId(step) {
2661
+ const target = step.target;
2662
+ if (!target)
2663
+ return null;
2664
+ const xpaths = [target.locatorBundle?.xpath, ...target.fallbacks ?? []].filter((value) => typeof value === "string" && value.length > 0);
2665
+ for (const xpath of xpaths) {
2666
+ const match = xpath.match(/@resource-id=['"]([^'"]+)['"]/);
2667
+ if (match)
2668
+ return match[1].split("/").pop().toLowerCase();
2669
+ }
2670
+ const resourceId = nonEmpty(target.locatorBundle?.resourceId);
2671
+ if (resourceId)
2672
+ return resourceId.split("/").pop().toLowerCase();
2673
+ const primary = (target.primary ?? "").trim();
2674
+ if (/^[A-Za-z][\w-]*$/.test(primary) && !primary.startsWith("css="))
2675
+ return primary.toLowerCase();
2676
+ return null;
2677
+ }
2678
+ function renumberMobileSteps(steps) {
2679
+ steps.forEach((step, index) => {
2680
+ step.order = index;
2681
+ });
2682
+ }
2683
+ function dropHybridNativeMirrorStepsInPlace(steps) {
2684
+ const webviewKeys = /* @__PURE__ */ new Set();
2685
+ for (const step of steps) {
2686
+ const id = webviewStepLogicalId(step);
2687
+ if (id)
2688
+ webviewKeys.add(`${step.target?.screenName ?? ""}::${id}`);
2689
+ }
2690
+ if (webviewKeys.size === 0)
2691
+ return 0;
2692
+ const kept = steps.filter((step) => {
2693
+ if (step.metadata?.appContext === "WEBVIEW")
2694
+ return true;
2695
+ if (step.action !== "tap" && step.action !== "type")
2696
+ return true;
2697
+ const id = nativeStepResourceId(step);
2698
+ if (id === null)
2699
+ return true;
2700
+ return !webviewKeys.has(`${step.target?.screenName ?? ""}::${id}`);
2701
+ });
2702
+ const removed = steps.length - kept.length;
2703
+ if (removed > 0) {
2704
+ steps.splice(0, steps.length, ...kept);
2705
+ renumberMobileSteps(steps);
2706
+ }
2707
+ return removed;
2708
+ }
2709
+ function strictOnMobileWanderFlag(value) {
2710
+ const v = (value || "").trim().toLowerCase();
2711
+ return v === "true" || v === "1";
2712
+ }
2713
+ function isMobileWanderSimplifyEnabled() {
2714
+ const value = (process.env.VALIDATEQA_MOBILE_WANDER_SIMPLIFY || "").trim().toLowerCase();
2715
+ return value === "on" || value === "true" || value === "1";
2716
+ }
2717
+ function mobileWanderTraceEnabled() {
2718
+ return strictOnMobileWanderFlag(process.env.VALIDATEQA_MOBILE_WANDER_TRACE);
2719
+ }
2720
+ function isMobileWanderCommitTapText(text) {
2721
+ 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);
2722
+ if (isChooser)
2723
+ return false;
2724
+ 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);
2725
+ }
2726
+ function isMobileWanderCommitStep(step) {
2727
+ if (step.action === "type" && typeof step.value === "string" && /\n$/.test(step.value))
2728
+ return true;
2729
+ if (step.action === "tap") {
2730
+ const text = [step.description, step.target?.primary].filter((v) => typeof v === "string" && v.length > 0).join(" ");
2731
+ return isMobileWanderCommitTapText(text);
2732
+ }
2733
+ return false;
2734
+ }
2735
+ function isMobileWanderStateToggleTap(step) {
2736
+ return step.action === "tap" && isStateValueMobileClassName(step.target?.locatorBundle?.className);
2737
+ }
2738
+ function simplifyMobileWanderingInPlace(steps, trace) {
2739
+ let removed = 0;
2740
+ const afterAdjacentDupes = [];
2741
+ for (const step of steps) {
2742
+ const previous = afterAdjacentDupes[afterAdjacentDupes.length - 1];
2743
+ const primary = step.target?.primary;
2744
+ 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);
2745
+ if (isAdjacentDuplicateTap) {
2746
+ removed += 1;
2747
+ trace?.(`dropped adjacent duplicate tap "${primary}" (exact repeat, nothing between)`);
2748
+ afterAdjacentDupes[afterAdjacentDupes.length - 1] = step;
2749
+ continue;
2750
+ }
2751
+ afterAdjacentDupes.push(step);
2752
+ }
2753
+ const lastTypeIndexByPrimary = /* @__PURE__ */ new Map();
2754
+ const refocusTapIndexesByPrimary = /* @__PURE__ */ new Map();
2755
+ const toDrop = /* @__PURE__ */ new Set();
2756
+ afterAdjacentDupes.forEach((step, index) => {
2757
+ if (step.action === "assertVisible" || step.action === "assertText" || step.action === "waitForElement") {
2758
+ lastTypeIndexByPrimary.clear();
2759
+ refocusTapIndexesByPrimary.clear();
2760
+ return;
2761
+ }
2762
+ if (step.action === "clear") {
2763
+ const primary2 = step.target?.primary;
2764
+ if (primary2) {
2765
+ lastTypeIndexByPrimary.delete(primary2);
2766
+ refocusTapIndexesByPrimary.delete(primary2);
2767
+ }
2768
+ return;
2769
+ }
2770
+ if (isMobileWanderCommitStep(step)) {
2771
+ lastTypeIndexByPrimary.clear();
2772
+ refocusTapIndexesByPrimary.clear();
2773
+ return;
2774
+ }
2775
+ if (step.action === "tap") {
2776
+ const primary2 = step.target?.primary;
2777
+ if (primary2) {
2778
+ const list = refocusTapIndexesByPrimary.get(primary2) ?? [];
2779
+ list.push(index);
2780
+ refocusTapIndexesByPrimary.set(primary2, list);
2781
+ }
2782
+ return;
2783
+ }
2784
+ if (step.action !== "type")
2785
+ return;
2786
+ const primary = step.target?.primary;
2787
+ if (!primary)
2788
+ return;
2789
+ const priorIndex = lastTypeIndexByPrimary.get(primary);
2790
+ if (priorIndex !== void 0) {
2791
+ toDrop.add(priorIndex);
2792
+ removed += 1;
2793
+ trace?.(`dropped superseded type into "${primary}" (a later type into the same field wins)`);
2794
+ for (const tapIndex of refocusTapIndexesByPrimary.get(primary) ?? []) {
2795
+ if (tapIndex > priorIndex && tapIndex < index && !toDrop.has(tapIndex)) {
2796
+ toDrop.add(tapIndex);
2797
+ removed += 1;
2798
+ trace?.(`dropped redundant re-focus tap on "${primary}" (it only re-focused a field whose write was superseded)`);
2799
+ }
2800
+ }
2801
+ }
2802
+ lastTypeIndexByPrimary.set(primary, index);
2803
+ refocusTapIndexesByPrimary.set(primary, []);
2804
+ });
2805
+ if (removed === 0)
2806
+ return 0;
2807
+ const kept = afterAdjacentDupes.filter((_, index) => !toDrop.has(index));
2808
+ steps.splice(0, steps.length, ...kept);
2809
+ renumberMobileSteps(steps);
2810
+ return removed;
2811
+ }
2624
2812
  function generateMobileTestFromSession(input) {
2625
2813
  if (input.medium !== "IOS_NATIVE" && input.medium !== "ANDROID_NATIVE") {
2626
2814
  throw new Error(`Mobile test generation requires IOS_NATIVE or ANDROID_NATIVE medium (received ${input.medium})`);
@@ -2664,12 +2852,20 @@ function generateMobileTestFromSession(input) {
2664
2852
  if (steps.length === 0) {
2665
2853
  throw new Error("No mobile actions were recorded for this session");
2666
2854
  }
2855
+ dropHybridNativeMirrorStepsInPlace(steps);
2856
+ const wanderTraceMessages = [];
2857
+ const wanderTraceSink = (message) => {
2858
+ wanderTraceMessages.push(message);
2859
+ };
2860
+ if (isMobileWanderSimplifyEnabled()) {
2861
+ simplifyMobileWanderingInPlace(steps, mobileWanderTraceEnabled() ? wanderTraceSink : void 0);
2862
+ }
2667
2863
  const interactionSteps = steps.filter((step) => step.action !== "launchApp");
2668
2864
  const runnableInteractions = interactionSteps.filter(isRunnableStep);
2669
2865
  if (interactionSteps.length === 0 || runnableInteractions.length === 0) {
2670
2866
  throw new Error("Generated mobile test has no runnable steps \u2014 re-record the flow so each interaction targets an on-screen element.");
2671
2867
  }
2672
- const warnings = [];
2868
+ const warnings = wanderTraceMessages.map((message) => `[wander] ${message}`);
2673
2869
  const unrunnable = interactionSteps.length - runnableInteractions.length;
2674
2870
  if (unrunnable > 0) {
2675
2871
  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.`);
@@ -6226,6 +6422,7 @@ __export(dist_exports, {
6226
6422
  isEnvironmentalError: () => isEnvironmentalError,
6227
6423
  isIdentityMobileXpath: () => isIdentityMobileXpath,
6228
6424
  isMobilePlatform: () => isMobilePlatform,
6425
+ isMobileWanderSimplifyEnabled: () => isMobileWanderSimplifyEnabled,
6229
6426
  isPricingPlan: () => isPricingPlan,
6230
6427
  isRunnableStep: () => isRunnableStep,
6231
6428
  isSafeDeepLink: () => isSafeDeepLink,
@@ -6247,6 +6444,7 @@ __export(dist_exports, {
6247
6444
  mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
6248
6445
  mobileLocatorBundleHasIdentity: () => mobileLocatorBundleHasIdentity,
6249
6446
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
6447
+ mobileWanderTraceEnabled: () => mobileWanderTraceEnabled,
6250
6448
  normalizeControlName: () => normalizeControlName,
6251
6449
  normalizeTtsVoiceId: () => normalizeTtsVoiceId,
6252
6450
  otherMobilePlatform: () => otherMobilePlatform,
@@ -6275,6 +6473,7 @@ __export(dist_exports, {
6275
6473
  scrubUrlPropertyBag: () => scrubUrlPropertyBag,
6276
6474
  selectorCandidatesForStep: () => selectorCandidatesForStep,
6277
6475
  selectorForValue: () => selectorForValue,
6476
+ simplifyMobileWanderingInPlace: () => simplifyMobileWanderingInPlace,
6278
6477
  stepAppliesToPlatform: () => stepAppliesToPlatform,
6279
6478
  stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
6280
6479
  stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
@@ -10561,7 +10760,7 @@ var require_sonic_boom = __commonJS({
10561
10760
  if (!(this instanceof SonicBoom)) {
10562
10761
  return new SonicBoom(opts);
10563
10762
  }
10564
- let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir: mkdir2, retryEAGAIN, fsync, contentMode, mode } = opts || {};
10763
+ let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir: mkdir3, retryEAGAIN, fsync, contentMode, mode } = opts || {};
10565
10764
  fd = fd || dest;
10566
10765
  this._len = 0;
10567
10766
  this.fd = -1;
@@ -10586,7 +10785,7 @@ var require_sonic_boom = __commonJS({
10586
10785
  this.append = append || false;
10587
10786
  this.mode = mode;
10588
10787
  this.retryEAGAIN = retryEAGAIN || (() => true);
10589
- this.mkdir = mkdir2 || false;
10788
+ this.mkdir = mkdir3 || false;
10590
10789
  let fsWriteSync;
10591
10790
  let fsWrite;
10592
10791
  if (contentMode === kContentModeBuffer) {
@@ -11298,7 +11497,7 @@ var require_thread_stream = __commonJS({
11298
11497
  var { version } = require_package();
11299
11498
  var { EventEmitter: EventEmitter2 } = __require("events");
11300
11499
  var { Worker } = __require("worker_threads");
11301
- var { join: join7 } = __require("path");
11500
+ var { join: join8 } = __require("path");
11302
11501
  var { pathToFileURL } = __require("url");
11303
11502
  var { wait } = require_wait();
11304
11503
  var {
@@ -11349,7 +11548,7 @@ var require_thread_stream = __commonJS({
11349
11548
  function createWorker(stream, opts) {
11350
11549
  const { filename, workerData } = opts;
11351
11550
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
11352
- const toExecute = bundlerOverrides["thread-stream-worker"] || join7(__dirname, "lib", "worker.js");
11551
+ const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11353
11552
  const worker = new Worker(toExecute, {
11354
11553
  ...opts.workerOpts,
11355
11554
  name: opts.workerOpts?.name || "thread-stream",
@@ -11817,7 +12016,7 @@ var require_transport = __commonJS({
11817
12016
  var { createRequire } = __require("module");
11818
12017
  var { existsSync: existsSync3 } = __require("fs");
11819
12018
  var getCallers = require_caller();
11820
- var { join: join7, isAbsolute, sep } = __require("path");
12019
+ var { join: join8, isAbsolute: isAbsolute2, sep } = __require("path");
11821
12020
  var { fileURLToPath } = __require("url");
11822
12021
  var sleep = require_atomic_sleep();
11823
12022
  var onExit = require_on_exit_leak_free();
@@ -11889,7 +12088,7 @@ var require_transport = __commonJS({
11889
12088
  return false;
11890
12089
  }
11891
12090
  }
11892
- return isAbsolute(path) && !existsSync3(path);
12091
+ return isAbsolute2(path) && !existsSync3(path);
11893
12092
  }
11894
12093
  function stripQuotes(value) {
11895
12094
  const first = value[0];
@@ -11970,7 +12169,7 @@ var require_transport = __commonJS({
11970
12169
  throw new Error("only one of target or targets can be specified");
11971
12170
  }
11972
12171
  if (targets) {
11973
- target = bundlerOverrides["pino-worker"] || join7(__dirname, "worker.js");
12172
+ target = bundlerOverrides["pino-worker"] || join8(__dirname, "worker.js");
11974
12173
  options.targets = targets.filter((dest) => dest.target).map((dest) => {
11975
12174
  return {
11976
12175
  ...dest,
@@ -11988,7 +12187,7 @@ var require_transport = __commonJS({
11988
12187
  });
11989
12188
  });
11990
12189
  } else if (pipeline) {
11991
- target = bundlerOverrides["pino-worker"] || join7(__dirname, "worker.js");
12190
+ target = bundlerOverrides["pino-worker"] || join8(__dirname, "worker.js");
11992
12191
  options.pipelines = [pipeline.map((dest) => {
11993
12192
  return {
11994
12193
  ...dest,
@@ -12007,11 +12206,11 @@ var require_transport = __commonJS({
12007
12206
  return buildStream(fixTarget(target), options, worker, sync, name);
12008
12207
  function fixTarget(origin) {
12009
12208
  origin = bundlerOverrides[origin] || origin;
12010
- if (isAbsolute(origin) || origin.indexOf("file://") === 0) {
12209
+ if (isAbsolute2(origin) || origin.indexOf("file://") === 0) {
12011
12210
  return origin;
12012
12211
  }
12013
12212
  if (origin === "pino/file") {
12014
- return join7(__dirname, "..", "file.js");
12213
+ return join8(__dirname, "..", "file.js");
12015
12214
  }
12016
12215
  let fixTarget2;
12017
12216
  for (const filePath of callers) {
@@ -12992,7 +13191,7 @@ var require_safe_stable_stringify = __commonJS({
12992
13191
  return circularValue;
12993
13192
  }
12994
13193
  let res = "";
12995
- let join7 = ",";
13194
+ let join8 = ",";
12996
13195
  const originalIndentation = indentation;
12997
13196
  if (Array.isArray(value)) {
12998
13197
  if (value.length === 0) {
@@ -13006,7 +13205,7 @@ var require_safe_stable_stringify = __commonJS({
13006
13205
  indentation += spacer;
13007
13206
  res += `
13008
13207
  ${indentation}`;
13009
- join7 = `,
13208
+ join8 = `,
13010
13209
  ${indentation}`;
13011
13210
  }
13012
13211
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -13014,13 +13213,13 @@ ${indentation}`;
13014
13213
  for (; i < maximumValuesToStringify - 1; i++) {
13015
13214
  const tmp2 = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
13016
13215
  res += tmp2 !== void 0 ? tmp2 : "null";
13017
- res += join7;
13216
+ res += join8;
13018
13217
  }
13019
13218
  const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
13020
13219
  res += tmp !== void 0 ? tmp : "null";
13021
13220
  if (value.length - 1 > maximumBreadth) {
13022
13221
  const removedKeys = value.length - maximumBreadth - 1;
13023
- res += `${join7}"... ${getItemCount(removedKeys)} not stringified"`;
13222
+ res += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
13024
13223
  }
13025
13224
  if (spacer !== "") {
13026
13225
  res += `
@@ -13041,7 +13240,7 @@ ${originalIndentation}`;
13041
13240
  let separator = "";
13042
13241
  if (spacer !== "") {
13043
13242
  indentation += spacer;
13044
- join7 = `,
13243
+ join8 = `,
13045
13244
  ${indentation}`;
13046
13245
  whitespace = " ";
13047
13246
  }
@@ -13055,13 +13254,13 @@ ${indentation}`;
13055
13254
  const tmp = stringifyFnReplacer(key2, value, stack, replacer, spacer, indentation);
13056
13255
  if (tmp !== void 0) {
13057
13256
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
13058
- separator = join7;
13257
+ separator = join8;
13059
13258
  }
13060
13259
  }
13061
13260
  if (keyLength > maximumBreadth) {
13062
13261
  const removedKeys = keyLength - maximumBreadth;
13063
13262
  res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"`;
13064
- separator = join7;
13263
+ separator = join8;
13065
13264
  }
13066
13265
  if (spacer !== "" && separator.length > 1) {
13067
13266
  res = `
@@ -13102,7 +13301,7 @@ ${originalIndentation}`;
13102
13301
  }
13103
13302
  const originalIndentation = indentation;
13104
13303
  let res = "";
13105
- let join7 = ",";
13304
+ let join8 = ",";
13106
13305
  if (Array.isArray(value)) {
13107
13306
  if (value.length === 0) {
13108
13307
  return "[]";
@@ -13115,7 +13314,7 @@ ${originalIndentation}`;
13115
13314
  indentation += spacer;
13116
13315
  res += `
13117
13316
  ${indentation}`;
13118
- join7 = `,
13317
+ join8 = `,
13119
13318
  ${indentation}`;
13120
13319
  }
13121
13320
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -13123,13 +13322,13 @@ ${indentation}`;
13123
13322
  for (; i < maximumValuesToStringify - 1; i++) {
13124
13323
  const tmp2 = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
13125
13324
  res += tmp2 !== void 0 ? tmp2 : "null";
13126
- res += join7;
13325
+ res += join8;
13127
13326
  }
13128
13327
  const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
13129
13328
  res += tmp !== void 0 ? tmp : "null";
13130
13329
  if (value.length - 1 > maximumBreadth) {
13131
13330
  const removedKeys = value.length - maximumBreadth - 1;
13132
- res += `${join7}"... ${getItemCount(removedKeys)} not stringified"`;
13331
+ res += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
13133
13332
  }
13134
13333
  if (spacer !== "") {
13135
13334
  res += `
@@ -13142,7 +13341,7 @@ ${originalIndentation}`;
13142
13341
  let whitespace = "";
13143
13342
  if (spacer !== "") {
13144
13343
  indentation += spacer;
13145
- join7 = `,
13344
+ join8 = `,
13146
13345
  ${indentation}`;
13147
13346
  whitespace = " ";
13148
13347
  }
@@ -13151,7 +13350,7 @@ ${indentation}`;
13151
13350
  const tmp = stringifyArrayReplacer(key2, value[key2], stack, replacer, spacer, indentation);
13152
13351
  if (tmp !== void 0) {
13153
13352
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
13154
- separator = join7;
13353
+ separator = join8;
13155
13354
  }
13156
13355
  }
13157
13356
  if (spacer !== "" && separator.length > 1) {
@@ -13209,20 +13408,20 @@ ${originalIndentation}`;
13209
13408
  indentation += spacer;
13210
13409
  let res2 = `
13211
13410
  ${indentation}`;
13212
- const join8 = `,
13411
+ const join9 = `,
13213
13412
  ${indentation}`;
13214
13413
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
13215
13414
  let i = 0;
13216
13415
  for (; i < maximumValuesToStringify - 1; i++) {
13217
13416
  const tmp2 = stringifyIndent(String(i), value[i], stack, spacer, indentation);
13218
13417
  res2 += tmp2 !== void 0 ? tmp2 : "null";
13219
- res2 += join8;
13418
+ res2 += join9;
13220
13419
  }
13221
13420
  const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation);
13222
13421
  res2 += tmp !== void 0 ? tmp : "null";
13223
13422
  if (value.length - 1 > maximumBreadth) {
13224
13423
  const removedKeys = value.length - maximumBreadth - 1;
13225
- res2 += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
13424
+ res2 += `${join9}"... ${getItemCount(removedKeys)} not stringified"`;
13226
13425
  }
13227
13426
  res2 += `
13228
13427
  ${originalIndentation}`;
@@ -13238,16 +13437,16 @@ ${originalIndentation}`;
13238
13437
  return '"[Object]"';
13239
13438
  }
13240
13439
  indentation += spacer;
13241
- const join7 = `,
13440
+ const join8 = `,
13242
13441
  ${indentation}`;
13243
13442
  let res = "";
13244
13443
  let separator = "";
13245
13444
  let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth);
13246
13445
  if (isTypedArrayWithEntries(value)) {
13247
- res += stringifyTypedArray(value, join7, maximumBreadth);
13446
+ res += stringifyTypedArray(value, join8, maximumBreadth);
13248
13447
  keys = keys.slice(value.length);
13249
13448
  maximumPropertiesToStringify -= value.length;
13250
- separator = join7;
13449
+ separator = join8;
13251
13450
  }
13252
13451
  if (deterministic) {
13253
13452
  keys = sort(keys, comparator);
@@ -13258,13 +13457,13 @@ ${indentation}`;
13258
13457
  const tmp = stringifyIndent(key2, value[key2], stack, spacer, indentation);
13259
13458
  if (tmp !== void 0) {
13260
13459
  res += `${separator}${strEscape(key2)}: ${tmp}`;
13261
- separator = join7;
13460
+ separator = join8;
13262
13461
  }
13263
13462
  }
13264
13463
  if (keyLength > maximumBreadth) {
13265
13464
  const removedKeys = keyLength - maximumBreadth;
13266
13465
  res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"`;
13267
- separator = join7;
13466
+ separator = join8;
13268
13467
  }
13269
13468
  if (separator !== "") {
13270
13469
  res = `
@@ -61940,11 +62139,11 @@ var require_core = __commonJS({
61940
62139
  function mapOuter(r, f) {
61941
62140
  return r.matched ? f(r) : r;
61942
62141
  }
61943
- function ab(pa, pb, join7) {
61944
- return (data, i) => mapOuter(pa(data, i), (ma) => mapInner(pb(data, ma.position), (vb, j) => join7(ma.value, vb, data, i, j)));
62142
+ function ab(pa, pb, join8) {
62143
+ return (data, i) => mapOuter(pa(data, i), (ma) => mapInner(pb(data, ma.position), (vb, j) => join8(ma.value, vb, data, i, j)));
61945
62144
  }
61946
- function abc(pa, pb, pc, join7) {
61947
- 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))));
62145
+ function abc(pa, pb, pc, join8) {
62146
+ 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))));
61948
62147
  }
61949
62148
  function action(f) {
61950
62149
  return (data, i) => {
@@ -81172,10 +81371,14 @@ var require_mobile_generator = __commonJS({
81172
81371
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
81173
81372
  "use strict";
81174
81373
  Object.defineProperty(exports, "__esModule", { value: true });
81374
+ exports.structuralScreenKey = structuralScreenKey;
81175
81375
  exports.collapseConsecutiveBacks = collapseConsecutiveBacks;
81176
81376
  exports.scenarioIntentMismatchReason = scenarioIntentMismatchReason;
81177
81377
  exports.dedupeConsecutiveIdenticalActions = dedupeConsecutiveIdenticalActions;
81178
81378
  exports.collapseRetryCycles = collapseRetryCycles;
81379
+ exports.coalesceSameFieldTypes = coalesceSameFieldTypes;
81380
+ exports.coalesceRepeatedTaps = coalesceRepeatedTaps;
81381
+ exports.elideNoOpDetours = elideNoOpDetours;
81179
81382
  exports.mobileActionsToPseudoPlaywright = mobileActionsToPseudoPlaywright;
81180
81383
  exports.evaluateMobileAssertionStrength = evaluateMobileAssertionStrength;
81181
81384
  exports.prepareMobileScenarioAppState = prepareMobileScenarioAppState;
@@ -81384,6 +81587,22 @@ var require_mobile_generator = __commonJS({
81384
81587
  MOBILE_ASSERT_TEXT_TOOL,
81385
81588
  FINISH_GENERATE_TOOL
81386
81589
  ];
81590
+ var STRUCTURAL_KEY_MAX_ELEMENTS = 16;
81591
+ function structuralScreenKey(resolved) {
81592
+ if (!resolved)
81593
+ return null;
81594
+ const nativeElements = resolved.screen.elements;
81595
+ const hybridElements = resolved.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81596
+ if (nativeElements.length + hybridElements.length < 2)
81597
+ return null;
81598
+ 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 ?? ""}`);
81599
+ 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 ?? ""}`);
81600
+ const activityOrBundle = resolved.signal.activity ?? resolved.signal.bundleId ?? "";
81601
+ const payload = `${activityOrBundle}
81602
+ ${nativeSkeleton.join("\n")}
81603
+ ${hybridSkeleton.join("\n")}`;
81604
+ return (0, crypto_1.createHash)("sha1").update(payload).digest("hex");
81605
+ }
81387
81606
  function platformForMedium(medium) {
81388
81607
  if (medium === "IOS_NATIVE")
81389
81608
  return "IOS";
@@ -81545,18 +81764,39 @@ var require_mobile_generator = __commonJS({
81545
81764
  }
81546
81765
  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.`;
81547
81766
  }
81548
- function metadataForElement(element, screenName, platform3) {
81767
+ function alreadyFocusedWithEqualValue(element, value) {
81768
+ return element.focused === true && typeof element.text === "string" && value.length > 0 && element.text === value;
81769
+ }
81770
+ function nativeElementsOf(resolved) {
81771
+ return resolved?.screen.elements ?? [];
81772
+ }
81773
+ function hybridElementsOf(resolved) {
81774
+ return resolved?.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81775
+ }
81776
+ function metadataForElement(element, screenName, platform3, elements = []) {
81777
+ const match = (0, mobile_source_parser_js_2.identityMatchInfo)(element, elements);
81549
81778
  return {
81550
81779
  screenName,
81551
81780
  targetLabel: labelForElement(element),
81552
81781
  locatorBundle: (0, mobile_source_parser_js_2.buildLocatorBundle)(element, platform3),
81553
- bounds: element.bounds
81782
+ bounds: element.bounds,
81783
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81554
81784
  };
81555
81785
  }
81556
- function metadataForHybridElement(element, screenName) {
81786
+ function hybridIdentityMatchInfo(element, elements) {
81787
+ const hybridIdentityValue = (candidate) => nonEmpty3(candidate.id) ?? (candidate.editable === true ? void 0 : nonEmpty3(candidate.text));
81788
+ const chosenValue = hybridIdentityValue(element);
81789
+ if (!chosenValue)
81790
+ return void 0;
81791
+ const sameValue = elements.filter((candidate) => hybridIdentityValue(candidate) === chosenValue);
81792
+ const matchIndex = sameValue.indexOf(element);
81793
+ return matchIndex >= 0 && sameValue.length > 1 ? { matchIndex, expectedMatchCount: sameValue.length } : void 0;
81794
+ }
81795
+ function metadataForHybridElement(element, screenName, elements = []) {
81557
81796
  const editable = isEditableHybridElement(element);
81558
81797
  const label = labelForHybridElement(element);
81559
81798
  const safeText = editable ? void 0 : nonEmpty3(element.text);
81799
+ const match = hybridIdentityMatchInfo(element, elements);
81560
81800
  return {
81561
81801
  screenName,
81562
81802
  targetLabel: label,
@@ -81565,11 +81805,16 @@ var require_mobile_generator = __commonJS({
81565
81805
  webviewContext: element.webviewContext,
81566
81806
  bounds: element.bounds,
81567
81807
  locatorBundle: {
81568
- xpath: `css=${element.css}`,
81808
+ // WebView controls resolve via `webCss` (above) in the WEBVIEW context, not
81809
+ // by xpath. `LocatorBundle.xpath` is contractually an XPath, so never store a
81810
+ // `css=` selector here — an empty xpath keeps the fallback chain honest and
81811
+ // prevents the css selector being misread as an xpath at replay.
81812
+ xpath: "",
81569
81813
  className: element.tag || "webview",
81570
81814
  ...element.id ? { accessibilityId: element.id } : {},
81571
81815
  ...safeText ? { text: safeText } : {}
81572
- }
81816
+ },
81817
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81573
81818
  };
81574
81819
  }
81575
81820
  function isEditableHybridElement(element) {
@@ -82287,7 +82532,15 @@ ${statePacket}`, redactionCredentials) },
82287
82532
  screenName: () => lastScreenName || "UnknownScreen",
82288
82533
  recordAction: (action) => {
82289
82534
  const screenHash = getLatest()?.signal.screenHash;
82290
- const stamped = screenHash && !action.metadata.screenHash ? { ...action, metadata: { ...action.metadata, screenHash } } : action;
82535
+ const structuralKey = structuralScreenKey(getLatest());
82536
+ const stamped = screenHash && !action.metadata.screenHash || structuralKey && !action.metadata.structuralKey ? {
82537
+ ...action,
82538
+ metadata: {
82539
+ ...action.metadata,
82540
+ ...screenHash && !action.metadata.screenHash ? { screenHash } : {},
82541
+ ...structuralKey && !action.metadata.structuralKey ? { structuralKey } : {}
82542
+ }
82543
+ } : action;
82291
82544
  actions.push((0, shared_1.placeholderizeRecordedMobileAction)(finalizeAction(stamped), credentials));
82292
82545
  if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
82293
82546
  assertCount++;
@@ -82393,7 +82646,7 @@ ${statePacket}`, redactionCredentials) },
82393
82646
  const result2 = await driver.webTap(hybridTap.webviewContext, hybridTap.css);
82394
82647
  const absorbed2 = await absorbActionResult(result2, "mobile_tap(webview)", opensAuthenticationSurface(hybridTap));
82395
82648
  if (shouldRecordAction(result2, absorbed2)) {
82396
- const metadata = metadataForHybridElement(hybridTap, beforeScreenName2);
82649
+ const metadata = metadataForHybridElement(hybridTap, beforeScreenName2, hybridElementsOf(before2));
82397
82650
  const label = metadata.targetLabel ?? hybridTap.css;
82398
82651
  const tapAction = {
82399
82652
  type: "TAP",
@@ -82445,7 +82698,7 @@ ${statePacket}`, redactionCredentials) },
82445
82698
  const tapAction = {
82446
82699
  type: "TAP",
82447
82700
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82448
- metadata: metadataForElement(element, beforeScreenName, platform3),
82701
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before)),
82449
82702
  innerText: null,
82450
82703
  url: "",
82451
82704
  value: null
@@ -82485,10 +82738,11 @@ ${statePacket}`, redactionCredentials) },
82485
82738
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82486
82739
  }
82487
82740
  const beforeScreenName2 = screenName();
82741
+ const before2 = latest();
82488
82742
  const result2 = await driver.webType(hybridType.webviewContext, hybridType.css, value);
82489
82743
  const absorbed2 = await absorbActionResult(result2, "mobile_type(webview)");
82490
82744
  if (shouldRecordAction(result2, absorbed2)) {
82491
- const metadata = metadataForHybridElement(hybridType, beforeScreenName2);
82745
+ const metadata = metadataForHybridElement(hybridType, beforeScreenName2, hybridElementsOf(before2));
82492
82746
  const label = metadata.targetLabel ?? hybridType.css;
82493
82747
  recordAction({
82494
82748
  type: "TYPE",
@@ -82503,7 +82757,14 @@ ${statePacket}`, redactionCredentials) },
82503
82757
  const element = resolveRef(toolArgs.ref);
82504
82758
  if (!element)
82505
82759
  return unknownRef(toolArgs.ref);
82760
+ if (alreadyFocusedWithEqualValue(element, value)) {
82761
+ return {
82762
+ ok: false,
82763
+ 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.`
82764
+ };
82765
+ }
82506
82766
  const beforeScreenName = screenName();
82767
+ const before = latest();
82507
82768
  const result = await driver.type(value, element.bounds);
82508
82769
  const absorbed = await absorbActionResult(result, "mobile_type");
82509
82770
  if (shouldRecordAction(result, absorbed)) {
@@ -82511,7 +82772,7 @@ ${statePacket}`, redactionCredentials) },
82511
82772
  type: "TYPE",
82512
82773
  value,
82513
82774
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82514
- metadata: metadataForElement(element, beforeScreenName, platform3)
82775
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82515
82776
  });
82516
82777
  }
82517
82778
  const payload = actionPayload(result, absorbed);
@@ -82529,6 +82790,7 @@ ${statePacket}`, redactionCredentials) },
82529
82790
  return { ok: false, error: "This driver does not support non-clearing WEBVIEW keyboard submit." };
82530
82791
  }
82531
82792
  const beforeScreenName2 = screenName();
82793
+ const before2 = latest();
82532
82794
  const result2 = await driver.webSubmitKeyboard(hybridField.webviewContext, hybridField.css);
82533
82795
  const absorbed2 = await absorbActionResult(result2, "mobile_submit_keyboard(webview)");
82534
82796
  if (shouldRecordAction(result2, absorbed2)) {
@@ -82536,7 +82798,7 @@ ${statePacket}`, redactionCredentials) },
82536
82798
  type: "TYPE",
82537
82799
  value: "\n",
82538
82800
  selector: `css=${hybridField.css}`,
82539
- metadata: metadataForHybridElement(hybridField, beforeScreenName2)
82801
+ metadata: metadataForHybridElement(hybridField, beforeScreenName2, hybridElementsOf(before2))
82540
82802
  });
82541
82803
  }
82542
82804
  return actionPayload(result2, absorbed2);
@@ -82548,6 +82810,7 @@ ${statePacket}`, redactionCredentials) },
82548
82810
  return { ok: false, error: "This driver does not support non-clearing keyboard submit." };
82549
82811
  }
82550
82812
  const beforeScreenName = screenName();
82813
+ const before = latest();
82551
82814
  const result = await driver.submitKeyboard(element.bounds);
82552
82815
  const absorbed = await absorbActionResult(result, "mobile_submit_keyboard");
82553
82816
  if (shouldRecordAction(result, absorbed)) {
@@ -82555,7 +82818,7 @@ ${statePacket}`, redactionCredentials) },
82555
82818
  type: "TYPE",
82556
82819
  value: "\n",
82557
82820
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82558
- metadata: metadataForElement(element, beforeScreenName, platform3)
82821
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82559
82822
  });
82560
82823
  }
82561
82824
  return actionPayload(result, absorbed);
@@ -82580,10 +82843,11 @@ ${statePacket}`, redactionCredentials) },
82580
82843
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82581
82844
  }
82582
82845
  const beforeScreenName2 = screenName();
82846
+ const before2 = latest();
82583
82847
  const result2 = await driver.webType(hybridFill.webviewContext, hybridFill.css, secret);
82584
82848
  const absorbed2 = await absorbActionResult(result2, "mobile_fill_credential(webview)");
82585
82849
  if (shouldRecordAction(result2, absorbed2)) {
82586
- const metadata = metadataForHybridElement(hybridFill, beforeScreenName2);
82850
+ const metadata = metadataForHybridElement(hybridFill, beforeScreenName2, hybridElementsOf(before2));
82587
82851
  const label = metadata.targetLabel ?? hybridFill.css;
82588
82852
  recordAction({
82589
82853
  type: "TYPE",
@@ -82598,7 +82862,14 @@ ${statePacket}`, redactionCredentials) },
82598
82862
  const element = resolveRef(toolArgs.ref);
82599
82863
  if (!element)
82600
82864
  return unknownRef(toolArgs.ref);
82865
+ if (alreadyFocusedWithEqualValue(element, secret)) {
82866
+ return {
82867
+ ok: false,
82868
+ 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.`
82869
+ };
82870
+ }
82601
82871
  const beforeScreenName = screenName();
82872
+ const before = latest();
82602
82873
  const result = await driver.type(secret, element.bounds);
82603
82874
  const absorbed = await absorbActionResult(result, "mobile_fill_credential");
82604
82875
  if (shouldRecordAction(result, absorbed)) {
@@ -82606,7 +82877,7 @@ ${statePacket}`, redactionCredentials) },
82606
82877
  type: "TYPE",
82607
82878
  value: (0, shared_1.mobileCredentialPlaceholder)(key),
82608
82879
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82609
- metadata: metadataForElement(element, beforeScreenName, platform3)
82880
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82610
82881
  });
82611
82882
  log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
82612
82883
  }
@@ -82634,13 +82905,14 @@ ${statePacket}`, redactionCredentials) },
82634
82905
  if (!element)
82635
82906
  return unknownRef(toolArgs.ref);
82636
82907
  const beforeScreenName = screenName();
82908
+ const before = latest();
82637
82909
  const result = await driver.clear(element.bounds);
82638
82910
  const absorbed = await absorbActionResult(result, "mobile_clear");
82639
82911
  if (shouldRecordAction(result, absorbed)) {
82640
82912
  recordAction({
82641
82913
  type: "CLEAR",
82642
82914
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82643
- metadata: metadataForElement(element, beforeScreenName, platform3)
82915
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82644
82916
  });
82645
82917
  }
82646
82918
  return actionPayload(result, absorbed);
@@ -82703,7 +82975,7 @@ ${statePacket}`, redactionCredentials) },
82703
82975
  case "mobile_assert_visible": {
82704
82976
  const hybrid = resolveHybridRef(toolArgs.ref);
82705
82977
  if (hybrid) {
82706
- const metadata = metadataForHybridElement(hybrid, screenName());
82978
+ const metadata = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82707
82979
  recordAction({
82708
82980
  type: "ASSERT_VISIBLE",
82709
82981
  selector: `css=${hybrid.css}`,
@@ -82743,7 +83015,7 @@ ${statePacket}`, redactionCredentials) },
82743
83015
  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.`
82744
83016
  };
82745
83017
  }
82746
- const metadata2 = metadataForHybridElement(hybrid, screenName());
83018
+ const metadata2 = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82747
83019
  const stableWebIdentity = Boolean(nonEmpty3(hybrid.id) || nonEmpty3(hybrid.name));
82748
83020
  if (stableWebIdentity) {
82749
83021
  recordAction({
@@ -82796,7 +83068,7 @@ ${statePacket}`, redactionCredentials) },
82796
83068
  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.`
82797
83069
  };
82798
83070
  }
82799
- const metadata = metadataForElement(element, screenName(), platform3);
83071
+ const metadata = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82800
83072
  const expectedNeedle = expected.trim().toLowerCase();
82801
83073
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
82802
83074
  const visibleText = (element.text ?? "").trim().toLowerCase();
@@ -82842,7 +83114,7 @@ ${statePacket}`, redactionCredentials) },
82842
83114
  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.`
82843
83115
  };
82844
83116
  }
82845
- const metadata2 = metadataForElement(element, screenName(), platform3);
83117
+ const metadata2 = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82846
83118
  recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata: metadata2 });
82847
83119
  return { ok: true, label: metadata2.targetLabel ?? "element" };
82848
83120
  }
@@ -82892,7 +83164,7 @@ ${statePacket}`, redactionCredentials) },
82892
83164
  error: `The live element matching ${JSON.stringify(accessibilityId ?? text)} is not a durable assertion target (${weakReason}).`
82893
83165
  };
82894
83166
  }
82895
- const metadata = metadataForElement(liveElement, screenName(), platform3);
83167
+ const metadata = metadataForElement(liveElement, screenName(), platform3, live.screen.elements);
82896
83168
  recordAction({
82897
83169
  type: "ASSERT_VISIBLE",
82898
83170
  selector: (0, mobile_source_parser_js_2.preferredLocator)(liveElement),
@@ -83020,7 +83292,7 @@ ${statePacket}`, redactionCredentials) },
83020
83292
  actions.push(finalizeAction({
83021
83293
  type: "ASSERT_VISIBLE",
83022
83294
  selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83023
- metadata: metadataForElement(anchor, screen, platform3)
83295
+ metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83024
83296
  }));
83025
83297
  return true;
83026
83298
  }
@@ -83363,6 +83635,153 @@ ${statePacket}`, redactionCredentials) },
83363
83635
  }
83364
83636
  return out;
83365
83637
  }
83638
+ function isDurableMutation(action) {
83639
+ if (action.type === "TYPE" || action.type === "CLEAR")
83640
+ return true;
83641
+ if (action.type === "TAP")
83642
+ return (0, shared_1.isStateValueMobileClassName)(action.metadata.locatorBundle?.className);
83643
+ return false;
83644
+ }
83645
+ function mobileWanderTraceEnabled2() {
83646
+ const v = (process.env.VALIDATEQA_MOBILE_WANDER_TRACE || "").trim().toLowerCase();
83647
+ return v === "true" || v === "1";
83648
+ }
83649
+ function wanderTraceLabel(action) {
83650
+ const identity = action.metadata.targetLabel || action.selector || action.metadata.locatorBundle?.accessibilityId || action.metadata.locatorBundle?.resourceId || "(no identity)";
83651
+ return `${action.type} ${identity}`;
83652
+ }
83653
+ function logWanderDiff(pass, before, after, log2) {
83654
+ if (after.length >= before.length)
83655
+ return;
83656
+ log2(` ${pass}: collapsed ${before.length - after.length} step(s) (${before.length} \u2192 ${after.length}).`);
83657
+ if (!mobileWanderTraceEnabled2())
83658
+ return;
83659
+ const survivors = new Set(after);
83660
+ for (const action of before) {
83661
+ if (!survivors.has(action)) {
83662
+ log2(` [wander] ${pass}: dropped ${wanderTraceLabel(action)}`);
83663
+ }
83664
+ }
83665
+ }
83666
+ function coalesceSameFieldTypes(actions) {
83667
+ if (actions.length <= 1)
83668
+ return actions;
83669
+ const toDrop = /* @__PURE__ */ new Set();
83670
+ const lastTypeIndexBySelector = /* @__PURE__ */ new Map();
83671
+ const refocusTapIndexesBySelector = /* @__PURE__ */ new Map();
83672
+ for (let i = 0; i < actions.length; i++) {
83673
+ const current = actions[i];
83674
+ if (current.type === "ASSERT_VISIBLE" || current.type === "ASSERT_TEXT") {
83675
+ lastTypeIndexBySelector.clear();
83676
+ refocusTapIndexesBySelector.clear();
83677
+ continue;
83678
+ }
83679
+ if (current.type === "CLEAR") {
83680
+ lastTypeIndexBySelector.delete(current.selector);
83681
+ refocusTapIndexesBySelector.delete(current.selector);
83682
+ continue;
83683
+ }
83684
+ if (isFormCommitAction(current)) {
83685
+ lastTypeIndexBySelector.clear();
83686
+ refocusTapIndexesBySelector.clear();
83687
+ continue;
83688
+ }
83689
+ if (current.type === "TAP") {
83690
+ if (current.selector) {
83691
+ const list = refocusTapIndexesBySelector.get(current.selector) ?? [];
83692
+ list.push(i);
83693
+ refocusTapIndexesBySelector.set(current.selector, list);
83694
+ }
83695
+ continue;
83696
+ }
83697
+ if (current.type !== "TYPE" || !current.selector)
83698
+ continue;
83699
+ const priorIndex = lastTypeIndexBySelector.get(current.selector);
83700
+ if (priorIndex !== void 0) {
83701
+ toDrop.add(priorIndex);
83702
+ for (const tapIndex of refocusTapIndexesBySelector.get(current.selector) ?? []) {
83703
+ if (tapIndex > priorIndex && tapIndex < i)
83704
+ toDrop.add(tapIndex);
83705
+ }
83706
+ }
83707
+ lastTypeIndexBySelector.set(current.selector, i);
83708
+ refocusTapIndexesBySelector.set(current.selector, []);
83709
+ }
83710
+ if (toDrop.size === 0)
83711
+ return actions;
83712
+ return actions.filter((_, index) => !toDrop.has(index));
83713
+ }
83714
+ function coalesceRepeatedTaps(actions) {
83715
+ if (actions.length <= 1)
83716
+ return actions;
83717
+ const toDrop = /* @__PURE__ */ new Set();
83718
+ const lastTapIndexBySelector = /* @__PURE__ */ new Map();
83719
+ for (let i = 0; i < actions.length; i++) {
83720
+ const action = actions[i];
83721
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT") {
83722
+ lastTapIndexBySelector.clear();
83723
+ continue;
83724
+ }
83725
+ if (isDurableMutation(action)) {
83726
+ lastTapIndexBySelector.clear();
83727
+ continue;
83728
+ }
83729
+ if (action.type !== "TAP" || !action.selector)
83730
+ continue;
83731
+ const key = action.metadata.structuralKey;
83732
+ const priorIndex = lastTapIndexBySelector.get(action.selector);
83733
+ if (priorIndex !== void 0 && key && actions[priorIndex].metadata.structuralKey === key) {
83734
+ const priorHash = actions[priorIndex].metadata.screenHash;
83735
+ const currentHash = action.metadata.screenHash;
83736
+ const screenAdvanced = !!priorHash && !!currentHash && priorHash !== currentHash;
83737
+ if (!screenAdvanced) {
83738
+ toDrop.add(priorIndex);
83739
+ }
83740
+ }
83741
+ lastTapIndexBySelector.set(action.selector, i);
83742
+ }
83743
+ if (toDrop.size === 0)
83744
+ return actions;
83745
+ return actions.filter((_, index) => !toDrop.has(index));
83746
+ }
83747
+ var MAX_DETOUR_LENGTH = 6;
83748
+ function elideNoOpDetours(actions) {
83749
+ if (actions.length < 2)
83750
+ return actions;
83751
+ const toDrop = /* @__PURE__ */ new Set();
83752
+ for (let j = 0; j < actions.length; j++) {
83753
+ if (toDrop.has(j))
83754
+ continue;
83755
+ if (actions[j].type !== "BACK" && actions[j].type !== "HOME")
83756
+ continue;
83757
+ const keyAfter = actions[j].metadata.structuralKey;
83758
+ if (!keyAfter)
83759
+ continue;
83760
+ let bestStart = null;
83761
+ const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83762
+ for (let i = j; i >= earliestStart; i--) {
83763
+ if (i < j) {
83764
+ const inside = actions[i];
83765
+ if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83766
+ break;
83767
+ }
83768
+ }
83769
+ if (i === 0)
83770
+ continue;
83771
+ const keyBefore = actions[i - 1].metadata.structuralKey;
83772
+ if (i < j && keyBefore && keyBefore === keyAfter) {
83773
+ bestStart = i;
83774
+ }
83775
+ }
83776
+ if (bestStart !== null) {
83777
+ for (let k = bestStart; k <= j; k++)
83778
+ toDrop.add(k);
83779
+ }
83780
+ }
83781
+ if (toDrop.size === 0)
83782
+ return actions;
83783
+ return actions.filter((_, index) => !toDrop.has(index));
83784
+ }
83366
83785
  function mobileActionsToPseudoPlaywright(actions) {
83367
83786
  const escape = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
83368
83787
  const lines = ["test('mobile', async ({ page }) => {"];
@@ -83450,6 +83869,15 @@ ${statePacket}`, redactionCredentials) },
83450
83869
  if (actions.length < beforeCycleCollapse) {
83451
83870
  log2(` collapsed ${beforeCycleCollapse - actions.length} retry-cycle step(s) out of the recorded trace (${beforeCycleCollapse} \u2192 ${actions.length}).`);
83452
83871
  }
83872
+ const beforeTypeCoalesce = actions;
83873
+ actions = coalesceSameFieldTypes(actions);
83874
+ logWanderDiff("coalesceSameFieldTypes", beforeTypeCoalesce, actions, log2);
83875
+ const beforeTapCoalesce = actions;
83876
+ actions = coalesceRepeatedTaps(actions);
83877
+ logWanderDiff("coalesceRepeatedTaps", beforeTapCoalesce, actions, log2);
83878
+ const beforeDetourElision = actions;
83879
+ actions = elideNoOpDetours(actions);
83880
+ logWanderDiff("elideNoOpDetours", beforeDetourElision, actions, log2);
83453
83881
  const beforeContainerDrop = actions.length;
83454
83882
  actions = actions.filter((a) => !isBareContainerTap(a));
83455
83883
  if (actions.length < beforeContainerDrop) {
@@ -83634,48 +84062,29 @@ ${statePacket}`, redactionCredentials) },
83634
84062
  log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
83635
84063
  return applyScenarioEntryDeepLink(driver, target, { appDataCleared: false, coldResetUnavailable: false, refuseGenerate: false, reason: "preserve_policy" }, log2);
83636
84064
  }
83637
- if (platform3 !== "IOS") {
83638
- const appDataCleared = await driver.resetAppState?.().catch((err) => {
83639
- log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
83640
- return false;
83641
- }) ?? false;
83642
- if (appDataCleared) {
83643
- return applyScenarioEntryDeepLink(driver, target, { appDataCleared: true, coldResetUnavailable: false, refuseGenerate: false }, log2);
83644
- }
83645
- await driver.relaunch();
83646
- if (resetPolicy === "CLEAR_APP_DATA") {
83647
- log2(" CLEAR_APP_DATA required but app-data reset was unavailable \u2014 refusing to generate from warm state.");
83648
- return {
83649
- appDataCleared: false,
83650
- coldResetUnavailable: true,
83651
- refuseGenerate: true,
83652
- reason: "clear_app_data_unavailable"
83653
- };
83654
- }
83655
- log2(" app-data reset unavailable \u2014 generating with @warm-state (relaunch only; may not match cold-boot replay).");
83656
- return applyScenarioEntryDeepLink(driver, target, {
83657
- appDataCleared: false,
83658
- coldResetUnavailable: true,
83659
- refuseGenerate: false,
83660
- reason: platform3 === "ANDROID" ? "android_reset_unavailable" : "reset_unavailable"
83661
- }, log2);
84065
+ const appDataCleared = await driver.resetAppState?.().catch((err) => {
84066
+ log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
84067
+ return false;
84068
+ }) ?? false;
84069
+ if (appDataCleared) {
84070
+ return applyScenarioEntryDeepLink(driver, target, { appDataCleared: true, coldResetUnavailable: false, refuseGenerate: false }, log2);
83662
84071
  }
83663
84072
  await driver.relaunch();
83664
84073
  if (resetPolicy === "CLEAR_APP_DATA") {
83665
- log2(" CLEAR_APP_DATA required but iOS has no supported clearApp primitive \u2014 refusing to generate from warm state.");
84074
+ 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.");
83666
84075
  return {
83667
84076
  appDataCleared: false,
83668
84077
  coldResetUnavailable: true,
83669
84078
  refuseGenerate: true,
83670
- reason: "ios_clear_app_data_unavailable"
84079
+ reason: platform3 === "IOS" ? "ios_clear_app_data_unavailable" : "clear_app_data_unavailable"
83671
84080
  };
83672
84081
  }
83673
- log2(" iOS has no clearApp \u2014 scenario records after relaunch (warm-state risk for auth flows).");
84082
+ 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).");
83674
84083
  return applyScenarioEntryDeepLink(driver, target, {
83675
84084
  appDataCleared: false,
83676
84085
  coldResetUnavailable: true,
83677
84086
  refuseGenerate: false,
83678
- reason: "ios_no_clear_app"
84087
+ reason: platform3 === "IOS" ? "ios_no_clear_app" : platform3 === "ANDROID" ? "android_reset_unavailable" : "reset_unavailable"
83679
84088
  }, log2);
83680
84089
  }
83681
84090
  async function runMobileGeneratePhase(args) {
@@ -84278,6 +84687,8 @@ var require_mobile_heal_loop = __commonJS({
84278
84687
  "use strict";
84279
84688
  Object.defineProperty(exports, "__esModule", { value: true });
84280
84689
  exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = void 0;
84690
+ exports.timed = timed;
84691
+ exports.timedSync = timedSync;
84281
84692
  exports.executeMobileScriptDrivenHeal = executeMobileScriptDrivenHeal;
84282
84693
  exports.executeMobileTestWithHealing = executeMobileTestWithHealing2;
84283
84694
  exports.executeMobileLiveHeal = executeMobileLiveHeal2;
@@ -84487,6 +84898,32 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84487
84898
  function detectInvalidMobileAuthSetup(steps, error2) {
84488
84899
  return detectInvalidExternalAuthSetup(steps, error2) ?? detectInvalidAuthValidationSetup(steps, error2);
84489
84900
  }
84901
+ function formatHealTimingLine(category, label, ms, failed, errMessage) {
84902
+ const base2 = `\u{1FA7A}\u23F1 [${category}] ${label} \u2014 ${ms}ms`;
84903
+ return failed ? `${base2} (FAILED: ${errMessage ?? "unknown error"})` : base2;
84904
+ }
84905
+ async function timed(category, label, onProgress, fn) {
84906
+ const start = Date.now();
84907
+ try {
84908
+ const result = await fn();
84909
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84910
+ return result;
84911
+ } catch (err) {
84912
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84913
+ throw err;
84914
+ }
84915
+ }
84916
+ function timedSync(category, label, onProgress, fn) {
84917
+ const start = Date.now();
84918
+ try {
84919
+ const result = fn();
84920
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84921
+ return result;
84922
+ } catch (err) {
84923
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84924
+ throw err;
84925
+ }
84926
+ }
84490
84927
  var IDENTITY_GATE_ERROR_RE = /No identity locator \(accessibility id \/ resource id \/ text\) available for this step/;
84491
84928
  function isIdentityEqualityXpath(value) {
84492
84929
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
@@ -84704,21 +85141,23 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84704
85141
  return JSON.stringify(base2?.target) !== JSON.stringify(healed.target);
84705
85142
  }
84706
85143
  function findMissingChangedLocators(baseSteps, healedSteps, screen) {
84707
- const missing = [];
85144
+ const results = [];
84708
85145
  healedSteps.forEach((healed, index) => {
84709
85146
  if (!stepTargetChanged(baseSteps[index], healed))
84710
85147
  return;
84711
85148
  const candidate = stepToLocatorCandidate(healed);
84712
85149
  if (!candidate)
84713
85150
  return;
84714
- if ((0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length === 0) {
84715
- missing.push({
84716
- label: healed.target?.primary ?? JSON.stringify(candidate),
84717
- expressions: stepIdentityExpressions(healed)
84718
- });
85151
+ const matchCount = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length;
85152
+ const label = healed.target?.primary ?? JSON.stringify(candidate);
85153
+ const expressions = stepIdentityExpressions(healed);
85154
+ if (matchCount === 0) {
85155
+ results.push({ label, expressions, kind: "missing" });
85156
+ } else if (matchCount > 1) {
85157
+ results.push({ label, expressions, kind: "ambiguous", matchCount });
84719
85158
  }
84720
85159
  });
84721
- return missing;
85160
+ return results;
84722
85161
  }
84723
85162
  function changedStepExpressions(baseSteps, healedSteps) {
84724
85163
  const out = [];
@@ -84947,10 +85386,10 @@ ${args.directive}`);
84947
85386
  let content;
84948
85387
  let finishReason;
84949
85388
  try {
84950
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
85389
+ 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)(() => {
84951
85390
  arm();
84952
- return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
84953
- }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
85391
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85392
+ }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true }));
84954
85393
  content = response?.choices?.[0]?.message?.content ?? "";
84955
85394
  finishReason = response?.choices?.[0]?.finish_reason ?? void 0;
84956
85395
  } catch {
@@ -84993,14 +85432,18 @@ ${args.directive}`);
84993
85432
  continue;
84994
85433
  }
84995
85434
  if (activeFailingSource && isLocatorNotFound(activeError) && !isDrift(activeError)) {
84996
- const screen = (0, mobile_source_parser_js_1.parseMobileSource)(activeFailingSource, platform3);
84997
- const missing = findMissingChangedLocators(baseSteps, healedSteps, screen);
85435
+ const failingSourceSnapshot = activeFailingSource;
85436
+ const missing = timedSync("device-op", `phase1-attempt-${attempt}-static-verify`, options.onProgress, () => {
85437
+ const screen = (0, mobile_source_parser_js_1.parseMobileSource)(failingSourceSnapshot, platform3);
85438
+ return findMissingChangedLocators(baseSteps, healedSteps, screen);
85439
+ });
84998
85440
  if (missing.length > 0) {
84999
85441
  for (const entry of missing)
85000
85442
  pushUnique(triedLocators, entry.expressions);
85001
85443
  markRecoverableTurn();
85002
85444
  lastVerdict = "fix";
85003
- 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.`;
85445
+ const first = missing[0];
85446
+ 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.`;
85004
85447
  continue;
85005
85448
  }
85006
85449
  }
@@ -85014,9 +85457,10 @@ ${args.directive}`);
85014
85457
  pendingDirective = "The proposed fix could not be device-verified here. If it is not clearly correct, revise it.";
85015
85458
  continue;
85016
85459
  }
85460
+ const runNativeTest = options.runNativeTest;
85017
85461
  let verify;
85018
85462
  try {
85019
- verify = await options.runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
85463
+ verify = await timed("reexec", `phase1-attempt-${attempt}-device-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables }));
85020
85464
  } catch {
85021
85465
  return {
85022
85466
  passed: false,
@@ -85123,6 +85567,9 @@ ${args.directive}`);
85123
85567
  }
85124
85568
  }
85125
85569
  async function executeMobileTestWithHealing2(steps, target, ctx, options) {
85570
+ return timed("total", "heal-total", options.onProgress, () => executeMobileTestWithHealingImpl(steps, target, ctx, options));
85571
+ }
85572
+ async function executeMobileTestWithHealingImpl(steps, target, ctx, options) {
85126
85573
  const invalidAuthSetup = detectInvalidMobileAuthSetup(steps, ctx.error);
85127
85574
  if (invalidAuthSetup) {
85128
85575
  return { passed: false, verdict: "test_invalid", healReason: invalidAuthSetup };
@@ -85249,9 +85696,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85249
85696
  const candidate = partsToCandidate(parts);
85250
85697
  if (!hasIdentitySignal(candidate))
85251
85698
  return;
85252
- const first = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen)[0];
85253
- if (first)
85254
- await driver.tap(first.bounds);
85699
+ const matches = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen);
85700
+ const target = matches.find((el) => el.bounds.width > 0 && el.bounds.height > 0) ?? matches[0];
85701
+ if (target)
85702
+ await driver.tap(target.bounds);
85255
85703
  }
85256
85704
  function buildLiveUserContent(args) {
85257
85705
  const parts = [];
@@ -85316,7 +85764,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85316
85764
  options.onProgress?.(`Phase 2 pass ${pass}/${maxPasses} \u2014 observing the live screen...`);
85317
85765
  let snap;
85318
85766
  try {
85319
- snap = await liveDriver.snapshot();
85767
+ snap = await timed("device-op", `phase2-pass-${pass}-snapshot`, options.onProgress, () => liveDriver.snapshot());
85320
85768
  } catch {
85321
85769
  return {
85322
85770
  passed: false,
@@ -85342,10 +85790,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85342
85790
  ];
85343
85791
  let content;
85344
85792
  try {
85345
- 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)(() => {
85793
+ 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)(() => {
85346
85794
  arm();
85347
- return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
85348
- }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true }));
85795
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85796
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true })));
85349
85797
  content = response?.choices?.[0]?.message?.content ?? "";
85350
85798
  } catch {
85351
85799
  continue;
@@ -85377,7 +85825,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85377
85825
  const healedSteps = parsed.steps;
85378
85826
  let verify;
85379
85827
  try {
85380
- verify = await runNativeTest({ steps: healedSteps, target, testVariables });
85828
+ verify = await timed("reexec", `phase2-pass-${pass}-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables }));
85381
85829
  } catch {
85382
85830
  return {
85383
85831
  passed: false,
@@ -89231,7 +89679,7 @@ var require_kill_tree = __commonJS({
89231
89679
  var node_child_process_1 = __require("child_process");
89232
89680
  var node_util_1 = __require("util");
89233
89681
  var node_fs_1 = __require("fs");
89234
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89682
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89235
89683
  var PS_TIMEOUT_MS = 2e3;
89236
89684
  var PS_BUFFER = 1024 * 1024;
89237
89685
  async function gracefulCloseWithTreeKill(options) {
@@ -89285,7 +89733,7 @@ var require_kill_tree = __commonJS({
89285
89733
  timer.unref?.();
89286
89734
  let stdout;
89287
89735
  try {
89288
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89736
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89289
89737
  maxBuffer: PS_BUFFER,
89290
89738
  signal: controller.signal
89291
89739
  });
@@ -89323,7 +89771,7 @@ var require_kill_tree = __commonJS({
89323
89771
  timer.unref?.();
89324
89772
  let stdout;
89325
89773
  try {
89326
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89774
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89327
89775
  maxBuffer: PS_BUFFER,
89328
89776
  signal: controller.signal
89329
89777
  });
@@ -89400,7 +89848,7 @@ var require_kill_tree = __commonJS({
89400
89848
  timer.unref?.();
89401
89849
  let stdout;
89402
89850
  try {
89403
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid="], {
89851
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid="], {
89404
89852
  maxBuffer: PS_BUFFER,
89405
89853
  signal: controller.signal
89406
89854
  });
@@ -99279,8 +99727,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99279
99727
  onRetry: (attempt, reason, delayMs) => logs2.push(` \u26A0 scenario-gen AI call retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
99280
99728
  });
99281
99729
  } catch (aiErr) {
99282
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99283
- logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg2}`);
99730
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99731
+ logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg3}`);
99284
99732
  throw (0, ai_retry_js_1.markAIProviderError)(aiErr);
99285
99733
  }
99286
99734
  const iterDuration = Date.now() - iterStart;
@@ -99323,8 +99771,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99323
99771
  try {
99324
99772
  toolArgs = JSON.parse(toolCall.function.arguments);
99325
99773
  } catch (parseErr) {
99326
- const errMsg2 = `Invalid tool arguments: ${parseErr.message}`;
99327
- toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg2 });
99774
+ const errMsg3 = `Invalid tool arguments: ${parseErr.message}`;
99775
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg3 });
99328
99776
  continue;
99329
99777
  }
99330
99778
  if (toolName === "finish_generate") {
@@ -100582,7 +101030,7 @@ var require_util4 = __commonJS({
100582
101030
  }
100583
101031
  path = url.path;
100584
101032
  }
100585
- var isAbsolute = exports.isAbsolute(path);
101033
+ var isAbsolute2 = exports.isAbsolute(path);
100586
101034
  var parts = path.split(/\/+/);
100587
101035
  for (var part, up = 0, i = parts.length - 1; i >= 0; i--) {
100588
101036
  part = parts[i];
@@ -100602,7 +101050,7 @@ var require_util4 = __commonJS({
100602
101050
  }
100603
101051
  path = parts.join("/");
100604
101052
  if (path === "") {
100605
- path = isAbsolute ? "/" : ".";
101053
+ path = isAbsolute2 ? "/" : ".";
100606
101054
  }
100607
101055
  if (url) {
100608
101056
  url.path = path;
@@ -100611,7 +101059,7 @@ var require_util4 = __commonJS({
100611
101059
  return path;
100612
101060
  }
100613
101061
  exports.normalize = normalize;
100614
- function join7(aRoot, aPath) {
101062
+ function join8(aRoot, aPath) {
100615
101063
  if (aRoot === "") {
100616
101064
  aRoot = ".";
100617
101065
  }
@@ -100643,7 +101091,7 @@ var require_util4 = __commonJS({
100643
101091
  }
100644
101092
  return joined;
100645
101093
  }
100646
- exports.join = join7;
101094
+ exports.join = join8;
100647
101095
  exports.isAbsolute = function(aPath) {
100648
101096
  return aPath.charAt(0) === "/" || urlRegexp.test(aPath);
100649
101097
  };
@@ -100816,7 +101264,7 @@ var require_util4 = __commonJS({
100816
101264
  parsed.path = parsed.path.substring(0, index + 1);
100817
101265
  }
100818
101266
  }
100819
- sourceURL = join7(urlGenerate(parsed), sourceURL);
101267
+ sourceURL = join8(urlGenerate(parsed), sourceURL);
100820
101268
  }
100821
101269
  return normalize(sourceURL);
100822
101270
  }
@@ -314529,8 +314977,8 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314529
314977
  ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel)
314530
314978
  })), { label: `script-heal-${attempt}` });
314531
314979
  } catch (aiErr) {
314532
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314533
- logs2.push(`\u26A0 AI call failed: ${errMsg2}`);
314980
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314981
+ logs2.push(`\u26A0 AI call failed: ${errMsg3}`);
314534
314982
  attemptHistory.push({
314535
314983
  phase: "script",
314536
314984
  attempt,
@@ -314538,13 +314986,13 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314538
314986
  passed: false,
314539
314987
  changedCode: false,
314540
314988
  summary: "AI call failed before returning a heal result",
314541
- errorSummary: truncateForPrompt(errMsg2),
314989
+ errorSummary: truncateForPrompt(errMsg3),
314542
314990
  failedStep: summarizeFailedStep(currentStepResults)
314543
314991
  });
314544
314992
  if (feedbackEnabled) {
314545
- lastTransportFailure = truncateForPrompt(errMsg2, 200) ?? "the AI request failed";
314993
+ lastTransportFailure = truncateForPrompt(errMsg3, 200) ?? "the AI request failed";
314546
314994
  } else {
314547
- currentError = `AI call failed on attempt ${attempt}: ${errMsg2}`;
314995
+ currentError = `AI call failed on attempt ${attempt}: ${errMsg3}`;
314548
314996
  }
314549
314997
  markRecoverableTurn();
314550
314998
  continue;
@@ -314828,8 +315276,8 @@ ${currentError.slice(0, 2e3)}`
314828
315276
  // item 5b
314829
315277
  });
314830
315278
  } catch (runErr) {
314831
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
314832
- logs2.push(`\u{1F4A5} Native execution threw: ${errMsg2}`);
315279
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
315280
+ logs2.push(`\u{1F4A5} Native execution threw: ${errMsg3}`);
314833
315281
  attemptHistory.push({
314834
315282
  phase: "script",
314835
315283
  attempt,
@@ -314837,9 +315285,9 @@ ${currentError.slice(0, 2e3)}`
314837
315285
  passed: false,
314838
315286
  changedCode: true,
314839
315287
  summary: truncateForPrompt(changeDesc) ?? "Applied code fix, but native execution threw before verification completed",
314840
- errorSummary: truncateForPrompt(errMsg2)
315288
+ errorSummary: truncateForPrompt(errMsg3)
314841
315289
  });
314842
- currentError = `Native execution threw: ${errMsg2}`;
315290
+ currentError = `Native execution threw: ${errMsg3}`;
314843
315291
  currentStepResults = [];
314844
315292
  continue;
314845
315293
  }
@@ -315595,9 +316043,9 @@ Last error (after heal attempts): ${lastErrorSnippet}` : `Heal aborted: same err
315595
316043
  previousErrorSignature = errorSig;
315596
316044
  }
315597
316045
  } catch (runErr) {
315598
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
315599
- logs2.push(`\u{1F4A5} Native run threw: ${errMsg2}`);
315600
- currentError = errMsg2;
316046
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
316047
+ logs2.push(`\u{1F4A5} Native run threw: ${errMsg3}`);
316048
+ currentError = errMsg3;
315601
316049
  currentStepResults = [];
315602
316050
  }
315603
316051
  } else {
@@ -316159,8 +316607,8 @@ When you call finish_fix, preserve locators only when they are stable structural
316159
316607
  }
316160
316608
  }
316161
316609
  } catch (toolErr) {
316162
- const errMsg2 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316163
- let errorContent = `Error: ${errMsg2}`;
316610
+ const errMsg3 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316611
+ let errorContent = `Error: ${errMsg3}`;
316164
316612
  try {
316165
316613
  const consoleResult = await mcpClient.callTool({ name: "browser_console_messages", arguments: {} });
316166
316614
  const consoleText = Array.isArray(consoleResult.content) ? consoleResult.content.map((c) => c.type === "text" ? c.text ?? "" : "").join("\n") : String(consoleResult.content ?? "");
@@ -316181,8 +316629,8 @@ ${errorContent}`;
316181
316629
  }
316182
316630
  }
316183
316631
  } catch (mcpErr) {
316184
- const errMsg2 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316185
- logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg2}`);
316632
+ const errMsg3 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316633
+ logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg3}`);
316186
316634
  }
316187
316635
  if (verdict === "app_bug") {
316188
316636
  phase2AttemptHistory.push({
@@ -316776,9 +317224,9 @@ ${testBlocks}
316776
317224
  }
316777
317225
  emit(` \u2713 ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
316778
317226
  } catch (err) {
316779
- const errMsg2 = err instanceof Error ? err.message : String(err);
316780
- toolResultContent = `ERROR: ${errMsg2}`;
316781
- emit(` \u2717 ${errMsg2}`);
317227
+ const errMsg3 = err instanceof Error ? err.message : String(err);
317228
+ toolResultContent = `ERROR: ${errMsg3}`;
317229
+ emit(` \u2717 ${errMsg3}`);
316782
317230
  }
316783
317231
  toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: toolResultContent });
316784
317232
  }
@@ -323062,8 +323510,8 @@ var require_capture_page_normalizer = __commonJS({
323062
323510
  const snapItems = Array.isArray(snapResult.content) ? snapResult.content : [];
323063
323511
  snapshotText = extractInlineSnapshotYaml(snapItems.find((c) => c.type === "text")?.text || null);
323064
323512
  } catch (err) {
323065
- const errMsg2 = err instanceof Error ? err.message : String(err);
323066
- const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg2);
323513
+ const errMsg3 = err instanceof Error ? err.message : String(err);
323514
+ const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg3);
323067
323515
  return {
323068
323516
  snapshotText: null,
323069
323517
  meta: {
@@ -327139,9 +327587,9 @@ ${currentSummary}`;
327139
327587
  });
327140
327588
  }
327141
327589
  } catch (err) {
327142
- const errMsg2 = err instanceof Error ? err.message : String(err);
327143
- logs2.push(` Error persisting page: ${errMsg2}`);
327144
- toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: `ERROR persisting page: ${errMsg2}` });
327590
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327591
+ logs2.push(` Error persisting page: ${errMsg3}`);
327592
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: `ERROR persisting page: ${errMsg3}` });
327145
327593
  }
327146
327594
  continue;
327147
327595
  }
@@ -327343,12 +327791,12 @@ ${currentSummary}`;
327343
327791
  })
327344
327792
  });
327345
327793
  } catch (err) {
327346
- const errMsg2 = err instanceof Error ? err.message : String(err);
327347
- logs2.push(` Error persisting page: ${errMsg2}`);
327794
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327795
+ logs2.push(` Error persisting page: ${errMsg3}`);
327348
327796
  toolResults.push({
327349
327797
  role: "tool",
327350
327798
  tool_call_id: toolCall.id,
327351
- content: `ERROR persisting page: ${errMsg2}`
327799
+ content: `ERROR persisting page: ${errMsg3}`
327352
327800
  });
327353
327801
  }
327354
327802
  continue;
@@ -327383,12 +327831,12 @@ ${currentSummary}`;
327383
327831
  content: JSON.stringify({ acknowledged: true })
327384
327832
  });
327385
327833
  } catch (err) {
327386
- const errMsg2 = err instanceof Error ? err.message : String(err);
327387
- logs2.push(` Error persisting transition: ${errMsg2}`);
327834
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327835
+ logs2.push(` Error persisting transition: ${errMsg3}`);
327388
327836
  toolResults.push({
327389
327837
  role: "tool",
327390
327838
  tool_call_id: toolCall.id,
327391
- content: `ERROR persisting transition: ${errMsg2}`
327839
+ content: `ERROR persisting transition: ${errMsg3}`
327392
327840
  });
327393
327841
  }
327394
327842
  continue;
@@ -328084,15 +328532,15 @@ Exploration complete: ${summary}`);
328084
328532
  toolResultContent = (0, snapshot_context_compactor_js_1.formatMCPToolResult)(toolResultContent, MAX_TOOL_RESULT_CHARS);
328085
328533
  logs2.push(` OK: ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
328086
328534
  } catch (err) {
328087
- const errMsg2 = err instanceof Error ? err.message : String(err);
328088
- toolResultContent = `ERROR: ${errMsg2}`;
328089
- logs2.push(` Error: ${errMsg2}`);
328535
+ const errMsg3 = err instanceof Error ? err.message : String(err);
328536
+ toolResultContent = `ERROR: ${errMsg3}`;
328537
+ logs2.push(` Error: ${errMsg3}`);
328090
328538
  if (useV2) {
328091
328539
  (0, exploration_state_js_1.reduceEvent)(state2, {
328092
328540
  type: "ACTION_FAILED",
328093
328541
  route: state2.currentRoute,
328094
328542
  action: `${toolName}(${JSON.stringify(toolArgs).slice(0, 100)})`,
328095
- error: errMsg2
328543
+ error: errMsg3
328096
328544
  });
328097
328545
  }
328098
328546
  }
@@ -330941,10 +331389,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
330941
331389
  onLog: options.onLog,
330942
331390
  onProvenCommands: recordProvenCommands
330943
331391
  });
330944
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
331392
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
330945
331393
  const fixPrompt = [
330946
331394
  "Your previous attempt did not produce a valid result file.",
330947
- `Error: ${errMsg2}`,
331395
+ `Error: ${errMsg3}`,
330948
331396
  `Re-read BRIEF.md and write a valid SurveyorOutput JSON to ${resultFilePath}.`,
330949
331397
  "Single JSON object. No prose. Then exit."
330950
331398
  ].join("\n");
@@ -333085,7 +333533,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333085
333533
  log2(`First-pass discovery-result invalid or missing: ${firstError.message}`);
333086
333534
  }
333087
333535
  if (!parsedResult) {
333088
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333536
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333089
333537
  if (planDebounceTimerRef.current) {
333090
333538
  clearTimeout(planDebounceTimerRef.current);
333091
333539
  planDebounceTimerRef.current = void 0;
@@ -333110,7 +333558,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333110
333558
  ""
333111
333559
  ].join("\n");
333112
333560
  const fixPrompt = [
333113
- `Your previous attempt did not produce a valid result file. Error: ${errMsg2}`,
333561
+ `Your previous attempt did not produce a valid result file. Error: ${errMsg3}`,
333114
333562
  "Re-read BRIEF.md.",
333115
333563
  phaseContext,
333116
333564
  `Continue from where you left off \u2014 already verified tests are in ${testsDirPath}/`,
@@ -334739,10 +335187,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
334739
335187
  onLog: parserLog,
334740
335188
  onProvenCommands: recordProvenCommands
334741
335189
  });
334742
- const errMsg2 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
335190
+ const errMsg3 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
334743
335191
  const fixPrompt = [
334744
335192
  "Your previous attempt did not leave a valid result file.",
334745
- `Error: ${errMsg2}`,
335193
+ `Error: ${errMsg3}`,
334746
335194
  `Writing a valid SurveyorOutput JSON object to ${resultFilePath} is your ONLY priority now.`,
334747
335195
  "Procedure: navigate to at most 6-8 of the main pages, then IMMEDIATELY use",
334748
335196
  `the Write tool to save the SurveyorOutput JSON to ${resultFilePath}. Do this`,
@@ -338623,8 +339071,8 @@ var require_resource_governor = __commonJS({
338623
339071
  function isDiscoveryLike(mode) {
338624
339072
  return mode === "discovery";
338625
339073
  }
338626
- function evaluateRunAdmission2(options, request) {
338627
- const snapshot = getResourceSnapshot2();
339074
+ function evaluateRunAdmission2(options, request, snapshotOverride) {
339075
+ const snapshot = snapshotOverride ?? getResourceSnapshot2();
338628
339076
  const candidateWeight = modeWeight(request.mode, options.weights);
338629
339077
  const currentUnits = activeUnits(request.activeByType, options.weights);
338630
339078
  const memory = pressureFactor(snapshot.memoryPct, options.memorySoftPct, options.memoryHardPct);
@@ -341122,8 +341570,8 @@ var require_adm_zip = __commonJS({
341122
341570
  return null;
341123
341571
  }
341124
341572
  function fixPath(zipPath) {
341125
- const { join: join7, normalize, sep } = pth.posix;
341126
- return join7(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341573
+ const { join: join8, normalize, sep } = pth.posix;
341574
+ return join8(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341127
341575
  }
341128
341576
  function filenameFilter(filterfn) {
341129
341577
  if (filterfn instanceof RegExp) {
@@ -343958,11 +344406,11 @@ var require_winchars = __commonJS({
343958
344406
  var require_strip_absolute_path = __commonJS({
343959
344407
  "../../node_modules/tar/lib/strip-absolute-path.js"(exports, module) {
343960
344408
  "use strict";
343961
- var { isAbsolute, parse } = __require("path").win32;
344409
+ var { isAbsolute: isAbsolute2, parse } = __require("path").win32;
343962
344410
  module.exports = (path) => {
343963
344411
  let r = "";
343964
344412
  let parsed = parse(path);
343965
- while (isAbsolute(path) || parsed.root) {
344413
+ while (isAbsolute2(path) || parsed.root) {
343966
344414
  const root = path.charAt(0) === "/" && path.slice(0, 4) !== "//?/" ? "/" : parsed.root;
343967
344415
  path = path.slice(root.length);
343968
344416
  r += root;
@@ -346474,7 +346922,7 @@ var require_update = __commonJS({
346474
346922
  var require_opts_arg = __commonJS({
346475
346923
  "../../node_modules/tar/node_modules/mkdirp/lib/opts-arg.js"(exports, module) {
346476
346924
  "use strict";
346477
- var { promisify: promisify3 } = __require("util");
346925
+ var { promisify: promisify4 } = __require("util");
346478
346926
  var fs = __require("fs");
346479
346927
  var optsArg = (opts) => {
346480
346928
  if (!opts)
@@ -346488,9 +346936,9 @@ var require_opts_arg = __commonJS({
346488
346936
  else
346489
346937
  throw new TypeError("invalid options argument");
346490
346938
  opts.mkdir = opts.mkdir || opts.fs.mkdir || fs.mkdir;
346491
- opts.mkdirAsync = promisify3(opts.mkdir);
346939
+ opts.mkdirAsync = promisify4(opts.mkdir);
346492
346940
  opts.stat = opts.stat || opts.fs.stat || fs.stat;
346493
- opts.statAsync = promisify3(opts.stat);
346941
+ opts.statAsync = promisify4(opts.stat);
346494
346942
  opts.statSync = opts.statSync || opts.fs.statSync || fs.statSync;
346495
346943
  opts.mkdirSync = opts.mkdirSync || opts.fs.mkdirSync || fs.mkdirSync;
346496
346944
  return opts;
@@ -346743,10 +347191,10 @@ var require_chownr = __commonJS({
346743
347191
  }
346744
347192
  } : (path2, uid, gid) => lchownSync(path2, uid, gid);
346745
347193
  var nodeVersion = process.version;
346746
- var readdir2 = (path2, options, cb) => fs.readdir(path2, options, cb);
347194
+ var readdir3 = (path2, options, cb) => fs.readdir(path2, options, cb);
346747
347195
  var readdirSync = (path2, options) => fs.readdirSync(path2, options);
346748
347196
  if (/^v4\./.test(nodeVersion))
346749
- readdir2 = (path2, options, cb) => fs.readdir(path2, cb);
347197
+ readdir3 = (path2, options, cb) => fs.readdir(path2, cb);
346750
347198
  var chown = (cpath, uid, gid, cb) => {
346751
347199
  fs[LCHOWN](cpath, uid, gid, handleEISDIR(cpath, uid, gid, (er) => {
346752
347200
  cb(er && er.code !== "ENOENT" ? er : null);
@@ -346773,7 +347221,7 @@ var require_chownr = __commonJS({
346773
347221
  }
346774
347222
  };
346775
347223
  var chownr = (p, uid, gid, cb) => {
346776
- readdir2(p, { withFileTypes: true }, (er, children) => {
347224
+ readdir3(p, { withFileTypes: true }, (er, children) => {
346777
347225
  if (er) {
346778
347226
  if (er.code === "ENOENT")
346779
347227
  return cb();
@@ -347047,7 +347495,7 @@ var require_path_reservations = __commonJS({
347047
347495
  var assert = __require("assert");
347048
347496
  var normalize = require_normalize_unicode();
347049
347497
  var stripSlashes = require_strip_trailing_slashes();
347050
- var { join: join7 } = __require("path");
347498
+ var { join: join8 } = __require("path");
347051
347499
  var platform3 = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform;
347052
347500
  var isWindows = platform3 === "win32";
347053
347501
  module.exports = () => {
@@ -347056,7 +347504,7 @@ var require_path_reservations = __commonJS({
347056
347504
  const getDirs = (path) => {
347057
347505
  const dirs = path.split("/").slice(0, -1).reduce((set, path2) => {
347058
347506
  if (set.length) {
347059
- path2 = join7(set[set.length - 1], path2);
347507
+ path2 = join8(set[set.length - 1], path2);
347060
347508
  }
347061
347509
  set.push(path2 || "/");
347062
347510
  return set;
@@ -347124,7 +347572,7 @@ var require_path_reservations = __commonJS({
347124
347572
  };
347125
347573
  const reserve = (paths, fn) => {
347126
347574
  paths = isWindows ? ["win32 parallelization disabled"] : paths.map((p) => {
347127
- return stripSlashes(join7(normalize(p))).toLowerCase();
347575
+ return stripSlashes(join8(normalize(p))).toLowerCase();
347128
347576
  });
347129
347577
  const dirs = new Set(
347130
347578
  paths.map((path) => getDirs(path)).reduce((a, b) => a.concat(b))
@@ -347179,7 +347627,7 @@ var require_unpack = __commonJS({
347179
347627
  var fs = __require("fs");
347180
347628
  var fsm = require_fs_minipass();
347181
347629
  var path = __require("path");
347182
- var mkdir2 = require_mkdir();
347630
+ var mkdir3 = require_mkdir();
347183
347631
  var wc = require_winchars();
347184
347632
  var pathReservations = require_path_reservations();
347185
347633
  var stripAbsolutePath = require_strip_absolute_path();
@@ -347430,7 +347878,7 @@ var require_unpack = __commonJS({
347430
347878
  }
347431
347879
  }
347432
347880
  [MKDIR](dir, mode, cb) {
347433
- mkdir2(normPath(dir), {
347881
+ mkdir3(normPath(dir), {
347434
347882
  uid: this.uid,
347435
347883
  gid: this.gid,
347436
347884
  processUid: this.processUid,
@@ -347833,7 +348281,7 @@ var require_unpack = __commonJS({
347833
348281
  }
347834
348282
  [MKDIR](dir, mode) {
347835
348283
  try {
347836
- return mkdir2.sync(normPath(dir), {
348284
+ return mkdir3.sync(normPath(dir), {
347837
348285
  uid: this.uid,
347838
348286
  gid: this.gid,
347839
348287
  processUid: this.processUid,
@@ -351339,7 +351787,7 @@ var require_process_inventory = __commonJS({
351339
351787
  var node_child_process_1 = __require("child_process");
351340
351788
  var node_os_1 = __importDefault(__require("os"));
351341
351789
  var node_util_1 = __require("util");
351342
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351790
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351343
351791
  var DEFAULT_LIMIT = 60;
351344
351792
  var DEFAULT_TIMEOUT_MS2 = 800;
351345
351793
  var MAX_COMMAND_LENGTH = 320;
@@ -351401,7 +351849,7 @@ var require_process_inventory = __commonJS({
351401
351849
  const timer = setTimeout(() => controller.abort(), timeoutMs);
351402
351850
  timer.unref?.();
351403
351851
  try {
351404
- const { stdout } = await execFileAsync3(command, args, {
351852
+ const { stdout } = await execFileAsync4(command, args, {
351405
351853
  maxBuffer: 1024 * 1024,
351406
351854
  signal: controller.signal
351407
351855
  });
@@ -352547,7 +352995,7 @@ var require_package4 = __commonJS({
352547
352995
  "package.json"(exports, module) {
352548
352996
  module.exports = {
352549
352997
  name: "@validate.qa/runner",
352550
- version: "1.0.70",
352998
+ version: "1.0.71",
352551
352999
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
352552
353000
  bin: {
352553
353001
  "validate-runner": "dist/cli.js"
@@ -352556,7 +353004,7 @@ var require_package4 = __commonJS({
352556
353004
  build: "tsup",
352557
353005
  dev: "tsx src/cli.ts",
352558
353006
  test: "node --import tsx --test src/version-check.test.ts src/services/auth-state.test.ts",
352559
- "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",
353007
+ "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",
352560
353008
  prepublishOnly: "npm run build"
352561
353009
  },
352562
353010
  files: [
@@ -352634,7 +353082,7 @@ function getConfigDir() {
352634
353082
  }
352635
353083
 
352636
353084
  // src/runner.ts
352637
- import { join as join6 } from "path";
353085
+ import { join as join7 } from "path";
352638
353086
  import { homedir as homedir3 } from "os";
352639
353087
  import { createHash } from "crypto";
352640
353088
 
@@ -353229,6 +353677,69 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
353229
353677
  };
353230
353678
  }
353231
353679
 
353680
+ // src/services/mobile/ios-simulator-reset.ts
353681
+ import { execFile } from "child_process";
353682
+ import { promisify } from "util";
353683
+ import { mkdir, readdir, rm } from "fs/promises";
353684
+ import { join as join3, isAbsolute } from "path";
353685
+ var execFileAsync = promisify(execFile);
353686
+ var SIMCTL_TIMEOUT_MS = 15e3;
353687
+ 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;
353688
+ function isIosSimulatorUdid(udid) {
353689
+ return typeof udid === "string" && SIMULATOR_UDID_RE.test(udid.trim());
353690
+ }
353691
+ function errMsg(err) {
353692
+ return err instanceof Error ? err.message : String(err);
353693
+ }
353694
+ async function simctl(args) {
353695
+ const { stdout } = await execFileAsync("xcrun", ["simctl", ...args], {
353696
+ timeout: SIMCTL_TIMEOUT_MS,
353697
+ encoding: "utf-8"
353698
+ });
353699
+ return stdout.trim();
353700
+ }
353701
+ async function clearIosSimulatorAppData(udid, bundleId, env = process.env) {
353702
+ if (!isIosSimulatorUdid(udid)) {
353703
+ return { ok: false, detail: `'${udid}' is not a simulator UDID \u2014 physical iOS devices cannot be cleared` };
353704
+ }
353705
+ let container;
353706
+ try {
353707
+ container = await simctl(["get_app_container", udid, bundleId, "data"]);
353708
+ } catch (err) {
353709
+ return { ok: false, detail: `simctl get_app_container failed (app not installed?): ${errMsg(err)}` };
353710
+ }
353711
+ if (!isAbsolute(container) || !container.includes("/Containers/Data/Application/")) {
353712
+ return { ok: false, detail: `unexpected app container path: ${container}` };
353713
+ }
353714
+ try {
353715
+ await simctl(["spawn", udid, "defaults", "delete", bundleId]);
353716
+ } catch {
353717
+ }
353718
+ try {
353719
+ const entries = await readdir(container);
353720
+ for (const entry of entries) {
353721
+ await rm(join3(container, entry), { recursive: true, force: true });
353722
+ }
353723
+ for (const dir of ["Documents", "Library/Caches", "Library/Preferences", "tmp"]) {
353724
+ await mkdir(join3(container, dir), { recursive: true });
353725
+ }
353726
+ } catch (err) {
353727
+ return { ok: false, detail: `app container wipe failed: ${errMsg(err)}` };
353728
+ }
353729
+ const keychainPolicy = (env.VALIDATEQA_MOBILE_IOS_KEYCHAIN_RESET ?? "").trim().toLowerCase();
353730
+ if (keychainPolicy !== "never") {
353731
+ try {
353732
+ await simctl(["keychain", udid, "reset"]);
353733
+ } catch (err) {
353734
+ return {
353735
+ ok: false,
353736
+ 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.`
353737
+ };
353738
+ }
353739
+ }
353740
+ return { ok: true };
353741
+ }
353742
+
353232
353743
  // src/services/mobile/appium-actions.ts
353233
353744
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
353234
353745
  var APPIUM_WEBVIEW_CONTEXT_TIMEOUT_MS = 12e4;
@@ -353411,9 +353922,12 @@ async function tapAt(sessionPath, bounds) {
353411
353922
  }
353412
353923
  }
353413
353924
  function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
353414
- if (platform3 !== "ANDROID") return false;
353415
353925
  const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
353416
353926
  if (policy === "never") return false;
353927
+ if (platform3 === "IOS") {
353928
+ return isIosSimulatorUdid(deviceId);
353929
+ }
353930
+ if (platform3 !== "ANDROID") return false;
353417
353931
  if (policy === "always") return true;
353418
353932
  return !!deviceId && deviceId.startsWith("emulator-");
353419
353933
  }
@@ -354292,12 +354806,37 @@ async function pressHome(sessionPath, platform3) {
354292
354806
  });
354293
354807
  }
354294
354808
 
354809
+ // src/services/device-quarantine.ts
354810
+ var quarantinedDevices = /* @__PURE__ */ new Set();
354811
+ var DeviceQuarantinedError = class extends Error {
354812
+ constructor(deviceId) {
354813
+ super(
354814
+ `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.`
354815
+ );
354816
+ this.name = "DeviceQuarantinedError";
354817
+ }
354818
+ };
354819
+ function quarantineDevice(deviceId) {
354820
+ quarantinedDevices.add(deviceId);
354821
+ }
354822
+ function unquarantineDevice(deviceId) {
354823
+ quarantinedDevices.delete(deviceId);
354824
+ }
354825
+ function isDeviceQuarantined(deviceId) {
354826
+ return typeof deviceId === "string" && quarantinedDevices.has(deviceId);
354827
+ }
354828
+ function assertDeviceNotQuarantined(deviceId) {
354829
+ if (isDeviceQuarantined(deviceId)) {
354830
+ throw new DeviceQuarantinedError(deviceId);
354831
+ }
354832
+ }
354833
+
354295
354834
  // src/services/appium-executor.ts
354296
354835
  var import_runner_core8 = __toESM(require_dist2());
354297
- import { execFile as execFile2 } from "child_process";
354298
- import { promisify as promisify2 } from "util";
354836
+ import { execFile as execFile3 } from "child_process";
354837
+ import { promisify as promisify3 } from "util";
354299
354838
  import { tmpdir as tmpdir3 } from "os";
354300
- import { join as join4 } from "path";
354839
+ import { join as join5 } from "path";
354301
354840
 
354302
354841
  // src/services/device-discovery.ts
354303
354842
  import { execFileSync as execFileSync2 } from "child_process";
@@ -354604,9 +355143,6 @@ function countReadyDevicesByKind(platform3, snapshot = getCachedDeviceSnapshot()
354604
355143
  function selectFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(), requirements = {}) {
354605
355144
  const ready = snapshot.filter((d) => d.platform === platform3 && d.isAvailable && d.state === "BOOTED");
354606
355145
  if (ready.length === 0) return { kind: "busy", reason: `no booted ${platform3} device` };
354607
- if (requirements.resetPolicy === "CLEAR_APP_DATA" && platform3 !== "ANDROID") {
354608
- return { kind: "unsatisfiable", reason: "CLEAR_APP_DATA is unsupported on iOS" };
354609
- }
354610
355146
  let candidates = requirements.resetPolicy === "CLEAR_APP_DATA" ? ready.filter((d) => !d.isPhysical) : [...ready];
354611
355147
  const appId = requirements.appId?.trim();
354612
355148
  if (appId) {
@@ -354655,23 +355191,23 @@ function pickFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(),
354655
355191
 
354656
355192
  // src/services/mobile/mobile-network-capture.ts
354657
355193
  var import_runner_core7 = __toESM(require_dist2());
354658
- import { execFile, execFileSync as execFileSync3 } from "child_process";
355194
+ import { execFile as execFile2, execFileSync as execFileSync3 } from "child_process";
354659
355195
  import { randomUUID } from "crypto";
354660
- import { mkdtemp, rm, writeFile, readFile, readdir, mkdir, unlink } from "fs/promises";
355196
+ import { mkdtemp, rm as rm2, writeFile, readFile, readdir as readdir2, mkdir as mkdir2, unlink } from "fs/promises";
354661
355197
  import { tmpdir as tmpdir2, networkInterfaces } from "os";
354662
- import { join as join3 } from "path";
354663
- import { promisify } from "util";
355198
+ import { join as join4 } from "path";
355199
+ import { promisify as promisify2 } from "util";
354664
355200
  import {
354665
355201
  getLocal,
354666
355202
  generateCACertificate
354667
355203
  } from "mockttp";
354668
- var execFileAsync = promisify(execFile);
355204
+ var execFileAsync2 = promisify2(execFile2);
354669
355205
  var MAX_CALLS = 1e3;
354670
355206
  var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
354671
355207
  var DEVICE_CMD_TIMEOUT_MS = 15e3;
354672
355208
  var CA_KEY_BITS = 2048;
354673
355209
  var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
354674
- var PENDING_CLEANUP_DIR = join3(tmpdir2(), "validateqa-mobile-capture-pending");
355210
+ var PENDING_CLEANUP_DIR = join4(tmpdir2(), "validateqa-mobile-capture-pending");
354675
355211
  var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
354676
355212
  var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
354677
355213
  function envFlag(name) {
@@ -354853,7 +355389,7 @@ var MitmProxy = class {
354853
355389
  try {
354854
355390
  await server3.stop();
354855
355391
  } catch (err) {
354856
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
355392
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg2(err)}`);
354857
355393
  }
354858
355394
  this.onLog?.(
354859
355395
  `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
@@ -354878,7 +355414,7 @@ var MitmProxy = class {
354878
355414
  isHttps
354879
355415
  });
354880
355416
  } catch (err) {
354881
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
355417
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg2(err)}`);
354882
355418
  }
354883
355419
  }
354884
355420
  // ── response → finalize the matched request ───────────────────────────────
@@ -354921,7 +355457,7 @@ var MitmProxy = class {
354921
355457
  intercepted: true
354922
355458
  });
354923
355459
  } catch (err) {
354924
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
355460
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg2(err)}`);
354925
355461
  }
354926
355462
  }
354927
355463
  // ── tls-client-error → record WHY interception failed ─────────────────────
@@ -354998,17 +355534,17 @@ async function generateCaCert(dir, onLog) {
354998
355534
  // rely on stop() to remove the cert from the device promptly after the run
354999
355535
  // rather than on a short expiry.
355000
355536
  });
355001
- const caCertPath = join3(dir, "validateqa-mobile-ca.pem");
355537
+ const caCertPath = join4(dir, "validateqa-mobile-ca.pem");
355002
355538
  await writeFile(caCertPath, cert, "utf-8");
355003
355539
  return { caCertPath, caKeyPem: key, caCertPem: cert };
355004
355540
  } catch (err) {
355005
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
355541
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg2(err)}`);
355006
355542
  return null;
355007
355543
  }
355008
355544
  }
355009
355545
  async function androidReadProxy(deviceId) {
355010
355546
  try {
355011
- const { stdout } = await execFileAsync(
355547
+ const { stdout } = await execFileAsync2(
355012
355548
  "adb",
355013
355549
  ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
355014
355550
  { timeout: DEVICE_CMD_TIMEOUT_MS }
@@ -355021,33 +355557,33 @@ async function androidReadProxy(deviceId) {
355021
355557
  }
355022
355558
  async function androidSetProxy(deviceId, hostPort, onLog) {
355023
355559
  try {
355024
- await execFileAsync(
355560
+ await execFileAsync2(
355025
355561
  "adb",
355026
355562
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
355027
355563
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355028
355564
  );
355029
355565
  return true;
355030
355566
  } catch (err) {
355031
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
355567
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg2(err)}`);
355032
355568
  return false;
355033
355569
  }
355034
355570
  }
355035
355571
  async function androidClearProxy(deviceId, originalProxy, onLog) {
355036
355572
  const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
355037
355573
  try {
355038
- await execFileAsync(
355574
+ await execFileAsync2(
355039
355575
  "adb",
355040
355576
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
355041
355577
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355042
355578
  );
355043
355579
  } catch (err) {
355044
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
355580
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg2(err)}`);
355045
355581
  }
355046
355582
  }
355047
355583
  async function androidInstallCa(deviceId, caCertPath) {
355048
355584
  const devicePath = "/sdcard/validateqa-mobile-ca.pem";
355049
355585
  try {
355050
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355586
+ await execFileAsync2("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355051
355587
  timeout: DEVICE_CMD_TIMEOUT_MS
355052
355588
  });
355053
355589
  return {
@@ -355055,23 +355591,23 @@ async function androidInstallCa(deviceId, caCertPath) {
355055
355591
  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.`
355056
355592
  };
355057
355593
  } catch (err) {
355058
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
355594
+ return { installed: false, note: `CA push failed: ${errMsg2(err)}` };
355059
355595
  }
355060
355596
  }
355061
355597
  async function androidRemoveCa(deviceId, onLog) {
355062
355598
  try {
355063
- await execFileAsync(
355599
+ await execFileAsync2(
355064
355600
  "adb",
355065
355601
  ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
355066
355602
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355067
355603
  );
355068
355604
  } catch (err) {
355069
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
355605
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg2(err)}`);
355070
355606
  }
355071
355607
  }
355072
355608
  async function iosSimTrustCa(deviceId, caCertPath) {
355073
355609
  try {
355074
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355610
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355075
355611
  timeout: DEVICE_CMD_TIMEOUT_MS
355076
355612
  });
355077
355613
  return {
@@ -355079,18 +355615,18 @@ async function iosSimTrustCa(deviceId, caCertPath) {
355079
355615
  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."
355080
355616
  };
355081
355617
  } catch (err) {
355082
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
355618
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg2(err)}` };
355083
355619
  }
355084
355620
  }
355085
355621
  async function iosSimRemoveCa(deviceId, onLog) {
355086
355622
  if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
355087
355623
  try {
355088
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355624
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355089
355625
  timeout: DEVICE_CMD_TIMEOUT_MS
355090
355626
  });
355091
355627
  onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
355092
355628
  } catch (err) {
355093
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
355629
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg2(err)}`);
355094
355630
  }
355095
355631
  return;
355096
355632
  }
@@ -355098,7 +355634,7 @@ async function iosSimRemoveCa(deviceId, onLog) {
355098
355634
  `[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.`
355099
355635
  );
355100
355636
  }
355101
- function errMsg(err) {
355637
+ function errMsg2(err) {
355102
355638
  return err instanceof Error ? err.message : String(err);
355103
355639
  }
355104
355640
  function isProcessAlive(pid) {
@@ -355189,7 +355725,7 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355189
355725
  let service;
355190
355726
  let prior;
355191
355727
  try {
355192
- const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
355728
+ const { stdout: order } = await execFileAsync2("networksetup", ["-listnetworkserviceorder"], {
355193
355729
  timeout: DEVICE_CMD_TIMEOUT_MS
355194
355730
  });
355195
355731
  const svc = parsePrimaryNetworkService(order);
@@ -355198,26 +355734,26 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355198
355734
  }
355199
355735
  service = svc;
355200
355736
  const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
355201
- execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
355202
- execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
355737
+ execFileAsync2("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
355738
+ execFileAsync2("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
355203
355739
  ]);
355204
355740
  prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
355205
355741
  for (const args of buildHostProxySetCommands(service, host, port)) {
355206
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355742
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355207
355743
  }
355208
355744
  onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
355209
355745
  return { configured: true, service, prior };
355210
355746
  } catch (err) {
355211
355747
  if (service && prior) {
355212
- onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
355748
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg2(err)}`);
355213
355749
  for (const args of buildHostProxyRestoreCommands(service, prior)) {
355214
355750
  try {
355215
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355751
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355216
355752
  } catch {
355217
355753
  }
355218
355754
  }
355219
355755
  }
355220
- return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
355756
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg2(err)}` };
355221
355757
  }
355222
355758
  }
355223
355759
  async function iosSimRestoreHostProxy(service, prior, onLog) {
@@ -355227,9 +355763,9 @@ async function iosSimRestoreHostProxy(service, prior, onLog) {
355227
355763
  };
355228
355764
  for (const args of buildHostProxyRestoreCommands(service, restore)) {
355229
355765
  try {
355230
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355766
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355231
355767
  } catch (err) {
355232
- onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
355768
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg2(err)}`);
355233
355769
  }
355234
355770
  }
355235
355771
  onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
@@ -355237,11 +355773,11 @@ async function iosSimRestoreHostProxy(service, prior, onLog) {
355237
355773
  var activeCleanups = /* @__PURE__ */ new Map();
355238
355774
  var exitHandlersInstalled = false;
355239
355775
  function markerPath(sessionKey) {
355240
- return join3(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
355776
+ return join4(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
355241
355777
  }
355242
355778
  async function writePendingCleanup(sessionKey, marker) {
355243
355779
  try {
355244
- await mkdir(PENDING_CLEANUP_DIR, { recursive: true });
355780
+ await mkdir2(PENDING_CLEANUP_DIR, { recursive: true });
355245
355781
  await writeFile(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
355246
355782
  } catch {
355247
355783
  }
@@ -355281,7 +355817,7 @@ function restoreDeviceSync(marker, onLog) {
355281
355817
  async function reconcilePendingCleanups(onLog) {
355282
355818
  let files;
355283
355819
  try {
355284
- files = await readdir(PENDING_CLEANUP_DIR);
355820
+ files = await readdir2(PENDING_CLEANUP_DIR);
355285
355821
  } catch {
355286
355822
  return;
355287
355823
  }
@@ -355289,7 +355825,7 @@ async function reconcilePendingCleanups(onLog) {
355289
355825
  if (!file.endsWith(".json")) continue;
355290
355826
  const sessionKey = file.slice(0, -".json".length);
355291
355827
  if (activeCleanups.has(sessionKey)) continue;
355292
- const full = join3(PENDING_CLEANUP_DIR, file);
355828
+ const full = join4(PENDING_CLEANUP_DIR, file);
355293
355829
  try {
355294
355830
  const raw = await readFile(full, "utf-8");
355295
355831
  const marker = JSON.parse(raw);
@@ -355342,7 +355878,7 @@ async function startMobileNetworkCapture(options) {
355342
355878
  let caKeyPem = null;
355343
355879
  let caCertPem = null;
355344
355880
  try {
355345
- tmpDir = await mkdtemp(join3(tmpdir2(), "validateqa-mobile-ca-"));
355881
+ tmpDir = await mkdtemp(join4(tmpdir2(), "validateqa-mobile-ca-"));
355346
355882
  const ca = await generateCaCert(tmpDir, onLog);
355347
355883
  if (ca) {
355348
355884
  caCertPath = ca.caCertPath;
@@ -355350,7 +355886,7 @@ async function startMobileNetworkCapture(options) {
355350
355886
  caCertPem = ca.caCertPem;
355351
355887
  }
355352
355888
  } catch (err) {
355353
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
355889
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg2(err)}`);
355354
355890
  }
355355
355891
  if (!caKeyPem || !caCertPem) {
355356
355892
  try {
@@ -355358,9 +355894,9 @@ async function startMobileNetworkCapture(options) {
355358
355894
  caKeyPem = fallback.key;
355359
355895
  caCertPem = fallback.cert;
355360
355896
  } catch (err) {
355361
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
355897
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg2(err)}`);
355362
355898
  if (tmpDir) {
355363
- await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355899
+ await rm2(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355364
355900
  }
355365
355901
  throw err instanceof Error ? err : new Error(String(err));
355366
355902
  }
@@ -355415,7 +355951,7 @@ async function startMobileNetworkCapture(options) {
355415
355951
  }
355416
355952
  }
355417
355953
  } catch (err) {
355418
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
355954
+ wiringReason = `device wiring degraded: ${errMsg2(err)}`;
355419
355955
  onLog?.(`[mobile-net] ${wiringReason}`);
355420
355956
  }
355421
355957
  if (proxySet || caInstalledOnDevice || hostProxySet) {
@@ -355504,9 +356040,9 @@ async function startMobileNetworkCapture(options) {
355504
356040
  await proxy.close();
355505
356041
  if (capturedTmpDir) {
355506
356042
  try {
355507
- await rm(capturedTmpDir, { recursive: true, force: true });
356043
+ await rm2(capturedTmpDir, { recursive: true, force: true });
355508
356044
  } catch (err) {
355509
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
356045
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg2(err)}`);
355510
356046
  }
355511
356047
  }
355512
356048
  const apiCount = finalCalls.filter((c) => c.isApi).length;
@@ -355539,11 +356075,11 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
355539
356075
  })();
355540
356076
  var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
355541
356077
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
355542
- var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
356078
+ var WD_SESSION_CREATE_TIMEOUT_MS = Number(process.env.VALIDATEQA_WD_SESSION_CREATE_TIMEOUT_MS) || 3e5;
355543
356079
  var WD_ACTION_TIMEOUT_MS = 3e4;
355544
356080
  var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
355545
356081
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
355546
- var execFileAsync2 = promisify2(execFile2);
356082
+ var execFileAsync3 = promisify3(execFile3);
355547
356083
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
355548
356084
  var WebDriverTransportError = class extends Error {
355549
356085
  isTransport = true;
@@ -355555,6 +356091,13 @@ var WebDriverTransportError = class extends Error {
355555
356091
  function isTransportError(err) {
355556
356092
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
355557
356093
  }
356094
+ var MobileAmbiguousMatchError = class extends Error {
356095
+ isAmbiguousMatch = true;
356096
+ constructor(message) {
356097
+ super(message);
356098
+ this.name = "MobileAmbiguousMatchError";
356099
+ }
356100
+ };
355558
356101
  function crossPlatformRunReadinessError(steps, authoringPlatform, runPlatform) {
355559
356102
  if (!authoringPlatform || authoringPlatform === runPlatform) return null;
355560
356103
  const missing = stepsMissingPlatformLocators(steps, runPlatform);
@@ -355672,11 +356215,11 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
355672
356215
  return { "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200) };
355673
356216
  }
355674
356217
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
355675
- const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join4(tmpdir3(), "validateqa-wda");
356218
+ const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join5(tmpdir3(), "validateqa-wda");
355676
356219
  return {
355677
356220
  "appium:wdaLocalPort": readPort("APPIUM_WDA_LOCAL_PORT", 8100),
355678
356221
  "appium:mjpegServerPort": readPort("APPIUM_MJPEG_SERVER_PORT", 9100),
355679
- "appium:derivedDataPath": join4(derivedDataRoot, safeDevice)
356222
+ "appium:derivedDataPath": join5(derivedDataRoot, safeDevice)
355680
356223
  };
355681
356224
  }
355682
356225
  function isAndroidSystemPortBusyError(error2) {
@@ -355700,6 +356243,7 @@ function androidSystemPortForwardRemovalArgs(target, error2, env = process.env)
355700
356243
  return ["-s", deviceId, "forward", "--remove", `tcp:${systemPort}`];
355701
356244
  }
355702
356245
  async function createSession(target) {
356246
+ assertDeviceNotQuarantined(target.deviceId);
355703
356247
  const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
355704
356248
  const parallelCaps = mobileParallelSessionCapabilities(
355705
356249
  target.platform,
@@ -355721,6 +356265,13 @@ async function createSession(target) {
355721
356265
  // whole survey. The runner owns session teardown explicitly, so a long
355722
356266
  // idle window is safe. Env-overridable.
355723
356267
  "appium:newCommandTimeout": MOBILE_NEW_COMMAND_TIMEOUT_SECONDS,
356268
+ // Xcode 27+ relocates Simulator.app out of <DEVELOPER_DIR>/Applications, so
356269
+ // XCUITest's default "open Simulator UI" step fails ("Simulator.app does not
356270
+ // exist") on this toolchain. The simulator is already booted and driven
356271
+ // headlessly via CoreSimulator, so tell XCUITest to skip opening the GUI.
356272
+ // Scoped to simulator UDIDs (a physical iOS device has no such step);
356273
+ // override with APPIUM_IOS_HEADLESS=false to force a visible Simulator run.
356274
+ ...isIosSimulatorUdid(target.deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
355724
356275
  ...parallelCaps,
355725
356276
  ...realCaps
355726
356277
  } : {
@@ -355760,7 +356311,7 @@ async function removeStaleAndroidSystemPortForward(target, error2) {
355760
356311
  const args = androidSystemPortForwardRemovalArgs(target, error2);
355761
356312
  if (!args) return false;
355762
356313
  try {
355763
- await execFileAsync2("adb", args, { timeout: 1e4, windowsHide: true });
356314
+ await execFileAsync3("adb", args, { timeout: 1e4, windowsHide: true });
355764
356315
  return true;
355765
356316
  } catch {
355766
356317
  return false;
@@ -355927,6 +356478,9 @@ async function deleteSession(sessionId) {
355927
356478
  } catch {
355928
356479
  }
355929
356480
  }
356481
+ async function deleteAppiumSession(sessionId) {
356482
+ await deleteSession(sessionId);
356483
+ }
355930
356484
  async function takeScreenshot(sessionId) {
355931
356485
  try {
355932
356486
  const result = await wdRequest(`/session/${sessionId}/screenshot`);
@@ -355998,10 +356552,9 @@ async function stopScreenRecording(sessionId, log2) {
355998
356552
  return void 0;
355999
356553
  }
356000
356554
  }
356001
- function boundsFromStep(step) {
356002
- const bounds = step.metadata?.bounds;
356003
- if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
356004
- const parsed = bounds;
356555
+ function parseMobileBoundsLike(value) {
356556
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.x === "number" && typeof value.y === "number" && typeof value.width === "number" && typeof value.height === "number") {
356557
+ const parsed = value;
356005
356558
  if ([parsed.x, parsed.y, parsed.width, parsed.height].every(Number.isFinite)) {
356006
356559
  return {
356007
356560
  x: parsed.x,
@@ -356013,6 +356566,9 @@ function boundsFromStep(step) {
356013
356566
  }
356014
356567
  return null;
356015
356568
  }
356569
+ function boundsFromStep(step) {
356570
+ return parseMobileBoundsLike(step.metadata?.bounds);
356571
+ }
356016
356572
  function boundsForRun(step, platform3, isCrossPlatform) {
356017
356573
  if (isCrossPlatform) return null;
356018
356574
  if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
@@ -356164,6 +356720,131 @@ async function revealLikelyOffscreenElement(sessionId) {
356164
356720
  });
356165
356721
  await new Promise((resolve2) => setTimeout(resolve2, 250));
356166
356722
  }
356723
+ function strictMatchMode() {
356724
+ const raw = (process.env.VALIDATEQA_MOBILE_STRICT_MATCH ?? "").trim().toLowerCase();
356725
+ if (raw === "strict") return "strict";
356726
+ if (raw === "off") return "off";
356727
+ return "lenient";
356728
+ }
356729
+ var MOBILE_MAX_DISAMBIG_CANDIDATES = 20;
356730
+ var COINCIDENT_CENTER_EPSILON_PX = 2;
356731
+ function isRealElementRect(rect) {
356732
+ return rect.width > 1 && rect.height > 1;
356733
+ }
356734
+ function rectContainsPoint(rect, x, y) {
356735
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
356736
+ }
356737
+ function rectCenter(rect) {
356738
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
356739
+ }
356740
+ function centerDistance(rect, point) {
356741
+ const center = rectCenter(rect);
356742
+ return Math.hypot(center.x - point.x, center.y - point.y);
356743
+ }
356744
+ function chooseRecordedMatch(candidates, opts) {
356745
+ if (candidates.length === 0) return { kind: "ambiguous", count: 0 };
356746
+ if (candidates.length === 1) {
356747
+ return { kind: "matched", elementId: candidates[0].elementId, reason: "single" };
356748
+ }
356749
+ if (typeof opts.matchIndex === "number" && Number.isInteger(opts.matchIndex) && opts.matchIndex >= 0 && opts.matchIndex < candidates.length && typeof opts.expectedMatchCount === "number" && opts.expectedMatchCount === candidates.length) {
356750
+ return { kind: "matched", elementId: candidates[opts.matchIndex].elementId, reason: "matchIndex" };
356751
+ }
356752
+ {
356753
+ const withRect = candidates.filter(
356754
+ (candidate) => candidate.rect != null
356755
+ );
356756
+ if (withRect.length === candidates.length) {
356757
+ const real = withRect.filter((candidate) => isRealElementRect(candidate.rect));
356758
+ if (real.length === 1) {
356759
+ return { kind: "matched", elementId: real[0].elementId, reason: "visible" };
356760
+ }
356761
+ const centers = withRect.map((candidate) => rectCenter(candidate.rect));
356762
+ 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);
356763
+ if (coincident) {
356764
+ return { kind: "matched", elementId: withRect[0].elementId, reason: "coincident" };
356765
+ }
356766
+ }
356767
+ }
356768
+ if (opts.recordedBounds) {
356769
+ const bounds = opts.recordedBounds;
356770
+ const center = rectCenter(bounds);
356771
+ const withRect = candidates.filter(
356772
+ (candidate) => candidate.rect != null
356773
+ );
356774
+ const containing = withRect.filter((candidate) => rectContainsPoint(candidate.rect, center.x, center.y));
356775
+ if (containing.length > 0) {
356776
+ const best = [...containing].sort(
356777
+ (a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height || centerDistance(a.rect, center) - centerDistance(b.rect, center)
356778
+ )[0];
356779
+ return { kind: "matched", elementId: best.elementId, reason: "bounds" };
356780
+ }
356781
+ if (withRect.length > 0) {
356782
+ const tolerance = Math.max(bounds.height, 1);
356783
+ const nearest = [...withRect].sort(
356784
+ (a, b) => centerDistance(a.rect, center) - centerDistance(b.rect, center)
356785
+ )[0];
356786
+ if (centerDistance(nearest.rect, center) <= tolerance) {
356787
+ return { kind: "matched", elementId: nearest.elementId, reason: "bounds" };
356788
+ }
356789
+ }
356790
+ }
356791
+ return opts.strict === "strict" ? { kind: "ambiguous", count: candidates.length } : { kind: "fallback", elementId: candidates[0].elementId, count: candidates.length };
356792
+ }
356793
+ function extractElementIdsFromPluralResponse(result) {
356794
+ const rawList = result?.value;
356795
+ if (!Array.isArray(rawList)) return [];
356796
+ const ids = [];
356797
+ for (const entry of rawList) {
356798
+ const record = entry;
356799
+ const id = validateElementId(record?.ELEMENT ?? record?.["element-6066-11e4-a52e-4f735466cecf"]);
356800
+ if (id) ids.push(id);
356801
+ }
356802
+ return ids;
356803
+ }
356804
+ async function findElements(sessionId, strategy) {
356805
+ const result = await wdRequest(`/session/${sessionId}/elements`, {
356806
+ method: "POST",
356807
+ body: JSON.stringify({ using: strategy.using, value: strategy.value })
356808
+ });
356809
+ return extractElementIdsFromPluralResponse(result);
356810
+ }
356811
+ async function findWebViewElements(sessionPath, css) {
356812
+ const result = await wdRequest(`${sessionPath}/elements`, {
356813
+ method: "POST",
356814
+ body: JSON.stringify({ using: "css selector", value: css })
356815
+ });
356816
+ return extractElementIdsFromPluralResponse(result);
356817
+ }
356818
+ async function readElementRect(sessionId, elementId) {
356819
+ try {
356820
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/rect`);
356821
+ return parseMobileBoundsLike(result?.value);
356822
+ } catch (err) {
356823
+ if (isTransportError(err)) throw err;
356824
+ return null;
356825
+ }
356826
+ }
356827
+ async function disambiguateElementIds(sessionId, candidateIds, opts) {
356828
+ const indexOnly = candidateIds.map((elementId) => ({ elementId }));
356829
+ const indexResult = chooseRecordedMatch(indexOnly, {
356830
+ recordedBounds: null,
356831
+ matchIndex: opts.matchIndex,
356832
+ expectedMatchCount: opts.expectedMatchCount,
356833
+ strict: opts.strict
356834
+ });
356835
+ if (indexResult.kind === "matched" || candidateIds.length > MOBILE_MAX_DISAMBIG_CANDIDATES) {
356836
+ return indexResult;
356837
+ }
356838
+ const withRects = await Promise.all(
356839
+ candidateIds.map(async (elementId) => ({ elementId, rect: await readElementRect(sessionId, elementId) }))
356840
+ );
356841
+ return chooseRecordedMatch(withRects, {
356842
+ recordedBounds: opts.recordedBounds ?? null,
356843
+ matchIndex: opts.matchIndex,
356844
+ expectedMatchCount: opts.expectedMatchCount,
356845
+ strict: opts.strict
356846
+ });
356847
+ }
356167
356848
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
356168
356849
  const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
356169
356850
  if (strategies.length === 0) {
@@ -356171,22 +356852,45 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356171
356852
  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"
356172
356853
  );
356173
356854
  }
356855
+ const mode = strictMatchMode();
356856
+ const matchIndex = typeof target?.matchIndex === "number" ? target.matchIndex : void 0;
356857
+ const expectedMatchCount = typeof target?.expectedMatchCount === "number" ? target.expectedMatchCount : void 0;
356174
356858
  const startTime = Date.now();
356175
356859
  let lastError = "";
356860
+ let lastAmbiguity = null;
356176
356861
  let revealAttempted = false;
356177
356862
  while (Date.now() - startTime < timeoutMs) {
356178
356863
  for (const strategy of strategies) {
356179
356864
  try {
356180
- const result = await wdRequest(`/session/${sessionId}/element`, {
356181
- method: "POST",
356182
- body: JSON.stringify({ using: strategy.using, value: strategy.value })
356865
+ const ids = await findElements(sessionId, strategy);
356866
+ if (ids.length === 0) continue;
356867
+ if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
356868
+ return ids[0];
356869
+ }
356870
+ const result = await disambiguateElementIds(sessionId, ids, {
356871
+ recordedBounds: opts?.recordedBounds,
356872
+ matchIndex,
356873
+ expectedMatchCount,
356874
+ strict: mode
356183
356875
  });
356184
- const elementId = validateElementId(
356185
- result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
356876
+ if (result.kind === "matched") return result.elementId;
356877
+ const shouldFailClosed = opts?.failClosedOnAmbiguity || result.kind === "ambiguous" && !opts?.tolerateAmbiguity;
356878
+ if (shouldFailClosed) {
356879
+ throw new MobileAmbiguousMatchError(
356880
+ `ambiguous locator: ${result.count} matches, cannot identify the recorded instance (strategy=${strategy.using} value=${strategy.value})`
356881
+ );
356882
+ }
356883
+ opts?.log?.(
356884
+ `\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.`
356186
356885
  );
356187
- if (elementId) return elementId;
356886
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
356188
356887
  } catch (err) {
356189
356888
  if (isTransportError(err)) throw err;
356889
+ if (err instanceof MobileAmbiguousMatchError) {
356890
+ lastAmbiguity = err;
356891
+ lastError = err.message;
356892
+ continue;
356893
+ }
356190
356894
  lastError = err instanceof Error ? err.message : String(err);
356191
356895
  }
356192
356896
  }
@@ -356201,6 +356905,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356201
356905
  }
356202
356906
  await new Promise((resolve2) => setTimeout(resolve2, 500));
356203
356907
  }
356908
+ if (lastAmbiguity) throw lastAmbiguity;
356204
356909
  throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
356205
356910
  }
356206
356911
  async function tapCoordinates(sessionId, bounds) {
@@ -356234,7 +356939,7 @@ async function prepareAndroidInputEnvironment(deviceId) {
356234
356939
  ];
356235
356940
  for (const [namespace, key, value] of settingsToDisable) {
356236
356941
  try {
356237
- await execFileAsync2("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356942
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356238
356943
  timeout: 8e3,
356239
356944
  windowsHide: true
356240
356945
  });
@@ -356246,14 +356951,14 @@ async function sendAndroidTextToFocusedInput(deviceId, value) {
356246
356951
  if (!deviceId) {
356247
356952
  throw new Error("Android keyboard fallback requires a device id");
356248
356953
  }
356249
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356954
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356250
356955
  timeout: 1e4,
356251
356956
  windowsHide: true
356252
356957
  });
356253
356958
  }
356254
356959
  async function pressAndroidKeyboardEnter(deviceId) {
356255
356960
  if (!deviceId) throw new Error("Android keyboard Enter fallback requires a device id");
356256
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356961
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356257
356962
  timeout: 1e4,
356258
356963
  windowsHide: true
356259
356964
  });
@@ -356341,21 +357046,40 @@ async function executeInWebViewContext(sessionId, step, css, body) {
356341
357046
  const cleanCss = css.startsWith("css=") ? css.slice(4) : css;
356342
357047
  await withWebViewContext(sessionPath, webview, () => body(sessionPath, cleanCss));
356343
357048
  }
356344
- async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
357049
+ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
357050
+ const mode = strictMatchMode();
356345
357051
  const startedAt = Date.now();
356346
357052
  let lastError = "";
356347
357053
  while (Date.now() - startedAt < timeoutMs) {
356348
357054
  try {
356349
- const result = await wdRequest(`${sessionPath}/element`, {
356350
- method: "POST",
356351
- body: JSON.stringify({ using: "css selector", value: css })
356352
- });
356353
- const elementId = validateElementId(
356354
- result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
356355
- );
356356
- if (elementId) return elementId;
357055
+ const ids = await findWebViewElements(sessionPath, css);
357056
+ if (ids.length === 0) {
357057
+ lastError = `no match for css=${css}`;
357058
+ } else if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
357059
+ return ids[0];
357060
+ } else {
357061
+ const candidates = ids.map((elementId) => ({ elementId }));
357062
+ const result = chooseRecordedMatch(candidates, {
357063
+ recordedBounds: null,
357064
+ matchIndex: opts?.matchIndex,
357065
+ expectedMatchCount: opts?.expectedMatchCount,
357066
+ strict: mode
357067
+ });
357068
+ if (result.kind === "matched") {
357069
+ return result.elementId;
357070
+ }
357071
+ if (result.kind === "ambiguous" && !opts?.tolerateAmbiguity) {
357072
+ throw new MobileAmbiguousMatchError(
357073
+ `ambiguous WEBVIEW locator: ${result.count} matches for css=${css}, cannot identify the recorded instance`
357074
+ );
357075
+ }
357076
+ opts?.log?.(
357077
+ `\u26A0 ambiguous WEBVIEW locator (${result.count} matches for css=${css}) \u2014 using the first match. Set VALIDATEQA_MOBILE_STRICT_MATCH=strict to fail instead.`
357078
+ );
357079
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
357080
+ }
356357
357081
  } catch (err) {
356358
- if (isTransportError(err)) throw err;
357082
+ if (isTransportError(err) || err instanceof MobileAmbiguousMatchError) throw err;
356359
357083
  lastError = err instanceof Error ? err.message : String(err);
356360
357084
  }
356361
357085
  await new Promise((resolve2) => setTimeout(resolve2, 250));
@@ -356364,21 +357088,42 @@ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIM
356364
357088
  `WEBVIEW element not found for css=${css} after ${timeoutMs}ms` + (lastError ? `. Last error: ${lastError}` : "")
356365
357089
  );
356366
357090
  }
357091
+ function isWebViewHydrationTransient(err) {
357092
+ const message = err instanceof Error ? err.message : String(err);
357093
+ 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);
357094
+ }
357095
+ var WEBVIEW_HYDRATION_TIMEOUT_MS = Number(process.env.VALIDATEQA_WEBVIEW_HYDRATION_TIMEOUT_MS) || 3e4;
357096
+ async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, body, timeoutMs) {
357097
+ const deadline = Date.now() + Math.max(1e3, timeoutMs);
357098
+ for (; ; ) {
357099
+ try {
357100
+ await executeInWebViewContext(sessionId, step, css, body);
357101
+ return;
357102
+ } catch (err) {
357103
+ if (!isWebViewHydrationTransient(err) || Date.now() >= deadline) throw err;
357104
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
357105
+ }
357106
+ }
357107
+ }
356367
357108
  async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
356368
357109
  const webCss = stepWebViewCss(step);
356369
357110
  if (webCss) {
356370
- await executeInWebViewContext(
357111
+ await executeInWebViewContextWithHydrationRetry(
356371
357112
  sessionId,
356372
357113
  step,
356373
357114
  webCss,
356374
- (sessionPath, css) => clickCssInCurrentContext(sessionPath, css)
357115
+ (sessionPath, css) => clickCssInCurrentContext(sessionPath, css),
357116
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356375
357117
  );
356376
357118
  return;
356377
357119
  }
356378
357120
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
356379
357121
  if (step.target) {
356380
357122
  try {
356381
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357123
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357124
+ platform: platform3,
357125
+ recordedBounds: bounds
357126
+ });
356382
357127
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356383
357128
  method: "POST",
356384
357129
  body: "{}"
@@ -356400,11 +357145,12 @@ async function executeType(sessionId, step, context) {
356400
357145
  const keysValue = keyboardSubmitOnly ? WEBDRIVER_ENTER_KEY : value;
356401
357146
  const webCss = stepWebViewCss(step);
356402
357147
  if (webCss) {
356403
- await executeInWebViewContext(
357148
+ await executeInWebViewContextWithHydrationRetry(
356404
357149
  sessionId,
356405
357150
  step,
356406
357151
  webCss,
356407
- (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value)
357152
+ (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value),
357153
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356408
357154
  );
356409
357155
  return;
356410
357156
  }
@@ -356415,7 +357161,9 @@ async function executeType(sessionId, step, context) {
356415
357161
  const locatorStrategies = buildLocatorStrategies(step.target, context.platform);
356416
357162
  if (step.target && locatorStrategies.length > 0) {
356417
357163
  const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
356418
- platform: context.platform
357164
+ platform: context.platform,
357165
+ recordedBounds: bounds,
357166
+ log: context.log
356419
357167
  });
356420
357168
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356421
357169
  method: "POST",
@@ -356456,22 +357204,28 @@ async function executeType(sessionId, step, context) {
356456
357204
  }
356457
357205
  async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
356458
357206
  const webCss = stepWebViewCss(step);
357207
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
356459
357208
  if (webCss) {
356460
357209
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356461
- const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357210
+ const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357211
+ matchIndex: step.target?.matchIndex,
357212
+ expectedMatchCount: step.target?.expectedMatchCount
357213
+ });
356462
357214
  await wdRequest(`${sessionPath}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356463
357215
  });
356464
357216
  return;
356465
357217
  }
356466
357218
  const locatorStrategies = buildLocatorStrategies(step.target, platform3);
356467
357219
  if (step.target && locatorStrategies.length > 0) {
356468
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357220
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357221
+ platform: platform3,
357222
+ recordedBounds: bounds
357223
+ });
356469
357224
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
356470
357225
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
356471
357226
  await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356472
357227
  return;
356473
357228
  }
356474
- const bounds = boundsForRun(step, platform3, isCrossPlatform);
356475
357229
  await clearField(`/session/${sessionId}`, bounds ?? void 0);
356476
357230
  }
356477
357231
  async function executeSwipe(sessionId, step) {
@@ -356532,34 +357286,47 @@ async function executeWaitForElement(sessionId, step, platform3) {
356532
357286
  sessionId,
356533
357287
  step,
356534
357288
  webCss,
356535
- (sessionPath, css) => findWebViewElement(sessionPath, css, timeout).then(() => void 0)
357289
+ (sessionPath, css) => findWebViewElement(sessionPath, css, timeout, { disambiguate: false }).then(() => void 0)
356536
357290
  );
356537
357291
  return;
356538
357292
  }
356539
- await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
357293
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3, disambiguate: false });
356540
357294
  }
356541
- async function executeAssertVisible(sessionId, step, platform3) {
357295
+ async function executeAssertVisible(sessionId, step, platform3, isCrossPlatform = false) {
356542
357296
  const webCss = stepWebViewCss(step);
356543
357297
  if (webCss) {
356544
357298
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356545
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357299
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357300
+ matchIndex: step.target?.matchIndex,
357301
+ expectedMatchCount: step.target?.expectedMatchCount,
357302
+ tolerateAmbiguity: true
357303
+ });
356546
357304
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/displayed`);
356547
357305
  if (!result2?.value) throw new Error(`WEBVIEW element found but not visible: css=${css}`);
356548
357306
  });
356549
357307
  return;
356550
357308
  }
356551
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357309
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357310
+ identityOnly: true,
357311
+ platform: platform3,
357312
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357313
+ tolerateAmbiguity: true
357314
+ });
356552
357315
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
356553
357316
  if (!result?.value) {
356554
357317
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
356555
357318
  }
356556
357319
  }
356557
- async function executeAssertText(sessionId, step, platform3) {
357320
+ async function executeAssertText(sessionId, step, platform3, isCrossPlatform = false) {
356558
357321
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
356559
357322
  const webCss = stepWebViewCss(step);
356560
357323
  if (webCss) {
356561
357324
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356562
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357325
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357326
+ matchIndex: step.target?.matchIndex,
357327
+ expectedMatchCount: step.target?.expectedMatchCount,
357328
+ tolerateAmbiguity: true
357329
+ });
356563
357330
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/text`);
356564
357331
  const actualText2 = typeof result2?.value === "string" ? result2.value : "";
356565
357332
  const textCandidates2 = await readElementTextCandidates(sessionId, elementId2, actualText2);
@@ -356571,7 +357338,12 @@ async function executeAssertText(sessionId, step, platform3) {
356571
357338
  });
356572
357339
  return;
356573
357340
  }
356574
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357341
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357342
+ identityOnly: true,
357343
+ platform: platform3,
357344
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357345
+ tolerateAmbiguity: true
357346
+ });
356575
357347
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
356576
357348
  const actualText = typeof result?.value === "string" ? result.value : "";
356577
357349
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -356611,6 +357383,8 @@ function acquireControlPortSlot(deviceId) {
356611
357383
  function releaseControlPortSlot(deviceId) {
356612
357384
  controlPortSlotsByDevice.delete(deviceId);
356613
357385
  }
357386
+ var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357387
+ var pinnedDeviceSnapshot = null;
356614
357388
  async function executeMobileTest(options) {
356615
357389
  const logLines = [];
356616
357390
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -356618,6 +357392,7 @@ async function executeMobileTest(options) {
356618
357392
  const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
356619
357393
  const runPlatform = options.target.platform;
356620
357394
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
357395
+ const captureStepScreenshots = options.captureStepScreenshots !== false;
356621
357396
  if (missingVariableKeys.length > 0) {
356622
357397
  log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
356623
357398
  }
@@ -356682,32 +357457,42 @@ async function executeMobileTest(options) {
356682
357457
  await startAppiumServer();
356683
357458
  log2("Appium server started");
356684
357459
  }
356685
- log2("Discovering devices...");
356686
- const devices = discoverAllDevices({ includeApps: true });
356687
357460
  const platformFilter = options.target.platform;
356688
- const matchingDevices = devices.filter(
356689
- (d) => d.platform === platformFilter && d.isAvailable && d.state === "BOOTED"
356690
- );
356691
- if (matchingDevices.length === 0) {
356692
- throw new Error(
356693
- `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
357461
+ const pinnedDeviceId = options.target.assignedDeviceId;
357462
+ const candidateSnapshot = pinnedDeviceSnapshot;
357463
+ const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && candidateSnapshot.deviceId === pinnedDeviceId && Date.now() - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
357464
+ if (reusablePinnedSnapshot) {
357465
+ assertDeviceNotQuarantined(reusablePinnedSnapshot.device.id);
357466
+ selectedDevice = reusablePinnedSnapshot.device;
357467
+ log2(`Reusing known device (skipped device re-scan): ${selectedDevice.name} (${selectedDevice.id})`);
357468
+ } else {
357469
+ log2("Discovering devices...");
357470
+ const devices = discoverAllDevices({ includeApps: true });
357471
+ const matchingDevices = devices.filter(
357472
+ (d) => d.platform === platformFilter && d.isAvailable && d.state === "BOOTED"
356694
357473
  );
356695
- }
356696
- if (options.target.assignedDeviceId) {
356697
- selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
356698
- if (!selectedDevice) {
357474
+ if (matchingDevices.length === 0) {
356699
357475
  throw new Error(
356700
- `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
+ `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
356701
357477
  );
356702
357478
  }
357479
+ if (options.target.assignedDeviceId) {
357480
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
357481
+ if (!selectedDevice) {
357482
+ throw new Error(
357483
+ `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.`
357484
+ );
357485
+ }
357486
+ }
357487
+ if (!selectedDevice && options.target.recordedDeviceId) {
357488
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
357489
+ }
357490
+ if (!selectedDevice) {
357491
+ selectedDevice = matchingDevices[0];
357492
+ }
357493
+ log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
357494
+ pinnedDeviceSnapshot = options.target.assignedDeviceId ? { deviceId: options.target.assignedDeviceId, device: selectedDevice, at: Date.now() } : null;
356703
357495
  }
356704
- if (!selectedDevice && options.target.recordedDeviceId) {
356705
- selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
356706
- }
356707
- if (!selectedDevice) {
356708
- selectedDevice = matchingDevices[0];
356709
- }
356710
- log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
356711
357496
  try {
356712
357497
  capture = await startMobileNetworkCapture({
356713
357498
  platform: options.target.platform,
@@ -356774,7 +357559,14 @@ async function executeMobileTest(options) {
356774
357559
  const resetAllowed = shouldResetMobileAppData(options.target.platform, selectedDevice.id);
356775
357560
  if (resetAllowed) {
356776
357561
  try {
356777
- await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357562
+ if (options.target.platform === "IOS") {
357563
+ await terminateTargetApp(sessionId, "IOS", options.target.appId).catch(() => {
357564
+ });
357565
+ const wiped = await clearIosSimulatorAppData(selectedDevice.id, options.target.appId);
357566
+ if (!wiped.ok) throw new Error(wiped.detail);
357567
+ } else {
357568
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357569
+ }
356778
357570
  clearedAppDataBeforeLaunch = true;
356779
357571
  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).");
356780
357572
  } catch (err) {
@@ -356827,17 +357619,21 @@ async function executeMobileTest(options) {
356827
357619
  const entryStep = selectEntryDriftGateStep(runSteps, runPlatform);
356828
357620
  if (entryStep) {
356829
357621
  try {
356830
- const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
357622
+ const entryTimeout = Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS);
356831
357623
  const entryWebCss = stepWebViewCss(entryStep);
356832
357624
  if (entryWebCss) {
356833
357625
  await executeInWebViewContext(
356834
357626
  sessionId,
356835
357627
  entryStep,
356836
357628
  entryWebCss,
356837
- (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout).then(() => void 0)
357629
+ (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout, { disambiguate: false }).then(() => void 0)
356838
357630
  );
356839
357631
  } else {
356840
- await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
357632
+ await findElement(sessionId, entryStep.target, entryTimeout, {
357633
+ identityOnly: true,
357634
+ platform: runPlatform,
357635
+ disambiguate: false
357636
+ });
356841
357637
  }
356842
357638
  } catch (err) {
356843
357639
  if (isTransportError(err)) throw err;
@@ -356897,10 +357693,10 @@ async function executeMobileTest(options) {
356897
357693
  await executeWaitForElement(sessionId, step, runPlatform);
356898
357694
  break;
356899
357695
  case "assertVisible":
356900
- await executeAssertVisible(sessionId, step, runPlatform);
357696
+ await executeAssertVisible(sessionId, step, runPlatform, isCrossPlatform);
356901
357697
  break;
356902
357698
  case "assertText":
356903
- await executeAssertText(sessionId, step, runPlatform);
357699
+ await executeAssertText(sessionId, step, runPlatform, isCrossPlatform);
356904
357700
  break;
356905
357701
  default: {
356906
357702
  const _exhaustive = step.action;
@@ -356919,7 +357715,7 @@ async function executeMobileTest(options) {
356919
357715
  environmental = stepError;
356920
357716
  }
356921
357717
  }
356922
- const screenshot = await takeScreenshot(sessionId).catch(() => null);
357718
+ const screenshot = captureStepScreenshots ? await takeScreenshot(sessionId).catch(() => null) : null;
356923
357719
  screenshots.push(screenshot ?? "");
356924
357720
  if (status === "failed") {
356925
357721
  await recordFirstFailingSource();
@@ -357181,10 +357977,10 @@ async function driveHarvestStep(sessionId, step, elementId, platform3) {
357181
357977
  await executeWaitForElement(sessionId, step, platform3);
357182
357978
  return true;
357183
357979
  case "assertVisible":
357184
- await executeAssertVisible(sessionId, step, platform3);
357980
+ await executeAssertVisible(sessionId, step, platform3, true);
357185
357981
  return true;
357186
357982
  case "assertText":
357187
- await executeAssertText(sessionId, step, platform3);
357983
+ await executeAssertText(sessionId, step, platform3, true);
357188
357984
  return true;
357189
357985
  default:
357190
357986
  break;
@@ -357266,7 +358062,15 @@ async function harvestPlatformLocators(options) {
357266
358062
  const identityStrategies = webViewCss ? [] : buildIdentityLocatorStrategies(step.target);
357267
358063
  if (!webViewCss && identityStrategies.length > 0 && step.target) {
357268
358064
  try {
357269
- elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
358065
+ elementId = await findElement(
358066
+ session.sessionId,
358067
+ step.target,
358068
+ Math.max(DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS),
358069
+ {
358070
+ identityOnly: true,
358071
+ failClosedOnAmbiguity: true
358072
+ }
358073
+ );
357270
358074
  } catch (err) {
357271
358075
  if (isTransportError(err)) throw err;
357272
358076
  elementId = null;
@@ -357435,7 +358239,7 @@ import { createServer } from "http";
357435
358239
  import { execFileSync as execFileSync4 } from "child_process";
357436
358240
  import { randomBytes, timingSafeEqual } from "crypto";
357437
358241
  import { homedir as homedir2 } from "os";
357438
- import { join as join5, dirname } from "path";
358242
+ import { join as join6, dirname } from "path";
357439
358243
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3 } from "fs";
357440
358244
  init_dist();
357441
358245
  var MOBILE_BRIDGE_PORT2 = 9515;
@@ -357542,7 +358346,7 @@ async function getWindowSize3() {
357542
358346
  }
357543
358347
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
357544
358348
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
357545
- var BRIDGE_PID_FILE = join5(homedir2(), ".validate.qa", "mobile-bridge.pid");
358349
+ var BRIDGE_PID_FILE = join6(homedir2(), ".validate.qa", "mobile-bridge.pid");
357546
358350
  function readBridgePidFile() {
357547
358351
  try {
357548
358352
  const raw = readFileSync2(BRIDGE_PID_FILE, "utf-8").trim();
@@ -357854,6 +358658,13 @@ async function startSession(validated, res) {
357854
358658
  ...deviceId ? { "appium:udid": deviceId } : {},
357855
358659
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
357856
358660
  "appium:noReset": true,
358661
+ // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
358662
+ // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
358663
+ // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
358664
+ // skip the GUI open. Scoped to simulator UDIDs; APPIUM_IOS_HEADLESS=false
358665
+ // overrides. Verified live: without this, recorder/mirror sessions on iOS
358666
+ // die with "Simulator.app does not exist" while executor sessions work.
358667
+ ...isIosSimulatorUdid(deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
357857
358668
  ...realCaps
357858
358669
  } : {
357859
358670
  platformName: "Android",
@@ -357872,7 +358683,12 @@ async function startSession(validated, res) {
357872
358683
  capabilities: {
357873
358684
  alwaysMatch: capabilities
357874
358685
  }
357875
- })
358686
+ }),
358687
+ // Session creation is the one legitimately slow WebDriver call (iOS WDA
358688
+ // build/launch can take minutes on a cold toolchain) — same ceiling as
358689
+ // the executor's wdRequest session-create path. Without this, the shared
358690
+ // appiumRequest 10s default aborts every iOS recorder session.
358691
+ signal: AbortSignal.timeout(WD_SESSION_CREATE_TIMEOUT_MS)
357876
358692
  });
357877
358693
  state.appiumSessionId = result?.value?.sessionId ?? result?.sessionId ?? null;
357878
358694
  if (!state.appiumSessionId) {
@@ -358650,6 +359466,9 @@ function detachMirrorSession(deviceId) {
358650
359466
  stopAllMirrorClients();
358651
359467
  stopSessionReaper();
358652
359468
  }
359469
+ function getMirrorSessionId(deviceId) {
359470
+ return runnerChannels.get(deviceId)?.sessionId;
359471
+ }
358653
359472
 
358654
359473
  // src/services/mobile/mobile-bridge-driver.ts
358655
359474
  var import_runner_core9 = __toESM(require_dist2());
@@ -358680,6 +359499,7 @@ var MobileBridgeDriver = class {
358680
359499
  sessionId;
358681
359500
  platform;
358682
359501
  appId;
359502
+ deviceId;
358683
359503
  allowAppReset;
358684
359504
  recoverSession;
358685
359505
  maxSessionRecoveries;
@@ -358700,6 +359520,7 @@ var MobileBridgeDriver = class {
358700
359520
  this.sessionId = config.sessionId;
358701
359521
  this.platform = config.platform;
358702
359522
  this.appId = config.appId;
359523
+ this.deviceId = config.deviceId;
358703
359524
  this.allowAppReset = config.allowAppReset === true;
358704
359525
  this.recoverSession = config.recoverSession;
358705
359526
  this.maxSessionRecoveries = Math.max(0, Math.floor(config.maxSessionRecoveries ?? 1));
@@ -358997,17 +359818,34 @@ var MobileBridgeDriver = class {
358997
359818
  });
358998
359819
  }
358999
359820
  /**
359000
- * Deterministic state reset: clear app data + relaunch (Android emulators
359001
- * only, per the runner-computed allowAppReset policy). Returns false when
359002
- * unsupported/disallowed or when clearApp throws — callers fall back to
359003
- * relaunch(), preserving today's behavior. On success, waits for cold-boot
359004
- * content so recording and replay agree on the true entry screen.
359821
+ * Deterministic state reset: clear app data + relaunch, per the
359822
+ * runner-computed allowAppReset policy (Android emulators / iOS simulators
359823
+ * only — a personal physical device is never wiped). Android clears via
359824
+ * `mobile: clearApp`; iOS terminates the app and wipes its simulator data
359825
+ * container + keychain through `simctl` (see ios-simulator-reset.ts).
359826
+ * Returns false when unsupported/disallowed or when the clear fails —
359827
+ * callers fall back to relaunch(), preserving today's behavior. On success,
359828
+ * waits for cold-boot content so recording and replay agree on the true
359829
+ * entry screen.
359005
359830
  */
359006
359831
  async resetAppState() {
359007
- if (!this.allowAppReset || this.platform !== "ANDROID") return false;
359832
+ if (!this.allowAppReset) return false;
359833
+ if (this.platform === "IOS" && !isIosSimulatorUdid(this.deviceId)) return false;
359008
359834
  return this.withSessionRecovery("app data reset", async () => {
359009
359835
  try {
359010
- await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359836
+ if (this.platform === "IOS") {
359837
+ try {
359838
+ await this.executeScript("mobile: terminateApp", [{ ...this.appLifecycleArgs(), timeout: 5e3 }]);
359839
+ } catch (err) {
359840
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
359841
+ }
359842
+ const wiped = await clearIosSimulatorAppData(this.deviceId ?? "", this.appId);
359843
+ if (!wiped.ok) {
359844
+ throw new Error(`iOS simulator app-data reset failed: ${wiped.detail}`);
359845
+ }
359846
+ } else {
359847
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359848
+ }
359011
359849
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
359012
359850
  try {
359013
359851
  await waitForColdBootSettle(this.sessionPath);
@@ -359598,6 +360436,113 @@ function envInt(name, fallback) {
359598
360436
  }
359599
360437
  var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
359600
360438
  var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
360439
+ var MOBILE_RUN_WATCHDOG_MS = Math.max(0, envInt("MOBILE_RUN_WATCHDOG_MS", 30 * 6e4));
360440
+ var MOBILE_RUN_ABORT_GRACE_MS = Math.max(0, envInt("MOBILE_RUN_ABORT_GRACE_MS", 2 * 6e4));
360441
+ var MOBILE_CANCEL_POLL_MS = Math.max(0, envInt("MOBILE_CANCEL_POLL_MS", 15e3));
360442
+ var MobileRunWatchdog = class {
360443
+ constructor(options) {
360444
+ this.options = options;
360445
+ }
360446
+ phase = "idle";
360447
+ released = false;
360448
+ quarantined = false;
360449
+ cancelAbortTimer = null;
360450
+ cancelGraceTimer = null;
360451
+ /** Current phase (exposed for tests / observability). */
360452
+ getPhase() {
360453
+ return this.phase;
360454
+ }
360455
+ /** Arm the wall-clock abort timer. Call once, immediately after dispatch. */
360456
+ start() {
360457
+ if (this.phase !== "idle") return;
360458
+ this.phase = "active";
360459
+ if (this.options.abortMs > 0) {
360460
+ this.cancelAbortTimer = this.options.schedule(
360461
+ () => this.triggerAbort(`exceeded the ${Math.round(this.options.abortMs / 6e4)}min wall-clock ceiling`),
360462
+ this.options.abortMs
360463
+ );
360464
+ }
360465
+ }
360466
+ /** Stage 1. Idempotent (a no-op unless still ACTIVE). Also called by the
360467
+ * cancellation observer. Quarantines the device (blocking the run's own
360468
+ * session-recovery from resurrecting the killed session — observed live),
360469
+ * kills the run's session (best-effort), and starts the grace clock;
360470
+ * deliberately does NOT release the lease. */
360471
+ triggerAbort(reason) {
360472
+ if (this.phase !== "active") return;
360473
+ this.phase = "aborting";
360474
+ this.clearAbortTimer();
360475
+ if (this.options.deviceId) {
360476
+ this.quarantined = true;
360477
+ this.options.callbacks.quarantine(this.options.deviceId);
360478
+ this.options.callbacks.abort(this.options.deviceId, this.options.runId, reason);
360479
+ } else {
360480
+ this.options.callbacks.log("warn", `Run ${this.options.runId} ${reason} \u2014 no leased device to abort; awaiting the run's own settle.`);
360481
+ }
360482
+ if (this.options.graceMs > 0) {
360483
+ this.cancelGraceTimer = this.options.schedule(() => this.forceRelease(), this.options.graceMs);
360484
+ } else {
360485
+ this.forceRelease();
360486
+ }
360487
+ }
360488
+ /** The run's promise settled (normal completion, or after an abort). The single
360489
+ * release owner: releases at most once and lifts any quarantine. Idempotent. */
360490
+ settle() {
360491
+ if (this.phase === "settled") return;
360492
+ const wasForceReleased = this.released;
360493
+ this.phase = "settled";
360494
+ this.clearAbortTimer();
360495
+ this.clearGraceTimer();
360496
+ if (!this.released) {
360497
+ this.released = true;
360498
+ this.options.callbacks.release();
360499
+ }
360500
+ if (this.quarantined && this.options.deviceId) {
360501
+ this.quarantined = false;
360502
+ this.options.callbacks.unquarantine(this.options.deviceId);
360503
+ if (wasForceReleased) {
360504
+ this.options.callbacks.log("info", `Run ${this.options.runId} finally settled \u2014 lifted the quarantine on device ${this.options.deviceId}.`);
360505
+ }
360506
+ }
360507
+ }
360508
+ /** Stage 2. The run did not settle within the grace window after the abort. Force-
360509
+ * release the slot to unwedge capacity; the device stays quarantined (since the
360510
+ * stage-1 abort) until the promise eventually settles. Idempotent and guarded
360511
+ * against a late settle. */
360512
+ forceRelease() {
360513
+ if (this.released || this.phase === "settled") return;
360514
+ this.clearGraceTimer();
360515
+ this.released = true;
360516
+ this.options.callbacks.release();
360517
+ if (this.options.deviceId) {
360518
+ if (!this.quarantined) {
360519
+ this.quarantined = true;
360520
+ this.options.callbacks.quarantine(this.options.deviceId);
360521
+ }
360522
+ this.options.callbacks.log(
360523
+ "warn",
360524
+ `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.`
360525
+ );
360526
+ } else {
360527
+ this.options.callbacks.log("warn", `Run ${this.options.runId} did not settle after abort \u2014 force-released its slot to unwedge mobile capacity.`);
360528
+ }
360529
+ }
360530
+ clearAbortTimer() {
360531
+ if (this.cancelAbortTimer) {
360532
+ this.cancelAbortTimer();
360533
+ this.cancelAbortTimer = null;
360534
+ }
360535
+ }
360536
+ clearGraceTimer() {
360537
+ if (this.cancelGraceTimer) {
360538
+ this.cancelGraceTimer();
360539
+ this.cancelGraceTimer = null;
360540
+ }
360541
+ }
360542
+ };
360543
+ function isServerCancelledRunStatus(status) {
360544
+ return status === "ERROR" || status === "CANCELLED" || status === "CANCELLING";
360545
+ }
359601
360546
  function scrubAuditMessages(messages, testVariables) {
359602
360547
  if (messages === void 0 || messages === null) return messages;
359603
360548
  const compacted = compactForAudit(messages);
@@ -359673,7 +360618,7 @@ var log = {
359673
360618
  dim: (msg) => console.log(`${C2.gray}${msg}${C2.reset}`)
359674
360619
  };
359675
360620
  function getRunsDir() {
359676
- const dir = process.env.RUNS_DIR || join6(homedir3(), ".validate.qa", "runs");
360621
+ const dir = process.env.RUNS_DIR || join7(homedir3(), ".validate.qa", "runs");
359677
360622
  (0, import_runner_core12.ensurePrivateDirectory)(dir);
359678
360623
  return dir;
359679
360624
  }
@@ -361159,9 +362104,15 @@ ${finalVerifyResult.logs ?? ""}`;
361159
362104
  // flip the test to needs_manual_authoring), so an environmentally
361160
362105
  // aborted pass — wrong screen, system dialog, transport flake —
361161
362106
  // must not persist step divergences it could not fairly judge.
361162
- // Harvested bundles are still posted: they were positively
361163
- // resolved before the abort and the merge is idempotent.
361164
- divergentOrders: harvest.environmentalError ? [] : harvest.divergentOrders,
362107
+ // The same applies to a harvest that ADVANCED THROUGH NOTHING
362108
+ // (reachedOrder null): it never got past the entry screen, so its
362109
+ // step judgments are cold-boot/entry noise, not app knowledge —
362110
+ // observed live: a slow WKWebView hydration marked step 1
362111
+ // divergent and permanently dead-ended the test in
362112
+ // needs_manual_authoring with no retry path. Harvested bundles
362113
+ // are still posted: they were positively resolved and the merge
362114
+ // is idempotent.
362115
+ divergentOrders: harvest.environmentalError || harvest.reachedOrder == null ? [] : harvest.divergentOrders,
361165
362116
  reachedOrder: harvest.reachedOrder,
361166
362117
  logs: harvest.logs,
361167
362118
  ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
@@ -361605,9 +362556,11 @@ ${finalVerifyResult.logs ?? ""}`;
361605
362556
  sessionId: session.sessionId,
361606
362557
  platform: platform3,
361607
362558
  appId: session.appId,
361608
- // Deterministic-state authorization: Android emulators only by
361609
- // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
361610
- // physical device must never have its app data wiped.
362559
+ deviceId: session.deviceId,
362560
+ // Deterministic-state authorization: Android emulators / iOS
362561
+ // simulators only (VALIDATEQA_MOBILE_APP_RESET overrides the
362562
+ // Android default) — a personal physical device must never have
362563
+ // its app data wiped.
361611
362564
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
361612
362565
  maxSessionRecoveries: 1,
361613
362566
  recoverSession: async (cause) => {
@@ -362123,6 +363076,7 @@ ${finalVerifyResult.logs ?? ""}`;
362123
363076
  sessionId: opened.sessionId,
362124
363077
  platform: target.platform,
362125
363078
  appId: opened.appId,
363079
+ deviceId: opened.deviceId,
362126
363080
  allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, opened.deviceId)
362127
363081
  });
362128
363082
  };
@@ -362735,6 +363689,7 @@ async function startRunner(opts) {
362735
363689
  else activeMobileOpsByPlatform.set(p, next);
362736
363690
  };
362737
363691
  const leasedDeviceIds = /* @__PURE__ */ new Map();
363692
+ const activeMobileRuns = /* @__PURE__ */ new Map();
362738
363693
  let activeMobileDiscoveries = 0;
362739
363694
  const mobileHealHold = /* @__PURE__ */ new Map();
362740
363695
  const pruneMobileHealHolds = () => {
@@ -362985,6 +363940,32 @@ async function startRunner(opts) {
362985
363940
  }
362986
363941
  }, PIDS_WATCHDOG_INTERVAL_MS);
362987
363942
  pidsWatchdog.unref();
363943
+ const pollMobileRunCancellations = async () => {
363944
+ if (shuttingDown || activeMobileRuns.size === 0) return;
363945
+ const runIds = [...activeMobileRuns.keys()];
363946
+ try {
363947
+ const res = await fetch(`${serverUrl}/api/runner/runs/status`, {
363948
+ method: "POST",
363949
+ headers: { ...headers, "Content-Type": "application/json" },
363950
+ body: JSON.stringify({ runIds }),
363951
+ signal: AbortSignal.timeout(1e4)
363952
+ });
363953
+ if (!res.ok) return;
363954
+ const body = await res.json().catch(() => null);
363955
+ if (!body || !Array.isArray(body.runs)) return;
363956
+ for (const row of body.runs) {
363957
+ if (!row || typeof row.id !== "string" || typeof row.status !== "string") continue;
363958
+ if (!isServerCancelledRunStatus(row.status)) continue;
363959
+ const watchdog = activeMobileRuns.get(row.id);
363960
+ if (watchdog) watchdog.triggerAbort(`was cancelled server-side (status ${row.status})`);
363961
+ }
363962
+ } catch {
363963
+ }
363964
+ };
363965
+ const mobileCancelPoll = enableMobile && MOBILE_CANCEL_POLL_MS > 0 ? setInterval(() => {
363966
+ void pollMobileRunCancellations();
363967
+ }, MOBILE_CANCEL_POLL_MS) : null;
363968
+ mobileCancelPoll?.unref?.();
362988
363969
  const deferralLogged = /* @__PURE__ */ new Map();
362989
363970
  const logDeferredOnce = (runId, line) => {
362990
363971
  if (deferralLogged.get(runId) === line) return;
@@ -363067,14 +364048,17 @@ async function startRunner(opts) {
363067
364048
  });
363068
364049
  const capacity = MOBILE_DEVICE_CEILING > 0 ? Math.min(derived, MOBILE_DEVICE_CEILING) : derived;
363069
364050
  const leaseTarget = run2.target ?? run2.deviceRequirements;
363070
- const leaseSelection = selectFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot, {
364051
+ const leaseExclusions = {
364052
+ has: (id) => leasedDeviceIds.has(id) || isDeviceQuarantined(id)
364053
+ };
364054
+ const leaseSelection = selectFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot, {
363071
364055
  appId: typeof leaseTarget?.appId === "string" ? leaseTarget.appId : void 0,
363072
364056
  recordedDeviceId: typeof leaseTarget?.recordedDeviceId === "string" ? leaseTarget.recordedDeviceId : void 0,
363073
364057
  platformVersion: typeof leaseTarget?.platformVersion === "string" ? leaseTarget.platformVersion : void 0,
363074
364058
  deviceName: typeof run2.deviceRequirements?.deviceName === "string" ? run2.deviceRequirements.deviceName : void 0,
363075
364059
  resetPolicy: leaseTarget?.resetPolicy
363076
364060
  });
363077
- const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot) : null;
364061
+ const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot) : null;
363078
364062
  if (leaseSelection.kind === "unsatisfiable") {
363079
364063
  mobileLeaseError = leaseSelection.reason;
363080
364064
  log.warn(` Mobile requirements unsatisfiable for ${run2.runId}: ${leaseSelection.reason}; claiming once to report the configuration error`);
@@ -363132,6 +364116,54 @@ async function startRunner(opts) {
363132
364116
  await new Promise((r) => setTimeout(r, staggerMs));
363133
364117
  }
363134
364118
  claimedThisPoll++;
364119
+ const applyRelease = () => {
364120
+ activeRuns--;
364121
+ decrementType(runMode);
364122
+ decrementProject(runProject);
364123
+ if (isMobileOp) {
364124
+ if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
364125
+ if (isDiscovery) activeMobileDiscoveries--;
364126
+ if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
364127
+ activeMobileRuns.delete(run2.runId);
364128
+ }
364129
+ };
364130
+ const runWatchdog = isMobileOp ? new MobileRunWatchdog({
364131
+ runId: run2.runId,
364132
+ deviceId: leasedDeviceId ?? void 0,
364133
+ abortMs: MOBILE_RUN_WATCHDOG_MS,
364134
+ graceMs: MOBILE_RUN_ABORT_GRACE_MS,
364135
+ // Real timers, unref'd so a pending watchdog never keeps the process
364136
+ // alive on shutdown.
364137
+ schedule: (fn, ms) => {
364138
+ const t = setTimeout(fn, ms);
364139
+ if (typeof t.unref === "function") t.unref();
364140
+ return () => clearTimeout(t);
364141
+ },
364142
+ callbacks: {
364143
+ abort: (deviceId, rid, reason) => {
364144
+ const sessionId = getMirrorSessionId(deviceId);
364145
+ log.warn(
364146
+ `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.`
364147
+ );
364148
+ if (sessionId) void deleteAppiumSession(sessionId);
364149
+ },
364150
+ release: applyRelease,
364151
+ quarantine: (deviceId) => {
364152
+ quarantineDevice(deviceId);
364153
+ },
364154
+ unquarantine: (deviceId) => {
364155
+ unquarantineDevice(deviceId);
364156
+ },
364157
+ log: (level, message) => {
364158
+ if (level === "warn") log.warn(message);
364159
+ else log.info(message);
364160
+ }
364161
+ }
364162
+ }) : null;
364163
+ if (runWatchdog) {
364164
+ activeMobileRuns.set(run2.runId, runWatchdog);
364165
+ runWatchdog.start();
364166
+ }
363135
364167
  executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, (testCaseId) => {
363136
364168
  if (mobilePlatform) {
363137
364169
  mobileHealHold.set(testCaseId, { expiry: Date.now() + MOBILE_HEAL_HOLD_MS, platform: mobilePlatform });
@@ -363142,14 +364174,8 @@ async function startRunner(opts) {
363142
364174
  const msg = err instanceof Error ? err.message : String(err);
363143
364175
  log.warn(`Run ${run2.runId} crashed: ${msg}`);
363144
364176
  }).finally(() => {
363145
- activeRuns--;
363146
- decrementType(runMode);
363147
- decrementProject(runProject);
363148
- if (isMobileOp) {
363149
- if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
363150
- if (isDiscovery) activeMobileDiscoveries--;
363151
- if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
363152
- }
364177
+ if (runWatchdog) runWatchdog.settle();
364178
+ else applyRelease();
363153
364179
  });
363154
364180
  }
363155
364181
  }