@validate.qa/runner 1.0.70 → 1.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +1526 -384
  2. package/dist/cli.mjs +1523 -381
  3. package/package.json +2 -2
package/dist/cli.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) {
@@ -11297,8 +11496,8 @@ var require_thread_stream = __commonJS({
11297
11496
  "use strict";
11298
11497
  var { version } = require_package();
11299
11498
  var { EventEmitter: EventEmitter2 } = __require("events");
11300
- var { Worker } = __require("worker_threads");
11301
- var { join: join7 } = __require("path");
11499
+ var { Worker: Worker2 } = __require("worker_threads");
11500
+ var { join: join8 } = __require("path");
11302
11501
  var { pathToFileURL } = __require("url");
11303
11502
  var { wait } = require_wait();
11304
11503
  var {
@@ -11349,8 +11548,8 @@ 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");
11353
- const worker = new Worker(toExecute, {
11551
+ const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11552
+ const worker = new Worker2(toExecute, {
11354
11553
  ...opts.workerOpts,
11355
11554
  name: opts.workerOpts?.name || "thread-stream",
11356
11555
  trackUnmanagedFds: false,
@@ -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) => {
@@ -74450,6 +74649,7 @@ var require_mobile_explorer = __commonJS({
74450
74649
  Object.defineProperty(exports, "__esModule", { value: true });
74451
74650
  exports.computeMobileSurveyBreadthContinuation = computeMobileSurveyBreadthContinuation;
74452
74651
  exports.computeMobileBreadthFinishRedirectDecision = computeMobileBreadthFinishRedirectDecision;
74652
+ exports.hybridScreenFingerprint = hybridScreenFingerprint;
74453
74653
  exports.runMobileExplorePhase = runMobileExplorePhase;
74454
74654
  var crypto_1 = __require("crypto");
74455
74655
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
@@ -74903,11 +75103,12 @@ var require_mobile_explorer = __commonJS({
74903
75103
  const validSurfaceDigest = /^[0-9a-f]{16,64}$/.test(normalizedSurfaceDigest) ? normalizedSurfaceDigest : void 0;
74904
75104
  const legacyElementDigests = [...new Set(elements.map((element) => element.pageIdentityDigest?.trim().toLowerCase() ?? "").filter((digest) => /^[0-9a-f]{16,64}$/.test(digest)))];
74905
75105
  const pageIdentityDigest = validSurfaceDigest ?? (legacyElementDigests.length === 1 ? legacyElementDigests[0] : void 0);
75106
+ const hasStableControlIdentity = elements.some((element) => Boolean(safeHybridIdentity(element.id, credentials) || safeHybridIdentity(element.name, credentials)));
74906
75107
  const descriptors = elements.map((element) => {
74907
75108
  const editable = element.editable === true || /^(?:input|textarea|select)$/i.test(element.tag) || /(?:textbox|searchbox|combobox)/i.test(element.role);
74908
75109
  const id = safeHybridIdentity(element.id, credentials)?.toLowerCase() ?? "";
74909
75110
  const name = safeHybridIdentity(element.name, credentials)?.toLowerCase() ?? "";
74910
- const semanticText = editable || id || name ? "" : stableHybridSemanticText(element.text, credentials);
75111
+ const semanticText = editable || id || name || hasStableControlIdentity ? "" : stableHybridSemanticText(element.text, credentials);
74911
75112
  return [
74912
75113
  element.tag.trim().toLowerCase(),
74913
75114
  element.role.trim().toLowerCase(),
@@ -81172,10 +81373,14 @@ var require_mobile_generator = __commonJS({
81172
81373
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
81173
81374
  "use strict";
81174
81375
  Object.defineProperty(exports, "__esModule", { value: true });
81376
+ exports.structuralScreenKey = structuralScreenKey;
81175
81377
  exports.collapseConsecutiveBacks = collapseConsecutiveBacks;
81176
81378
  exports.scenarioIntentMismatchReason = scenarioIntentMismatchReason;
81177
81379
  exports.dedupeConsecutiveIdenticalActions = dedupeConsecutiveIdenticalActions;
81178
81380
  exports.collapseRetryCycles = collapseRetryCycles;
81381
+ exports.coalesceSameFieldTypes = coalesceSameFieldTypes;
81382
+ exports.coalesceRepeatedTaps = coalesceRepeatedTaps;
81383
+ exports.elideNoOpDetours = elideNoOpDetours;
81179
81384
  exports.mobileActionsToPseudoPlaywright = mobileActionsToPseudoPlaywright;
81180
81385
  exports.evaluateMobileAssertionStrength = evaluateMobileAssertionStrength;
81181
81386
  exports.prepareMobileScenarioAppState = prepareMobileScenarioAppState;
@@ -81384,6 +81589,22 @@ var require_mobile_generator = __commonJS({
81384
81589
  MOBILE_ASSERT_TEXT_TOOL,
81385
81590
  FINISH_GENERATE_TOOL
81386
81591
  ];
81592
+ var STRUCTURAL_KEY_MAX_ELEMENTS = 16;
81593
+ function structuralScreenKey(resolved) {
81594
+ if (!resolved)
81595
+ return null;
81596
+ const nativeElements = resolved.screen.elements;
81597
+ const hybridElements = resolved.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81598
+ if (nativeElements.length + hybridElements.length < 2)
81599
+ return null;
81600
+ 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 ?? ""}`);
81601
+ 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 ?? ""}`);
81602
+ const activityOrBundle = resolved.signal.activity ?? resolved.signal.bundleId ?? "";
81603
+ const payload = `${activityOrBundle}
81604
+ ${nativeSkeleton.join("\n")}
81605
+ ${hybridSkeleton.join("\n")}`;
81606
+ return (0, crypto_1.createHash)("sha1").update(payload).digest("hex");
81607
+ }
81387
81608
  function platformForMedium(medium) {
81388
81609
  if (medium === "IOS_NATIVE")
81389
81610
  return "IOS";
@@ -81545,18 +81766,39 @@ var require_mobile_generator = __commonJS({
81545
81766
  }
81546
81767
  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
81768
  }
81548
- function metadataForElement(element, screenName, platform3) {
81769
+ function alreadyFocusedWithEqualValue(element, value) {
81770
+ return element.focused === true && typeof element.text === "string" && value.length > 0 && element.text === value;
81771
+ }
81772
+ function nativeElementsOf(resolved) {
81773
+ return resolved?.screen.elements ?? [];
81774
+ }
81775
+ function hybridElementsOf(resolved) {
81776
+ return resolved?.hybridWeb ? Array.from(resolved.hybridWeb.values()) : [];
81777
+ }
81778
+ function metadataForElement(element, screenName, platform3, elements = []) {
81779
+ const match = (0, mobile_source_parser_js_2.identityMatchInfo)(element, elements);
81549
81780
  return {
81550
81781
  screenName,
81551
81782
  targetLabel: labelForElement(element),
81552
81783
  locatorBundle: (0, mobile_source_parser_js_2.buildLocatorBundle)(element, platform3),
81553
- bounds: element.bounds
81784
+ bounds: element.bounds,
81785
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81554
81786
  };
81555
81787
  }
81556
- function metadataForHybridElement(element, screenName) {
81788
+ function hybridIdentityMatchInfo(element, elements) {
81789
+ const hybridIdentityValue = (candidate) => nonEmpty3(candidate.id) ?? (candidate.editable === true ? void 0 : nonEmpty3(candidate.text));
81790
+ const chosenValue = hybridIdentityValue(element);
81791
+ if (!chosenValue)
81792
+ return void 0;
81793
+ const sameValue = elements.filter((candidate) => hybridIdentityValue(candidate) === chosenValue);
81794
+ const matchIndex = sameValue.indexOf(element);
81795
+ return matchIndex >= 0 && sameValue.length > 1 ? { matchIndex, expectedMatchCount: sameValue.length } : void 0;
81796
+ }
81797
+ function metadataForHybridElement(element, screenName, elements = []) {
81557
81798
  const editable = isEditableHybridElement(element);
81558
81799
  const label = labelForHybridElement(element);
81559
81800
  const safeText = editable ? void 0 : nonEmpty3(element.text);
81801
+ const match = hybridIdentityMatchInfo(element, elements);
81560
81802
  return {
81561
81803
  screenName,
81562
81804
  targetLabel: label,
@@ -81565,11 +81807,16 @@ var require_mobile_generator = __commonJS({
81565
81807
  webviewContext: element.webviewContext,
81566
81808
  bounds: element.bounds,
81567
81809
  locatorBundle: {
81568
- xpath: `css=${element.css}`,
81810
+ // WebView controls resolve via `webCss` (above) in the WEBVIEW context, not
81811
+ // by xpath. `LocatorBundle.xpath` is contractually an XPath, so never store a
81812
+ // `css=` selector here — an empty xpath keeps the fallback chain honest and
81813
+ // prevents the css selector being misread as an xpath at replay.
81814
+ xpath: "",
81569
81815
  className: element.tag || "webview",
81570
81816
  ...element.id ? { accessibilityId: element.id } : {},
81571
81817
  ...safeText ? { text: safeText } : {}
81572
- }
81818
+ },
81819
+ ...match ? { matchIndex: match.matchIndex, expectedMatchCount: match.expectedMatchCount } : {}
81573
81820
  };
81574
81821
  }
81575
81822
  function isEditableHybridElement(element) {
@@ -82287,7 +82534,15 @@ ${statePacket}`, redactionCredentials) },
82287
82534
  screenName: () => lastScreenName || "UnknownScreen",
82288
82535
  recordAction: (action) => {
82289
82536
  const screenHash = getLatest()?.signal.screenHash;
82290
- const stamped = screenHash && !action.metadata.screenHash ? { ...action, metadata: { ...action.metadata, screenHash } } : action;
82537
+ const structuralKey = structuralScreenKey(getLatest());
82538
+ const stamped = screenHash && !action.metadata.screenHash || structuralKey && !action.metadata.structuralKey ? {
82539
+ ...action,
82540
+ metadata: {
82541
+ ...action.metadata,
82542
+ ...screenHash && !action.metadata.screenHash ? { screenHash } : {},
82543
+ ...structuralKey && !action.metadata.structuralKey ? { structuralKey } : {}
82544
+ }
82545
+ } : action;
82291
82546
  actions.push((0, shared_1.placeholderizeRecordedMobileAction)(finalizeAction(stamped), credentials));
82292
82547
  if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
82293
82548
  assertCount++;
@@ -82393,7 +82648,7 @@ ${statePacket}`, redactionCredentials) },
82393
82648
  const result2 = await driver.webTap(hybridTap.webviewContext, hybridTap.css);
82394
82649
  const absorbed2 = await absorbActionResult(result2, "mobile_tap(webview)", opensAuthenticationSurface(hybridTap));
82395
82650
  if (shouldRecordAction(result2, absorbed2)) {
82396
- const metadata = metadataForHybridElement(hybridTap, beforeScreenName2);
82651
+ const metadata = metadataForHybridElement(hybridTap, beforeScreenName2, hybridElementsOf(before2));
82397
82652
  const label = metadata.targetLabel ?? hybridTap.css;
82398
82653
  const tapAction = {
82399
82654
  type: "TAP",
@@ -82445,7 +82700,7 @@ ${statePacket}`, redactionCredentials) },
82445
82700
  const tapAction = {
82446
82701
  type: "TAP",
82447
82702
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82448
- metadata: metadataForElement(element, beforeScreenName, platform3),
82703
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before)),
82449
82704
  innerText: null,
82450
82705
  url: "",
82451
82706
  value: null
@@ -82485,10 +82740,11 @@ ${statePacket}`, redactionCredentials) },
82485
82740
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82486
82741
  }
82487
82742
  const beforeScreenName2 = screenName();
82743
+ const before2 = latest();
82488
82744
  const result2 = await driver.webType(hybridType.webviewContext, hybridType.css, value);
82489
82745
  const absorbed2 = await absorbActionResult(result2, "mobile_type(webview)");
82490
82746
  if (shouldRecordAction(result2, absorbed2)) {
82491
- const metadata = metadataForHybridElement(hybridType, beforeScreenName2);
82747
+ const metadata = metadataForHybridElement(hybridType, beforeScreenName2, hybridElementsOf(before2));
82492
82748
  const label = metadata.targetLabel ?? hybridType.css;
82493
82749
  recordAction({
82494
82750
  type: "TYPE",
@@ -82503,7 +82759,14 @@ ${statePacket}`, redactionCredentials) },
82503
82759
  const element = resolveRef(toolArgs.ref);
82504
82760
  if (!element)
82505
82761
  return unknownRef(toolArgs.ref);
82762
+ if (alreadyFocusedWithEqualValue(element, value)) {
82763
+ return {
82764
+ ok: false,
82765
+ 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.`
82766
+ };
82767
+ }
82506
82768
  const beforeScreenName = screenName();
82769
+ const before = latest();
82507
82770
  const result = await driver.type(value, element.bounds);
82508
82771
  const absorbed = await absorbActionResult(result, "mobile_type");
82509
82772
  if (shouldRecordAction(result, absorbed)) {
@@ -82511,7 +82774,7 @@ ${statePacket}`, redactionCredentials) },
82511
82774
  type: "TYPE",
82512
82775
  value,
82513
82776
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82514
- metadata: metadataForElement(element, beforeScreenName, platform3)
82777
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82515
82778
  });
82516
82779
  }
82517
82780
  const payload = actionPayload(result, absorbed);
@@ -82529,6 +82792,7 @@ ${statePacket}`, redactionCredentials) },
82529
82792
  return { ok: false, error: "This driver does not support non-clearing WEBVIEW keyboard submit." };
82530
82793
  }
82531
82794
  const beforeScreenName2 = screenName();
82795
+ const before2 = latest();
82532
82796
  const result2 = await driver.webSubmitKeyboard(hybridField.webviewContext, hybridField.css);
82533
82797
  const absorbed2 = await absorbActionResult(result2, "mobile_submit_keyboard(webview)");
82534
82798
  if (shouldRecordAction(result2, absorbed2)) {
@@ -82536,7 +82800,7 @@ ${statePacket}`, redactionCredentials) },
82536
82800
  type: "TYPE",
82537
82801
  value: "\n",
82538
82802
  selector: `css=${hybridField.css}`,
82539
- metadata: metadataForHybridElement(hybridField, beforeScreenName2)
82803
+ metadata: metadataForHybridElement(hybridField, beforeScreenName2, hybridElementsOf(before2))
82540
82804
  });
82541
82805
  }
82542
82806
  return actionPayload(result2, absorbed2);
@@ -82548,6 +82812,7 @@ ${statePacket}`, redactionCredentials) },
82548
82812
  return { ok: false, error: "This driver does not support non-clearing keyboard submit." };
82549
82813
  }
82550
82814
  const beforeScreenName = screenName();
82815
+ const before = latest();
82551
82816
  const result = await driver.submitKeyboard(element.bounds);
82552
82817
  const absorbed = await absorbActionResult(result, "mobile_submit_keyboard");
82553
82818
  if (shouldRecordAction(result, absorbed)) {
@@ -82555,7 +82820,7 @@ ${statePacket}`, redactionCredentials) },
82555
82820
  type: "TYPE",
82556
82821
  value: "\n",
82557
82822
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82558
- metadata: metadataForElement(element, beforeScreenName, platform3)
82823
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82559
82824
  });
82560
82825
  }
82561
82826
  return actionPayload(result, absorbed);
@@ -82580,10 +82845,11 @@ ${statePacket}`, redactionCredentials) },
82580
82845
  return { ok: false, error: "This driver does not support hybrid WEBVIEW typing." };
82581
82846
  }
82582
82847
  const beforeScreenName2 = screenName();
82848
+ const before2 = latest();
82583
82849
  const result2 = await driver.webType(hybridFill.webviewContext, hybridFill.css, secret);
82584
82850
  const absorbed2 = await absorbActionResult(result2, "mobile_fill_credential(webview)");
82585
82851
  if (shouldRecordAction(result2, absorbed2)) {
82586
- const metadata = metadataForHybridElement(hybridFill, beforeScreenName2);
82852
+ const metadata = metadataForHybridElement(hybridFill, beforeScreenName2, hybridElementsOf(before2));
82587
82853
  const label = metadata.targetLabel ?? hybridFill.css;
82588
82854
  recordAction({
82589
82855
  type: "TYPE",
@@ -82598,7 +82864,14 @@ ${statePacket}`, redactionCredentials) },
82598
82864
  const element = resolveRef(toolArgs.ref);
82599
82865
  if (!element)
82600
82866
  return unknownRef(toolArgs.ref);
82867
+ if (alreadyFocusedWithEqualValue(element, secret)) {
82868
+ return {
82869
+ ok: false,
82870
+ 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.`
82871
+ };
82872
+ }
82601
82873
  const beforeScreenName = screenName();
82874
+ const before = latest();
82602
82875
  const result = await driver.type(secret, element.bounds);
82603
82876
  const absorbed = await absorbActionResult(result, "mobile_fill_credential");
82604
82877
  if (shouldRecordAction(result, absorbed)) {
@@ -82606,7 +82879,7 @@ ${statePacket}`, redactionCredentials) },
82606
82879
  type: "TYPE",
82607
82880
  value: (0, shared_1.mobileCredentialPlaceholder)(key),
82608
82881
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82609
- metadata: metadataForElement(element, beforeScreenName, platform3)
82882
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82610
82883
  });
82611
82884
  log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
82612
82885
  }
@@ -82634,13 +82907,14 @@ ${statePacket}`, redactionCredentials) },
82634
82907
  if (!element)
82635
82908
  return unknownRef(toolArgs.ref);
82636
82909
  const beforeScreenName = screenName();
82910
+ const before = latest();
82637
82911
  const result = await driver.clear(element.bounds);
82638
82912
  const absorbed = await absorbActionResult(result, "mobile_clear");
82639
82913
  if (shouldRecordAction(result, absorbed)) {
82640
82914
  recordAction({
82641
82915
  type: "CLEAR",
82642
82916
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
82643
- metadata: metadataForElement(element, beforeScreenName, platform3)
82917
+ metadata: metadataForElement(element, beforeScreenName, platform3, nativeElementsOf(before))
82644
82918
  });
82645
82919
  }
82646
82920
  return actionPayload(result, absorbed);
@@ -82703,7 +82977,7 @@ ${statePacket}`, redactionCredentials) },
82703
82977
  case "mobile_assert_visible": {
82704
82978
  const hybrid = resolveHybridRef(toolArgs.ref);
82705
82979
  if (hybrid) {
82706
- const metadata = metadataForHybridElement(hybrid, screenName());
82980
+ const metadata = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82707
82981
  recordAction({
82708
82982
  type: "ASSERT_VISIBLE",
82709
82983
  selector: `css=${hybrid.css}`,
@@ -82743,7 +83017,7 @@ ${statePacket}`, redactionCredentials) },
82743
83017
  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
83018
  };
82745
83019
  }
82746
- const metadata2 = metadataForHybridElement(hybrid, screenName());
83020
+ const metadata2 = metadataForHybridElement(hybrid, screenName(), hybridElementsOf(latest()));
82747
83021
  const stableWebIdentity = Boolean(nonEmpty3(hybrid.id) || nonEmpty3(hybrid.name));
82748
83022
  if (stableWebIdentity) {
82749
83023
  recordAction({
@@ -82796,7 +83070,7 @@ ${statePacket}`, redactionCredentials) },
82796
83070
  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
83071
  };
82798
83072
  }
82799
- const metadata = metadataForElement(element, screenName(), platform3);
83073
+ const metadata = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82800
83074
  const expectedNeedle = expected.trim().toLowerCase();
82801
83075
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
82802
83076
  const visibleText = (element.text ?? "").trim().toLowerCase();
@@ -82842,7 +83116,7 @@ ${statePacket}`, redactionCredentials) },
82842
83116
  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
83117
  };
82844
83118
  }
82845
- const metadata2 = metadataForElement(element, screenName(), platform3);
83119
+ const metadata2 = metadataForElement(element, screenName(), platform3, nativeElementsOf(latest()));
82846
83120
  recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata: metadata2 });
82847
83121
  return { ok: true, label: metadata2.targetLabel ?? "element" };
82848
83122
  }
@@ -82892,7 +83166,7 @@ ${statePacket}`, redactionCredentials) },
82892
83166
  error: `The live element matching ${JSON.stringify(accessibilityId ?? text)} is not a durable assertion target (${weakReason}).`
82893
83167
  };
82894
83168
  }
82895
- const metadata = metadataForElement(liveElement, screenName(), platform3);
83169
+ const metadata = metadataForElement(liveElement, screenName(), platform3, live.screen.elements);
82896
83170
  recordAction({
82897
83171
  type: "ASSERT_VISIBLE",
82898
83172
  selector: (0, mobile_source_parser_js_2.preferredLocator)(liveElement),
@@ -83020,7 +83294,7 @@ ${statePacket}`, redactionCredentials) },
83020
83294
  actions.push(finalizeAction({
83021
83295
  type: "ASSERT_VISIBLE",
83022
83296
  selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83023
- metadata: metadataForElement(anchor, screen, platform3)
83297
+ metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83024
83298
  }));
83025
83299
  return true;
83026
83300
  }
@@ -83363,6 +83637,168 @@ ${statePacket}`, redactionCredentials) },
83363
83637
  }
83364
83638
  return out;
83365
83639
  }
83640
+ function isDurableMutation(action) {
83641
+ if (action.type === "TYPE" || action.type === "CLEAR")
83642
+ return true;
83643
+ if (action.type === "TAP")
83644
+ return (0, shared_1.isStateValueMobileClassName)(action.metadata.locatorBundle?.className);
83645
+ return false;
83646
+ }
83647
+ function mobileWanderTraceEnabled2() {
83648
+ const v = (process.env.VALIDATEQA_MOBILE_WANDER_TRACE || "").trim().toLowerCase();
83649
+ return v === "true" || v === "1";
83650
+ }
83651
+ function wanderTraceLabel(action) {
83652
+ const identity = action.metadata.targetLabel || action.selector || action.metadata.locatorBundle?.accessibilityId || action.metadata.locatorBundle?.resourceId || "(no identity)";
83653
+ return `${action.type} ${identity}`;
83654
+ }
83655
+ function logWanderDiff(pass, before, after, log2) {
83656
+ if (after.length >= before.length)
83657
+ return;
83658
+ log2(` ${pass}: collapsed ${before.length - after.length} step(s) (${before.length} \u2192 ${after.length}).`);
83659
+ if (!mobileWanderTraceEnabled2())
83660
+ return;
83661
+ const survivors = new Set(after);
83662
+ for (const action of before) {
83663
+ if (!survivors.has(action)) {
83664
+ log2(` [wander] ${pass}: dropped ${wanderTraceLabel(action)}`);
83665
+ }
83666
+ }
83667
+ }
83668
+ function coalesceSameFieldTypes(actions) {
83669
+ if (actions.length <= 1)
83670
+ return actions;
83671
+ const toDrop = /* @__PURE__ */ new Set();
83672
+ const lastTypeIndexBySelector = /* @__PURE__ */ new Map();
83673
+ const refocusTapIndexesBySelector = /* @__PURE__ */ new Map();
83674
+ for (let i = 0; i < actions.length; i++) {
83675
+ const current = actions[i];
83676
+ if (current.type === "ASSERT_VISIBLE" || current.type === "ASSERT_TEXT") {
83677
+ lastTypeIndexBySelector.clear();
83678
+ refocusTapIndexesBySelector.clear();
83679
+ continue;
83680
+ }
83681
+ if (current.type === "CLEAR") {
83682
+ lastTypeIndexBySelector.delete(current.selector);
83683
+ refocusTapIndexesBySelector.delete(current.selector);
83684
+ continue;
83685
+ }
83686
+ if (isFormCommitAction(current)) {
83687
+ lastTypeIndexBySelector.clear();
83688
+ refocusTapIndexesBySelector.clear();
83689
+ continue;
83690
+ }
83691
+ if (current.type === "TAP") {
83692
+ if (current.selector) {
83693
+ const list = refocusTapIndexesBySelector.get(current.selector) ?? [];
83694
+ list.push(i);
83695
+ refocusTapIndexesBySelector.set(current.selector, list);
83696
+ }
83697
+ continue;
83698
+ }
83699
+ if (current.type !== "TYPE" || !current.selector)
83700
+ continue;
83701
+ const priorIndex = lastTypeIndexBySelector.get(current.selector);
83702
+ if (priorIndex !== void 0) {
83703
+ toDrop.add(priorIndex);
83704
+ for (const tapIndex of refocusTapIndexesBySelector.get(current.selector) ?? []) {
83705
+ if (tapIndex > priorIndex && tapIndex < i)
83706
+ toDrop.add(tapIndex);
83707
+ }
83708
+ }
83709
+ lastTypeIndexBySelector.set(current.selector, i);
83710
+ refocusTapIndexesBySelector.set(current.selector, []);
83711
+ }
83712
+ if (toDrop.size === 0)
83713
+ return actions;
83714
+ return actions.filter((_, index) => !toDrop.has(index));
83715
+ }
83716
+ function coalesceRepeatedTaps(actions) {
83717
+ if (actions.length <= 1)
83718
+ return actions;
83719
+ const toDrop = /* @__PURE__ */ new Set();
83720
+ const lastTapIndexBySelector = /* @__PURE__ */ new Map();
83721
+ for (let i = 0; i < actions.length; i++) {
83722
+ const action = actions[i];
83723
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT") {
83724
+ lastTapIndexBySelector.clear();
83725
+ continue;
83726
+ }
83727
+ if (isDurableMutation(action)) {
83728
+ lastTapIndexBySelector.clear();
83729
+ continue;
83730
+ }
83731
+ if (action.type !== "TAP" || !action.selector)
83732
+ continue;
83733
+ const key = action.metadata.structuralKey;
83734
+ const priorIndex = lastTapIndexBySelector.get(action.selector);
83735
+ if (priorIndex !== void 0 && key && actions[priorIndex].metadata.structuralKey === key) {
83736
+ const priorHash = actions[priorIndex].metadata.screenHash;
83737
+ const currentHash = action.metadata.screenHash;
83738
+ const screenAdvanced = !!priorHash && !!currentHash && priorHash !== currentHash;
83739
+ if (!screenAdvanced) {
83740
+ toDrop.add(priorIndex);
83741
+ }
83742
+ }
83743
+ lastTapIndexBySelector.set(action.selector, i);
83744
+ }
83745
+ if (toDrop.size === 0)
83746
+ return actions;
83747
+ return actions.filter((_, index) => !toDrop.has(index));
83748
+ }
83749
+ var MAX_DETOUR_LENGTH = 6;
83750
+ function isDetourClosingAction(action) {
83751
+ if (action.type === "BACK" || action.type === "HOME")
83752
+ return true;
83753
+ return isNavigationChromeTap(action);
83754
+ }
83755
+ function isEvidenceAction(action) {
83756
+ return action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "WAIT_FOR_ELEMENT";
83757
+ }
83758
+ function elideNoOpDetours(actions) {
83759
+ if (actions.length < 2)
83760
+ return actions;
83761
+ const toDrop = /* @__PURE__ */ new Set();
83762
+ for (let j = 0; j < actions.length; j++) {
83763
+ if (toDrop.has(j))
83764
+ continue;
83765
+ if (!isDetourClosingAction(actions[j]))
83766
+ continue;
83767
+ const keyAfter = actions[j].metadata.structuralKey;
83768
+ if (!keyAfter)
83769
+ continue;
83770
+ const next = actions[j + 1];
83771
+ if (next && isEvidenceAction(next))
83772
+ continue;
83773
+ let bestStart = null;
83774
+ let leftScreen = false;
83775
+ const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83776
+ for (let i = j; i >= earliestStart; i--) {
83777
+ if (i < j) {
83778
+ const inside = actions[i];
83779
+ if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83780
+ break;
83781
+ }
83782
+ const insideKey = inside.metadata.structuralKey;
83783
+ if (insideKey && insideKey !== keyAfter)
83784
+ leftScreen = true;
83785
+ }
83786
+ if (i === 0)
83787
+ continue;
83788
+ const keyBefore = actions[i - 1].metadata.structuralKey;
83789
+ if (i < j && leftScreen && keyBefore && keyBefore === keyAfter) {
83790
+ bestStart = i;
83791
+ }
83792
+ }
83793
+ if (bestStart !== null) {
83794
+ for (let k = bestStart; k <= j; k++)
83795
+ toDrop.add(k);
83796
+ }
83797
+ }
83798
+ if (toDrop.size === 0)
83799
+ return actions;
83800
+ return actions.filter((_, index) => !toDrop.has(index));
83801
+ }
83366
83802
  function mobileActionsToPseudoPlaywright(actions) {
83367
83803
  const escape = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
83368
83804
  const lines = ["test('mobile', async ({ page }) => {"];
@@ -83450,6 +83886,15 @@ ${statePacket}`, redactionCredentials) },
83450
83886
  if (actions.length < beforeCycleCollapse) {
83451
83887
  log2(` collapsed ${beforeCycleCollapse - actions.length} retry-cycle step(s) out of the recorded trace (${beforeCycleCollapse} \u2192 ${actions.length}).`);
83452
83888
  }
83889
+ const beforeTypeCoalesce = actions;
83890
+ actions = coalesceSameFieldTypes(actions);
83891
+ logWanderDiff("coalesceSameFieldTypes", beforeTypeCoalesce, actions, log2);
83892
+ const beforeTapCoalesce = actions;
83893
+ actions = coalesceRepeatedTaps(actions);
83894
+ logWanderDiff("coalesceRepeatedTaps", beforeTapCoalesce, actions, log2);
83895
+ const beforeDetourElision = actions;
83896
+ actions = elideNoOpDetours(actions);
83897
+ logWanderDiff("elideNoOpDetours", beforeDetourElision, actions, log2);
83453
83898
  const beforeContainerDrop = actions.length;
83454
83899
  actions = actions.filter((a) => !isBareContainerTap(a));
83455
83900
  if (actions.length < beforeContainerDrop) {
@@ -83634,48 +84079,29 @@ ${statePacket}`, redactionCredentials) },
83634
84079
  log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
83635
84080
  return applyScenarioEntryDeepLink(driver, target, { appDataCleared: false, coldResetUnavailable: false, refuseGenerate: false, reason: "preserve_policy" }, log2);
83636
84081
  }
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);
84082
+ const appDataCleared = await driver.resetAppState?.().catch((err) => {
84083
+ log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
84084
+ return false;
84085
+ }) ?? false;
84086
+ if (appDataCleared) {
84087
+ return applyScenarioEntryDeepLink(driver, target, { appDataCleared: true, coldResetUnavailable: false, refuseGenerate: false }, log2);
83662
84088
  }
83663
84089
  await driver.relaunch();
83664
84090
  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.");
84091
+ 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
84092
  return {
83667
84093
  appDataCleared: false,
83668
84094
  coldResetUnavailable: true,
83669
84095
  refuseGenerate: true,
83670
- reason: "ios_clear_app_data_unavailable"
84096
+ reason: platform3 === "IOS" ? "ios_clear_app_data_unavailable" : "clear_app_data_unavailable"
83671
84097
  };
83672
84098
  }
83673
- log2(" iOS has no clearApp \u2014 scenario records after relaunch (warm-state risk for auth flows).");
84099
+ 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
84100
  return applyScenarioEntryDeepLink(driver, target, {
83675
84101
  appDataCleared: false,
83676
84102
  coldResetUnavailable: true,
83677
84103
  refuseGenerate: false,
83678
- reason: "ios_no_clear_app"
84104
+ reason: platform3 === "IOS" ? "ios_no_clear_app" : platform3 === "ANDROID" ? "android_reset_unavailable" : "reset_unavailable"
83679
84105
  }, log2);
83680
84106
  }
83681
84107
  async function runMobileGeneratePhase(args) {
@@ -84278,6 +84704,8 @@ var require_mobile_heal_loop = __commonJS({
84278
84704
  "use strict";
84279
84705
  Object.defineProperty(exports, "__esModule", { value: true });
84280
84706
  exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = void 0;
84707
+ exports.timed = timed;
84708
+ exports.timedSync = timedSync;
84281
84709
  exports.executeMobileScriptDrivenHeal = executeMobileScriptDrivenHeal;
84282
84710
  exports.executeMobileTestWithHealing = executeMobileTestWithHealing2;
84283
84711
  exports.executeMobileLiveHeal = executeMobileLiveHeal2;
@@ -84487,6 +84915,32 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84487
84915
  function detectInvalidMobileAuthSetup(steps, error2) {
84488
84916
  return detectInvalidExternalAuthSetup(steps, error2) ?? detectInvalidAuthValidationSetup(steps, error2);
84489
84917
  }
84918
+ function formatHealTimingLine(category, label, ms, failed, errMessage) {
84919
+ const base2 = `\u{1FA7A}\u23F1 [${category}] ${label} \u2014 ${ms}ms`;
84920
+ return failed ? `${base2} (FAILED: ${errMessage ?? "unknown error"})` : base2;
84921
+ }
84922
+ async function timed(category, label, onProgress, fn) {
84923
+ const start = Date.now();
84924
+ try {
84925
+ const result = await fn();
84926
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84927
+ return result;
84928
+ } catch (err) {
84929
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84930
+ throw err;
84931
+ }
84932
+ }
84933
+ function timedSync(category, label, onProgress, fn) {
84934
+ const start = Date.now();
84935
+ try {
84936
+ const result = fn();
84937
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, false));
84938
+ return result;
84939
+ } catch (err) {
84940
+ onProgress?.(formatHealTimingLine(category, label, Date.now() - start, true, err instanceof Error ? err.message : String(err)));
84941
+ throw err;
84942
+ }
84943
+ }
84490
84944
  var IDENTITY_GATE_ERROR_RE = /No identity locator \(accessibility id \/ resource id \/ text\) available for this step/;
84491
84945
  function isIdentityEqualityXpath(value) {
84492
84946
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
@@ -84704,21 +85158,23 @@ For a fix, return the COMPLETE steps array (not a diff).`;
84704
85158
  return JSON.stringify(base2?.target) !== JSON.stringify(healed.target);
84705
85159
  }
84706
85160
  function findMissingChangedLocators(baseSteps, healedSteps, screen) {
84707
- const missing = [];
85161
+ const results = [];
84708
85162
  healedSteps.forEach((healed, index) => {
84709
85163
  if (!stepTargetChanged(baseSteps[index], healed))
84710
85164
  return;
84711
85165
  const candidate = stepToLocatorCandidate(healed);
84712
85166
  if (!candidate)
84713
85167
  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
- });
85168
+ const matchCount = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length;
85169
+ const label = healed.target?.primary ?? JSON.stringify(candidate);
85170
+ const expressions = stepIdentityExpressions(healed);
85171
+ if (matchCount === 0) {
85172
+ results.push({ label, expressions, kind: "missing" });
85173
+ } else if (matchCount > 1) {
85174
+ results.push({ label, expressions, kind: "ambiguous", matchCount });
84719
85175
  }
84720
85176
  });
84721
- return missing;
85177
+ return results;
84722
85178
  }
84723
85179
  function changedStepExpressions(baseSteps, healedSteps) {
84724
85180
  const out = [];
@@ -84947,10 +85403,10 @@ ${args.directive}`);
84947
85403
  let content;
84948
85404
  let finishReason;
84949
85405
  try {
84950
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
85406
+ 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
85407
  arm();
84952
- return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
84953
- }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
85408
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85409
+ }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true }));
84954
85410
  content = response?.choices?.[0]?.message?.content ?? "";
84955
85411
  finishReason = response?.choices?.[0]?.finish_reason ?? void 0;
84956
85412
  } catch {
@@ -84993,14 +85449,18 @@ ${args.directive}`);
84993
85449
  continue;
84994
85450
  }
84995
85451
  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);
85452
+ const failingSourceSnapshot = activeFailingSource;
85453
+ const missing = timedSync("device-op", `phase1-attempt-${attempt}-static-verify`, options.onProgress, () => {
85454
+ const screen = (0, mobile_source_parser_js_1.parseMobileSource)(failingSourceSnapshot, platform3);
85455
+ return findMissingChangedLocators(baseSteps, healedSteps, screen);
85456
+ });
84998
85457
  if (missing.length > 0) {
84999
85458
  for (const entry of missing)
85000
85459
  pushUnique(triedLocators, entry.expressions);
85001
85460
  markRecoverableTurn();
85002
85461
  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.`;
85462
+ const first = missing[0];
85463
+ 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
85464
  continue;
85005
85465
  }
85006
85466
  }
@@ -85014,9 +85474,10 @@ ${args.directive}`);
85014
85474
  pendingDirective = "The proposed fix could not be device-verified here. If it is not clearly correct, revise it.";
85015
85475
  continue;
85016
85476
  }
85477
+ const runNativeTest = options.runNativeTest;
85017
85478
  let verify;
85018
85479
  try {
85019
- verify = await options.runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
85480
+ verify = await timed("reexec", `phase1-attempt-${attempt}-device-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables }));
85020
85481
  } catch {
85021
85482
  return {
85022
85483
  passed: false,
@@ -85123,6 +85584,9 @@ ${args.directive}`);
85123
85584
  }
85124
85585
  }
85125
85586
  async function executeMobileTestWithHealing2(steps, target, ctx, options) {
85587
+ return timed("total", "heal-total", options.onProgress, () => executeMobileTestWithHealingImpl(steps, target, ctx, options));
85588
+ }
85589
+ async function executeMobileTestWithHealingImpl(steps, target, ctx, options) {
85126
85590
  const invalidAuthSetup = detectInvalidMobileAuthSetup(steps, ctx.error);
85127
85591
  if (invalidAuthSetup) {
85128
85592
  return { passed: false, verdict: "test_invalid", healReason: invalidAuthSetup };
@@ -85249,9 +85713,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85249
85713
  const candidate = partsToCandidate(parts);
85250
85714
  if (!hasIdentitySignal(candidate))
85251
85715
  return;
85252
- const first = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen)[0];
85253
- if (first)
85254
- await driver.tap(first.bounds);
85716
+ const matches = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen);
85717
+ const target = matches.find((el) => el.bounds.width > 0 && el.bounds.height > 0) ?? matches[0];
85718
+ if (target)
85719
+ await driver.tap(target.bounds);
85255
85720
  }
85256
85721
  function buildLiveUserContent(args) {
85257
85722
  const parts = [];
@@ -85316,7 +85781,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85316
85781
  options.onProgress?.(`Phase 2 pass ${pass}/${maxPasses} \u2014 observing the live screen...`);
85317
85782
  let snap;
85318
85783
  try {
85319
- snap = await liveDriver.snapshot();
85784
+ snap = await timed("device-op", `phase2-pass-${pass}-snapshot`, options.onProgress, () => liveDriver.snapshot());
85320
85785
  } catch {
85321
85786
  return {
85322
85787
  passed: false,
@@ -85342,10 +85807,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85342
85807
  ];
85343
85808
  let content;
85344
85809
  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)(() => {
85810
+ 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
85811
  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 }));
85812
+ return aiClient.chat.completions.create({ model, messages, max_tokens: 16e3, temperature: 0.1, ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(model) }, { signal });
85813
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true })));
85349
85814
  content = response?.choices?.[0]?.message?.content ?? "";
85350
85815
  } catch {
85351
85816
  continue;
@@ -85377,7 +85842,7 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
85377
85842
  const healedSteps = parsed.steps;
85378
85843
  let verify;
85379
85844
  try {
85380
- verify = await runNativeTest({ steps: healedSteps, target, testVariables });
85845
+ verify = await timed("reexec", `phase2-pass-${pass}-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables }));
85381
85846
  } catch {
85382
85847
  return {
85383
85848
  passed: false,
@@ -89231,7 +89696,7 @@ var require_kill_tree = __commonJS({
89231
89696
  var node_child_process_1 = __require("child_process");
89232
89697
  var node_util_1 = __require("util");
89233
89698
  var node_fs_1 = __require("fs");
89234
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89699
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
89235
89700
  var PS_TIMEOUT_MS = 2e3;
89236
89701
  var PS_BUFFER = 1024 * 1024;
89237
89702
  async function gracefulCloseWithTreeKill(options) {
@@ -89285,7 +89750,7 @@ var require_kill_tree = __commonJS({
89285
89750
  timer.unref?.();
89286
89751
  let stdout;
89287
89752
  try {
89288
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89753
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89289
89754
  maxBuffer: PS_BUFFER,
89290
89755
  signal: controller.signal
89291
89756
  });
@@ -89323,7 +89788,7 @@ var require_kill_tree = __commonJS({
89323
89788
  timer.unref?.();
89324
89789
  let stdout;
89325
89790
  try {
89326
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid=,command="], {
89791
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
89327
89792
  maxBuffer: PS_BUFFER,
89328
89793
  signal: controller.signal
89329
89794
  });
@@ -89400,7 +89865,7 @@ var require_kill_tree = __commonJS({
89400
89865
  timer.unref?.();
89401
89866
  let stdout;
89402
89867
  try {
89403
- const result = await execFileAsync3("ps", ["-A", "-o", "pid=,ppid="], {
89868
+ const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid="], {
89404
89869
  maxBuffer: PS_BUFFER,
89405
89870
  signal: controller.signal
89406
89871
  });
@@ -99279,8 +99744,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99279
99744
  onRetry: (attempt, reason, delayMs) => logs2.push(` \u26A0 scenario-gen AI call retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
99280
99745
  });
99281
99746
  } catch (aiErr) {
99282
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99283
- logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg2}`);
99747
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
99748
+ logs2.push(` AI call failed at iteration ${iteration} (after retries): ${errMsg3}`);
99284
99749
  throw (0, ai_retry_js_1.markAIProviderError)(aiErr);
99285
99750
  }
99286
99751
  const iterDuration = Date.now() - iterStart;
@@ -99323,8 +99788,8 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
99323
99788
  try {
99324
99789
  toolArgs = JSON.parse(toolCall.function.arguments);
99325
99790
  } catch (parseErr) {
99326
- const errMsg2 = `Invalid tool arguments: ${parseErr.message}`;
99327
- toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg2 });
99791
+ const errMsg3 = `Invalid tool arguments: ${parseErr.message}`;
99792
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: errMsg3 });
99328
99793
  continue;
99329
99794
  }
99330
99795
  if (toolName === "finish_generate") {
@@ -100582,7 +101047,7 @@ var require_util4 = __commonJS({
100582
101047
  }
100583
101048
  path = url.path;
100584
101049
  }
100585
- var isAbsolute = exports.isAbsolute(path);
101050
+ var isAbsolute2 = exports.isAbsolute(path);
100586
101051
  var parts = path.split(/\/+/);
100587
101052
  for (var part, up = 0, i = parts.length - 1; i >= 0; i--) {
100588
101053
  part = parts[i];
@@ -100602,7 +101067,7 @@ var require_util4 = __commonJS({
100602
101067
  }
100603
101068
  path = parts.join("/");
100604
101069
  if (path === "") {
100605
- path = isAbsolute ? "/" : ".";
101070
+ path = isAbsolute2 ? "/" : ".";
100606
101071
  }
100607
101072
  if (url) {
100608
101073
  url.path = path;
@@ -100611,7 +101076,7 @@ var require_util4 = __commonJS({
100611
101076
  return path;
100612
101077
  }
100613
101078
  exports.normalize = normalize;
100614
- function join7(aRoot, aPath) {
101079
+ function join8(aRoot, aPath) {
100615
101080
  if (aRoot === "") {
100616
101081
  aRoot = ".";
100617
101082
  }
@@ -100643,7 +101108,7 @@ var require_util4 = __commonJS({
100643
101108
  }
100644
101109
  return joined;
100645
101110
  }
100646
- exports.join = join7;
101111
+ exports.join = join8;
100647
101112
  exports.isAbsolute = function(aPath) {
100648
101113
  return aPath.charAt(0) === "/" || urlRegexp.test(aPath);
100649
101114
  };
@@ -100816,7 +101281,7 @@ var require_util4 = __commonJS({
100816
101281
  parsed.path = parsed.path.substring(0, index + 1);
100817
101282
  }
100818
101283
  }
100819
- sourceURL = join7(urlGenerate(parsed), sourceURL);
101284
+ sourceURL = join8(urlGenerate(parsed), sourceURL);
100820
101285
  }
100821
101286
  return normalize(sourceURL);
100822
101287
  }
@@ -314529,8 +314994,8 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314529
314994
  ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel)
314530
314995
  })), { label: `script-heal-${attempt}` });
314531
314996
  } catch (aiErr) {
314532
- const errMsg2 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314533
- logs2.push(`\u26A0 AI call failed: ${errMsg2}`);
314997
+ const errMsg3 = aiErr instanceof Error ? aiErr.message : String(aiErr);
314998
+ logs2.push(`\u26A0 AI call failed: ${errMsg3}`);
314534
314999
  attemptHistory.push({
314535
315000
  phase: "script",
314536
315001
  attempt,
@@ -314538,13 +315003,13 @@ Try a different fix approach. This is NOT your final script-driven attempt yet,
314538
315003
  passed: false,
314539
315004
  changedCode: false,
314540
315005
  summary: "AI call failed before returning a heal result",
314541
- errorSummary: truncateForPrompt(errMsg2),
315006
+ errorSummary: truncateForPrompt(errMsg3),
314542
315007
  failedStep: summarizeFailedStep(currentStepResults)
314543
315008
  });
314544
315009
  if (feedbackEnabled) {
314545
- lastTransportFailure = truncateForPrompt(errMsg2, 200) ?? "the AI request failed";
315010
+ lastTransportFailure = truncateForPrompt(errMsg3, 200) ?? "the AI request failed";
314546
315011
  } else {
314547
- currentError = `AI call failed on attempt ${attempt}: ${errMsg2}`;
315012
+ currentError = `AI call failed on attempt ${attempt}: ${errMsg3}`;
314548
315013
  }
314549
315014
  markRecoverableTurn();
314550
315015
  continue;
@@ -314828,8 +315293,8 @@ ${currentError.slice(0, 2e3)}`
314828
315293
  // item 5b
314829
315294
  });
314830
315295
  } catch (runErr) {
314831
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
314832
- logs2.push(`\u{1F4A5} Native execution threw: ${errMsg2}`);
315296
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
315297
+ logs2.push(`\u{1F4A5} Native execution threw: ${errMsg3}`);
314833
315298
  attemptHistory.push({
314834
315299
  phase: "script",
314835
315300
  attempt,
@@ -314837,9 +315302,9 @@ ${currentError.slice(0, 2e3)}`
314837
315302
  passed: false,
314838
315303
  changedCode: true,
314839
315304
  summary: truncateForPrompt(changeDesc) ?? "Applied code fix, but native execution threw before verification completed",
314840
- errorSummary: truncateForPrompt(errMsg2)
315305
+ errorSummary: truncateForPrompt(errMsg3)
314841
315306
  });
314842
- currentError = `Native execution threw: ${errMsg2}`;
315307
+ currentError = `Native execution threw: ${errMsg3}`;
314843
315308
  currentStepResults = [];
314844
315309
  continue;
314845
315310
  }
@@ -315595,9 +316060,9 @@ Last error (after heal attempts): ${lastErrorSnippet}` : `Heal aborted: same err
315595
316060
  previousErrorSignature = errorSig;
315596
316061
  }
315597
316062
  } catch (runErr) {
315598
- const errMsg2 = runErr instanceof Error ? runErr.message : String(runErr);
315599
- logs2.push(`\u{1F4A5} Native run threw: ${errMsg2}`);
315600
- currentError = errMsg2;
316063
+ const errMsg3 = runErr instanceof Error ? runErr.message : String(runErr);
316064
+ logs2.push(`\u{1F4A5} Native run threw: ${errMsg3}`);
316065
+ currentError = errMsg3;
315601
316066
  currentStepResults = [];
315602
316067
  }
315603
316068
  } else {
@@ -316159,8 +316624,8 @@ When you call finish_fix, preserve locators only when they are stable structural
316159
316624
  }
316160
316625
  }
316161
316626
  } catch (toolErr) {
316162
- const errMsg2 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316163
- let errorContent = `Error: ${errMsg2}`;
316627
+ const errMsg3 = toolErr instanceof Error ? toolErr.message : String(toolErr);
316628
+ let errorContent = `Error: ${errMsg3}`;
316164
316629
  try {
316165
316630
  const consoleResult = await mcpClient.callTool({ name: "browser_console_messages", arguments: {} });
316166
316631
  const consoleText = Array.isArray(consoleResult.content) ? consoleResult.content.map((c) => c.type === "text" ? c.text ?? "" : "").join("\n") : String(consoleResult.content ?? "");
@@ -316181,8 +316646,8 @@ ${errorContent}`;
316181
316646
  }
316182
316647
  }
316183
316648
  } catch (mcpErr) {
316184
- const errMsg2 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316185
- logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg2}`);
316649
+ const errMsg3 = mcpErr instanceof Error ? mcpErr.message : String(mcpErr);
316650
+ logs2.push(`\u{1F4A5} MCP exploration failed: ${errMsg3}`);
316186
316651
  }
316187
316652
  if (verdict === "app_bug") {
316188
316653
  phase2AttemptHistory.push({
@@ -316776,9 +317241,9 @@ ${testBlocks}
316776
317241
  }
316777
317242
  emit(` \u2713 ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
316778
317243
  } catch (err) {
316779
- const errMsg2 = err instanceof Error ? err.message : String(err);
316780
- toolResultContent = `ERROR: ${errMsg2}`;
316781
- emit(` \u2717 ${errMsg2}`);
317244
+ const errMsg3 = err instanceof Error ? err.message : String(err);
317245
+ toolResultContent = `ERROR: ${errMsg3}`;
317246
+ emit(` \u2717 ${errMsg3}`);
316782
317247
  }
316783
317248
  toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: toolResultContent });
316784
317249
  }
@@ -323062,8 +323527,8 @@ var require_capture_page_normalizer = __commonJS({
323062
323527
  const snapItems = Array.isArray(snapResult.content) ? snapResult.content : [];
323063
323528
  snapshotText = extractInlineSnapshotYaml(snapItems.find((c) => c.type === "text")?.text || null);
323064
323529
  } catch (err) {
323065
- const errMsg2 = err instanceof Error ? err.message : String(err);
323066
- const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg2);
323530
+ const errMsg3 = err instanceof Error ? err.message : String(err);
323531
+ const isTimeout = /timeout|timed out|TimeoutError/i.test(errMsg3);
323067
323532
  return {
323068
323533
  snapshotText: null,
323069
323534
  meta: {
@@ -327139,9 +327604,9 @@ ${currentSummary}`;
327139
327604
  });
327140
327605
  }
327141
327606
  } 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}` });
327607
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327608
+ logs2.push(` Error persisting page: ${errMsg3}`);
327609
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: `ERROR persisting page: ${errMsg3}` });
327145
327610
  }
327146
327611
  continue;
327147
327612
  }
@@ -327343,12 +327808,12 @@ ${currentSummary}`;
327343
327808
  })
327344
327809
  });
327345
327810
  } catch (err) {
327346
- const errMsg2 = err instanceof Error ? err.message : String(err);
327347
- logs2.push(` Error persisting page: ${errMsg2}`);
327811
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327812
+ logs2.push(` Error persisting page: ${errMsg3}`);
327348
327813
  toolResults.push({
327349
327814
  role: "tool",
327350
327815
  tool_call_id: toolCall.id,
327351
- content: `ERROR persisting page: ${errMsg2}`
327816
+ content: `ERROR persisting page: ${errMsg3}`
327352
327817
  });
327353
327818
  }
327354
327819
  continue;
@@ -327383,12 +327848,12 @@ ${currentSummary}`;
327383
327848
  content: JSON.stringify({ acknowledged: true })
327384
327849
  });
327385
327850
  } catch (err) {
327386
- const errMsg2 = err instanceof Error ? err.message : String(err);
327387
- logs2.push(` Error persisting transition: ${errMsg2}`);
327851
+ const errMsg3 = err instanceof Error ? err.message : String(err);
327852
+ logs2.push(` Error persisting transition: ${errMsg3}`);
327388
327853
  toolResults.push({
327389
327854
  role: "tool",
327390
327855
  tool_call_id: toolCall.id,
327391
- content: `ERROR persisting transition: ${errMsg2}`
327856
+ content: `ERROR persisting transition: ${errMsg3}`
327392
327857
  });
327393
327858
  }
327394
327859
  continue;
@@ -328084,15 +328549,15 @@ Exploration complete: ${summary}`);
328084
328549
  toolResultContent = (0, snapshot_context_compactor_js_1.formatMCPToolResult)(toolResultContent, MAX_TOOL_RESULT_CHARS);
328085
328550
  logs2.push(` OK: ${toolResultContent.slice(0, 200)}${toolResultContent.length > 200 ? "..." : ""}`);
328086
328551
  } catch (err) {
328087
- const errMsg2 = err instanceof Error ? err.message : String(err);
328088
- toolResultContent = `ERROR: ${errMsg2}`;
328089
- logs2.push(` Error: ${errMsg2}`);
328552
+ const errMsg3 = err instanceof Error ? err.message : String(err);
328553
+ toolResultContent = `ERROR: ${errMsg3}`;
328554
+ logs2.push(` Error: ${errMsg3}`);
328090
328555
  if (useV2) {
328091
328556
  (0, exploration_state_js_1.reduceEvent)(state2, {
328092
328557
  type: "ACTION_FAILED",
328093
328558
  route: state2.currentRoute,
328094
328559
  action: `${toolName}(${JSON.stringify(toolArgs).slice(0, 100)})`,
328095
- error: errMsg2
328560
+ error: errMsg3
328096
328561
  });
328097
328562
  }
328098
328563
  }
@@ -330941,10 +331406,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
330941
331406
  onLog: options.onLog,
330942
331407
  onProvenCommands: recordProvenCommands
330943
331408
  });
330944
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
331409
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
330945
331410
  const fixPrompt = [
330946
331411
  "Your previous attempt did not produce a valid result file.",
330947
- `Error: ${errMsg2}`,
331412
+ `Error: ${errMsg3}`,
330948
331413
  `Re-read BRIEF.md and write a valid SurveyorOutput JSON to ${resultFilePath}.`,
330949
331414
  "Single JSON object. No prose. Then exit."
330950
331415
  ].join("\n");
@@ -333085,7 +333550,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333085
333550
  log2(`First-pass discovery-result invalid or missing: ${firstError.message}`);
333086
333551
  }
333087
333552
  if (!parsedResult) {
333088
- const errMsg2 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333553
+ const errMsg3 = firstError?.message ?? (grokError instanceof Error ? grokError.message : "unknown error");
333089
333554
  if (planDebounceTimerRef.current) {
333090
333555
  clearTimeout(planDebounceTimerRef.current);
333091
333556
  planDebounceTimerRef.current = void 0;
@@ -333110,7 +333575,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
333110
333575
  ""
333111
333576
  ].join("\n");
333112
333577
  const fixPrompt = [
333113
- `Your previous attempt did not produce a valid result file. Error: ${errMsg2}`,
333578
+ `Your previous attempt did not produce a valid result file. Error: ${errMsg3}`,
333114
333579
  "Re-read BRIEF.md.",
333115
333580
  phaseContext,
333116
333581
  `Continue from where you left off \u2014 already verified tests are in ${testsDirPath}/`,
@@ -334739,10 +335204,10 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
334739
335204
  onLog: parserLog,
334740
335205
  onProvenCommands: recordProvenCommands
334741
335206
  });
334742
- const errMsg2 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
335207
+ const errMsg3 = firstError?.message ?? (claudeError instanceof Error ? claudeError.message : "unknown error");
334743
335208
  const fixPrompt = [
334744
335209
  "Your previous attempt did not leave a valid result file.",
334745
- `Error: ${errMsg2}`,
335210
+ `Error: ${errMsg3}`,
334746
335211
  `Writing a valid SurveyorOutput JSON object to ${resultFilePath} is your ONLY priority now.`,
334747
335212
  "Procedure: navigate to at most 6-8 of the main pages, then IMMEDIATELY use",
334748
335213
  `the Write tool to save the SurveyorOutput JSON to ${resultFilePath}. Do this`,
@@ -338623,8 +339088,8 @@ var require_resource_governor = __commonJS({
338623
339088
  function isDiscoveryLike(mode) {
338624
339089
  return mode === "discovery";
338625
339090
  }
338626
- function evaluateRunAdmission2(options, request) {
338627
- const snapshot = getResourceSnapshot2();
339091
+ function evaluateRunAdmission2(options, request, snapshotOverride) {
339092
+ const snapshot = snapshotOverride ?? getResourceSnapshot2();
338628
339093
  const candidateWeight = modeWeight(request.mode, options.weights);
338629
339094
  const currentUnits = activeUnits(request.activeByType, options.weights);
338630
339095
  const memory = pressureFactor(snapshot.memoryPct, options.memorySoftPct, options.memoryHardPct);
@@ -341122,8 +341587,8 @@ var require_adm_zip = __commonJS({
341122
341587
  return null;
341123
341588
  }
341124
341589
  function fixPath(zipPath) {
341125
- const { join: join7, normalize, sep } = pth.posix;
341126
- return join7(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341590
+ const { join: join8, normalize, sep } = pth.posix;
341591
+ return join8(pth.isAbsolute(zipPath) ? "/" : ".", normalize(sep + zipPath.split("\\").join(sep) + sep));
341127
341592
  }
341128
341593
  function filenameFilter(filterfn) {
341129
341594
  if (filterfn instanceof RegExp) {
@@ -343958,11 +344423,11 @@ var require_winchars = __commonJS({
343958
344423
  var require_strip_absolute_path = __commonJS({
343959
344424
  "../../node_modules/tar/lib/strip-absolute-path.js"(exports, module) {
343960
344425
  "use strict";
343961
- var { isAbsolute, parse } = __require("path").win32;
344426
+ var { isAbsolute: isAbsolute2, parse } = __require("path").win32;
343962
344427
  module.exports = (path) => {
343963
344428
  let r = "";
343964
344429
  let parsed = parse(path);
343965
- while (isAbsolute(path) || parsed.root) {
344430
+ while (isAbsolute2(path) || parsed.root) {
343966
344431
  const root = path.charAt(0) === "/" && path.slice(0, 4) !== "//?/" ? "/" : parsed.root;
343967
344432
  path = path.slice(root.length);
343968
344433
  r += root;
@@ -346474,7 +346939,7 @@ var require_update = __commonJS({
346474
346939
  var require_opts_arg = __commonJS({
346475
346940
  "../../node_modules/tar/node_modules/mkdirp/lib/opts-arg.js"(exports, module) {
346476
346941
  "use strict";
346477
- var { promisify: promisify3 } = __require("util");
346942
+ var { promisify: promisify4 } = __require("util");
346478
346943
  var fs = __require("fs");
346479
346944
  var optsArg = (opts) => {
346480
346945
  if (!opts)
@@ -346488,9 +346953,9 @@ var require_opts_arg = __commonJS({
346488
346953
  else
346489
346954
  throw new TypeError("invalid options argument");
346490
346955
  opts.mkdir = opts.mkdir || opts.fs.mkdir || fs.mkdir;
346491
- opts.mkdirAsync = promisify3(opts.mkdir);
346956
+ opts.mkdirAsync = promisify4(opts.mkdir);
346492
346957
  opts.stat = opts.stat || opts.fs.stat || fs.stat;
346493
- opts.statAsync = promisify3(opts.stat);
346958
+ opts.statAsync = promisify4(opts.stat);
346494
346959
  opts.statSync = opts.statSync || opts.fs.statSync || fs.statSync;
346495
346960
  opts.mkdirSync = opts.mkdirSync || opts.fs.mkdirSync || fs.mkdirSync;
346496
346961
  return opts;
@@ -346743,10 +347208,10 @@ var require_chownr = __commonJS({
346743
347208
  }
346744
347209
  } : (path2, uid, gid) => lchownSync(path2, uid, gid);
346745
347210
  var nodeVersion = process.version;
346746
- var readdir2 = (path2, options, cb) => fs.readdir(path2, options, cb);
347211
+ var readdir3 = (path2, options, cb) => fs.readdir(path2, options, cb);
346747
347212
  var readdirSync = (path2, options) => fs.readdirSync(path2, options);
346748
347213
  if (/^v4\./.test(nodeVersion))
346749
- readdir2 = (path2, options, cb) => fs.readdir(path2, cb);
347214
+ readdir3 = (path2, options, cb) => fs.readdir(path2, cb);
346750
347215
  var chown = (cpath, uid, gid, cb) => {
346751
347216
  fs[LCHOWN](cpath, uid, gid, handleEISDIR(cpath, uid, gid, (er) => {
346752
347217
  cb(er && er.code !== "ENOENT" ? er : null);
@@ -346773,7 +347238,7 @@ var require_chownr = __commonJS({
346773
347238
  }
346774
347239
  };
346775
347240
  var chownr = (p, uid, gid, cb) => {
346776
- readdir2(p, { withFileTypes: true }, (er, children) => {
347241
+ readdir3(p, { withFileTypes: true }, (er, children) => {
346777
347242
  if (er) {
346778
347243
  if (er.code === "ENOENT")
346779
347244
  return cb();
@@ -347047,7 +347512,7 @@ var require_path_reservations = __commonJS({
347047
347512
  var assert = __require("assert");
347048
347513
  var normalize = require_normalize_unicode();
347049
347514
  var stripSlashes = require_strip_trailing_slashes();
347050
- var { join: join7 } = __require("path");
347515
+ var { join: join8 } = __require("path");
347051
347516
  var platform3 = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform;
347052
347517
  var isWindows = platform3 === "win32";
347053
347518
  module.exports = () => {
@@ -347056,7 +347521,7 @@ var require_path_reservations = __commonJS({
347056
347521
  const getDirs = (path) => {
347057
347522
  const dirs = path.split("/").slice(0, -1).reduce((set, path2) => {
347058
347523
  if (set.length) {
347059
- path2 = join7(set[set.length - 1], path2);
347524
+ path2 = join8(set[set.length - 1], path2);
347060
347525
  }
347061
347526
  set.push(path2 || "/");
347062
347527
  return set;
@@ -347124,7 +347589,7 @@ var require_path_reservations = __commonJS({
347124
347589
  };
347125
347590
  const reserve = (paths, fn) => {
347126
347591
  paths = isWindows ? ["win32 parallelization disabled"] : paths.map((p) => {
347127
- return stripSlashes(join7(normalize(p))).toLowerCase();
347592
+ return stripSlashes(join8(normalize(p))).toLowerCase();
347128
347593
  });
347129
347594
  const dirs = new Set(
347130
347595
  paths.map((path) => getDirs(path)).reduce((a, b) => a.concat(b))
@@ -347179,7 +347644,7 @@ var require_unpack = __commonJS({
347179
347644
  var fs = __require("fs");
347180
347645
  var fsm = require_fs_minipass();
347181
347646
  var path = __require("path");
347182
- var mkdir2 = require_mkdir();
347647
+ var mkdir3 = require_mkdir();
347183
347648
  var wc = require_winchars();
347184
347649
  var pathReservations = require_path_reservations();
347185
347650
  var stripAbsolutePath = require_strip_absolute_path();
@@ -347430,7 +347895,7 @@ var require_unpack = __commonJS({
347430
347895
  }
347431
347896
  }
347432
347897
  [MKDIR](dir, mode, cb) {
347433
- mkdir2(normPath(dir), {
347898
+ mkdir3(normPath(dir), {
347434
347899
  uid: this.uid,
347435
347900
  gid: this.gid,
347436
347901
  processUid: this.processUid,
@@ -347833,7 +348298,7 @@ var require_unpack = __commonJS({
347833
348298
  }
347834
348299
  [MKDIR](dir, mode) {
347835
348300
  try {
347836
- return mkdir2.sync(normPath(dir), {
348301
+ return mkdir3.sync(normPath(dir), {
347837
348302
  uid: this.uid,
347838
348303
  gid: this.gid,
347839
348304
  processUid: this.processUid,
@@ -351339,7 +351804,7 @@ var require_process_inventory = __commonJS({
351339
351804
  var node_child_process_1 = __require("child_process");
351340
351805
  var node_os_1 = __importDefault(__require("os"));
351341
351806
  var node_util_1 = __require("util");
351342
- var execFileAsync3 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351807
+ var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
351343
351808
  var DEFAULT_LIMIT = 60;
351344
351809
  var DEFAULT_TIMEOUT_MS2 = 800;
351345
351810
  var MAX_COMMAND_LENGTH = 320;
@@ -351401,7 +351866,7 @@ var require_process_inventory = __commonJS({
351401
351866
  const timer = setTimeout(() => controller.abort(), timeoutMs);
351402
351867
  timer.unref?.();
351403
351868
  try {
351404
- const { stdout } = await execFileAsync3(command, args, {
351869
+ const { stdout } = await execFileAsync4(command, args, {
351405
351870
  maxBuffer: 1024 * 1024,
351406
351871
  signal: controller.signal
351407
351872
  });
@@ -352547,7 +353012,7 @@ var require_package4 = __commonJS({
352547
353012
  "package.json"(exports, module) {
352548
353013
  module.exports = {
352549
353014
  name: "@validate.qa/runner",
352550
- version: "1.0.70",
353015
+ version: "1.0.72",
352551
353016
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
352552
353017
  bin: {
352553
353018
  "validate-runner": "dist/cli.js"
@@ -352556,7 +353021,7 @@ var require_package4 = __commonJS({
352556
353021
  build: "tsup",
352557
353022
  dev: "tsx src/cli.ts",
352558
353023
  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",
353024
+ "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
353025
  prepublishOnly: "npm run build"
352561
353026
  },
352562
353027
  files: [
@@ -352634,7 +353099,7 @@ function getConfigDir() {
352634
353099
  }
352635
353100
 
352636
353101
  // src/runner.ts
352637
- import { join as join6 } from "path";
353102
+ import { join as join7 } from "path";
352638
353103
  import { homedir as homedir3 } from "os";
352639
353104
  import { createHash } from "crypto";
352640
353105
 
@@ -353229,6 +353694,69 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
353229
353694
  };
353230
353695
  }
353231
353696
 
353697
+ // src/services/mobile/ios-simulator-reset.ts
353698
+ import { execFile } from "child_process";
353699
+ import { promisify } from "util";
353700
+ import { mkdir, readdir, rm } from "fs/promises";
353701
+ import { join as join3, isAbsolute } from "path";
353702
+ var execFileAsync = promisify(execFile);
353703
+ var SIMCTL_TIMEOUT_MS = 15e3;
353704
+ 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;
353705
+ function isIosSimulatorUdid(udid) {
353706
+ return typeof udid === "string" && SIMULATOR_UDID_RE.test(udid.trim());
353707
+ }
353708
+ function errMsg(err) {
353709
+ return err instanceof Error ? err.message : String(err);
353710
+ }
353711
+ async function simctl(args) {
353712
+ const { stdout } = await execFileAsync("xcrun", ["simctl", ...args], {
353713
+ timeout: SIMCTL_TIMEOUT_MS,
353714
+ encoding: "utf-8"
353715
+ });
353716
+ return stdout.trim();
353717
+ }
353718
+ async function clearIosSimulatorAppData(udid, bundleId, env = process.env) {
353719
+ if (!isIosSimulatorUdid(udid)) {
353720
+ return { ok: false, detail: `'${udid}' is not a simulator UDID \u2014 physical iOS devices cannot be cleared` };
353721
+ }
353722
+ let container;
353723
+ try {
353724
+ container = await simctl(["get_app_container", udid, bundleId, "data"]);
353725
+ } catch (err) {
353726
+ return { ok: false, detail: `simctl get_app_container failed (app not installed?): ${errMsg(err)}` };
353727
+ }
353728
+ if (!isAbsolute(container) || !container.includes("/Containers/Data/Application/")) {
353729
+ return { ok: false, detail: `unexpected app container path: ${container}` };
353730
+ }
353731
+ try {
353732
+ await simctl(["spawn", udid, "defaults", "delete", bundleId]);
353733
+ } catch {
353734
+ }
353735
+ try {
353736
+ const entries = await readdir(container);
353737
+ for (const entry of entries) {
353738
+ await rm(join3(container, entry), { recursive: true, force: true });
353739
+ }
353740
+ for (const dir of ["Documents", "Library/Caches", "Library/Preferences", "tmp"]) {
353741
+ await mkdir(join3(container, dir), { recursive: true });
353742
+ }
353743
+ } catch (err) {
353744
+ return { ok: false, detail: `app container wipe failed: ${errMsg(err)}` };
353745
+ }
353746
+ const keychainPolicy = (env.VALIDATEQA_MOBILE_IOS_KEYCHAIN_RESET ?? "").trim().toLowerCase();
353747
+ if (keychainPolicy !== "never") {
353748
+ try {
353749
+ await simctl(["keychain", udid, "reset"]);
353750
+ } catch (err) {
353751
+ return {
353752
+ ok: false,
353753
+ 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.`
353754
+ };
353755
+ }
353756
+ }
353757
+ return { ok: true };
353758
+ }
353759
+
353232
353760
  // src/services/mobile/appium-actions.ts
353233
353761
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
353234
353762
  var APPIUM_WEBVIEW_CONTEXT_TIMEOUT_MS = 12e4;
@@ -353411,9 +353939,12 @@ async function tapAt(sessionPath, bounds) {
353411
353939
  }
353412
353940
  }
353413
353941
  function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
353414
- if (platform3 !== "ANDROID") return false;
353415
353942
  const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
353416
353943
  if (policy === "never") return false;
353944
+ if (platform3 === "IOS") {
353945
+ return isIosSimulatorUdid(deviceId);
353946
+ }
353947
+ if (platform3 !== "ANDROID") return false;
353417
353948
  if (policy === "always") return true;
353418
353949
  return !!deviceId && deviceId.startsWith("emulator-");
353419
353950
  }
@@ -354292,12 +354823,51 @@ async function pressHome(sessionPath, platform3) {
354292
354823
  });
354293
354824
  }
354294
354825
 
354826
+ // src/services/device-quarantine.ts
354827
+ var QUARANTINE_MAX_MS = (() => {
354828
+ const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
354829
+ return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
354830
+ })();
354831
+ var quarantinedAt = /* @__PURE__ */ new Map();
354832
+ var DeviceQuarantinedError = class extends Error {
354833
+ constructor(deviceId) {
354834
+ super(
354835
+ `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.`
354836
+ );
354837
+ this.name = "DeviceQuarantinedError";
354838
+ }
354839
+ };
354840
+ function quarantineDevice(deviceId, now = Date.now()) {
354841
+ quarantinedAt.set(deviceId, now);
354842
+ }
354843
+ function unquarantineDevice(deviceId) {
354844
+ quarantinedAt.delete(deviceId);
354845
+ }
354846
+ function isDeviceQuarantined(deviceId, now = Date.now()) {
354847
+ if (typeof deviceId !== "string") return false;
354848
+ const since = quarantinedAt.get(deviceId);
354849
+ if (since === void 0) return false;
354850
+ if (now - since >= QUARANTINE_MAX_MS) {
354851
+ quarantinedAt.delete(deviceId);
354852
+ console.warn(
354853
+ `[device-quarantine] Quarantine on ${deviceId} exceeded its ${Math.round(QUARANTINE_MAX_MS / 6e4)}min ceiling without the original run settling \u2014 lifting it so the device is usable again. If this recurs, the zombie run is wedged in non-device work (check the runner log).`
354854
+ );
354855
+ return false;
354856
+ }
354857
+ return true;
354858
+ }
354859
+ function assertDeviceNotQuarantined(deviceId, now = Date.now()) {
354860
+ if (isDeviceQuarantined(deviceId, now)) {
354861
+ throw new DeviceQuarantinedError(deviceId);
354862
+ }
354863
+ }
354864
+
354295
354865
  // src/services/appium-executor.ts
354296
354866
  var import_runner_core8 = __toESM(require_dist2());
354297
- import { execFile as execFile2 } from "child_process";
354298
- import { promisify as promisify2 } from "util";
354867
+ import { execFile as execFile3 } from "child_process";
354868
+ import { promisify as promisify3 } from "util";
354299
354869
  import { tmpdir as tmpdir3 } from "os";
354300
- import { join as join4 } from "path";
354870
+ import { join as join5 } from "path";
354301
354871
 
354302
354872
  // src/services/device-discovery.ts
354303
354873
  import { execFileSync as execFileSync2 } from "child_process";
@@ -354604,9 +355174,6 @@ function countReadyDevicesByKind(platform3, snapshot = getCachedDeviceSnapshot()
354604
355174
  function selectFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(), requirements = {}) {
354605
355175
  const ready = snapshot.filter((d) => d.platform === platform3 && d.isAvailable && d.state === "BOOTED");
354606
355176
  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
355177
  let candidates = requirements.resetPolicy === "CLEAR_APP_DATA" ? ready.filter((d) => !d.isPhysical) : [...ready];
354611
355178
  const appId = requirements.appId?.trim();
354612
355179
  if (appId) {
@@ -354655,23 +355222,23 @@ function pickFreeDevice(platform3, leased, snapshot = getCachedDeviceSnapshot(),
354655
355222
 
354656
355223
  // src/services/mobile/mobile-network-capture.ts
354657
355224
  var import_runner_core7 = __toESM(require_dist2());
354658
- import { execFile, execFileSync as execFileSync3 } from "child_process";
355225
+ import { execFile as execFile2, execFileSync as execFileSync3 } from "child_process";
354659
355226
  import { randomUUID } from "crypto";
354660
- import { mkdtemp, rm, writeFile, readFile, readdir, mkdir, unlink } from "fs/promises";
355227
+ import { mkdtemp, rm as rm2, writeFile, readFile, readdir as readdir2, mkdir as mkdir2, unlink } from "fs/promises";
354661
355228
  import { tmpdir as tmpdir2, networkInterfaces } from "os";
354662
- import { join as join3 } from "path";
354663
- import { promisify } from "util";
355229
+ import { join as join4 } from "path";
355230
+ import { promisify as promisify2 } from "util";
354664
355231
  import {
354665
355232
  getLocal,
354666
355233
  generateCACertificate
354667
355234
  } from "mockttp";
354668
- var execFileAsync = promisify(execFile);
355235
+ var execFileAsync2 = promisify2(execFile2);
354669
355236
  var MAX_CALLS = 1e3;
354670
355237
  var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
354671
355238
  var DEVICE_CMD_TIMEOUT_MS = 15e3;
354672
355239
  var CA_KEY_BITS = 2048;
354673
355240
  var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
354674
- var PENDING_CLEANUP_DIR = join3(tmpdir2(), "validateqa-mobile-capture-pending");
355241
+ var PENDING_CLEANUP_DIR = join4(tmpdir2(), "validateqa-mobile-capture-pending");
354675
355242
  var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
354676
355243
  var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
354677
355244
  function envFlag(name) {
@@ -354853,7 +355420,7 @@ var MitmProxy = class {
354853
355420
  try {
354854
355421
  await server3.stop();
354855
355422
  } catch (err) {
354856
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
355423
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg2(err)}`);
354857
355424
  }
354858
355425
  this.onLog?.(
354859
355426
  `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
@@ -354878,7 +355445,7 @@ var MitmProxy = class {
354878
355445
  isHttps
354879
355446
  });
354880
355447
  } catch (err) {
354881
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
355448
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg2(err)}`);
354882
355449
  }
354883
355450
  }
354884
355451
  // ── response → finalize the matched request ───────────────────────────────
@@ -354921,7 +355488,7 @@ var MitmProxy = class {
354921
355488
  intercepted: true
354922
355489
  });
354923
355490
  } catch (err) {
354924
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
355491
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg2(err)}`);
354925
355492
  }
354926
355493
  }
354927
355494
  // ── tls-client-error → record WHY interception failed ─────────────────────
@@ -354998,17 +355565,17 @@ async function generateCaCert(dir, onLog) {
354998
355565
  // rely on stop() to remove the cert from the device promptly after the run
354999
355566
  // rather than on a short expiry.
355000
355567
  });
355001
- const caCertPath = join3(dir, "validateqa-mobile-ca.pem");
355568
+ const caCertPath = join4(dir, "validateqa-mobile-ca.pem");
355002
355569
  await writeFile(caCertPath, cert, "utf-8");
355003
355570
  return { caCertPath, caKeyPem: key, caCertPem: cert };
355004
355571
  } catch (err) {
355005
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
355572
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg2(err)}`);
355006
355573
  return null;
355007
355574
  }
355008
355575
  }
355009
355576
  async function androidReadProxy(deviceId) {
355010
355577
  try {
355011
- const { stdout } = await execFileAsync(
355578
+ const { stdout } = await execFileAsync2(
355012
355579
  "adb",
355013
355580
  ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
355014
355581
  { timeout: DEVICE_CMD_TIMEOUT_MS }
@@ -355021,33 +355588,33 @@ async function androidReadProxy(deviceId) {
355021
355588
  }
355022
355589
  async function androidSetProxy(deviceId, hostPort, onLog) {
355023
355590
  try {
355024
- await execFileAsync(
355591
+ await execFileAsync2(
355025
355592
  "adb",
355026
355593
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
355027
355594
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355028
355595
  );
355029
355596
  return true;
355030
355597
  } catch (err) {
355031
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
355598
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg2(err)}`);
355032
355599
  return false;
355033
355600
  }
355034
355601
  }
355035
355602
  async function androidClearProxy(deviceId, originalProxy, onLog) {
355036
355603
  const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
355037
355604
  try {
355038
- await execFileAsync(
355605
+ await execFileAsync2(
355039
355606
  "adb",
355040
355607
  ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
355041
355608
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355042
355609
  );
355043
355610
  } catch (err) {
355044
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
355611
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg2(err)}`);
355045
355612
  }
355046
355613
  }
355047
355614
  async function androidInstallCa(deviceId, caCertPath) {
355048
355615
  const devicePath = "/sdcard/validateqa-mobile-ca.pem";
355049
355616
  try {
355050
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355617
+ await execFileAsync2("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
355051
355618
  timeout: DEVICE_CMD_TIMEOUT_MS
355052
355619
  });
355053
355620
  return {
@@ -355055,23 +355622,23 @@ async function androidInstallCa(deviceId, caCertPath) {
355055
355622
  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
355623
  };
355057
355624
  } catch (err) {
355058
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
355625
+ return { installed: false, note: `CA push failed: ${errMsg2(err)}` };
355059
355626
  }
355060
355627
  }
355061
355628
  async function androidRemoveCa(deviceId, onLog) {
355062
355629
  try {
355063
- await execFileAsync(
355630
+ await execFileAsync2(
355064
355631
  "adb",
355065
355632
  ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
355066
355633
  { timeout: DEVICE_CMD_TIMEOUT_MS }
355067
355634
  );
355068
355635
  } catch (err) {
355069
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
355636
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg2(err)}`);
355070
355637
  }
355071
355638
  }
355072
355639
  async function iosSimTrustCa(deviceId, caCertPath) {
355073
355640
  try {
355074
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355641
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
355075
355642
  timeout: DEVICE_CMD_TIMEOUT_MS
355076
355643
  });
355077
355644
  return {
@@ -355079,18 +355646,18 @@ async function iosSimTrustCa(deviceId, caCertPath) {
355079
355646
  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
355647
  };
355081
355648
  } catch (err) {
355082
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
355649
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg2(err)}` };
355083
355650
  }
355084
355651
  }
355085
355652
  async function iosSimRemoveCa(deviceId, onLog) {
355086
355653
  if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
355087
355654
  try {
355088
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355655
+ await execFileAsync2("xcrun", ["simctl", "keychain", deviceId, "reset"], {
355089
355656
  timeout: DEVICE_CMD_TIMEOUT_MS
355090
355657
  });
355091
355658
  onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
355092
355659
  } catch (err) {
355093
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
355660
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg2(err)}`);
355094
355661
  }
355095
355662
  return;
355096
355663
  }
@@ -355098,7 +355665,7 @@ async function iosSimRemoveCa(deviceId, onLog) {
355098
355665
  `[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
355666
  );
355100
355667
  }
355101
- function errMsg(err) {
355668
+ function errMsg2(err) {
355102
355669
  return err instanceof Error ? err.message : String(err);
355103
355670
  }
355104
355671
  function isProcessAlive(pid) {
@@ -355189,7 +355756,7 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355189
355756
  let service;
355190
355757
  let prior;
355191
355758
  try {
355192
- const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
355759
+ const { stdout: order } = await execFileAsync2("networksetup", ["-listnetworkserviceorder"], {
355193
355760
  timeout: DEVICE_CMD_TIMEOUT_MS
355194
355761
  });
355195
355762
  const svc = parsePrimaryNetworkService(order);
@@ -355198,26 +355765,26 @@ async function iosSimConfigureHostProxy(host, port, onLog) {
355198
355765
  }
355199
355766
  service = svc;
355200
355767
  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 })
355768
+ execFileAsync2("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
355769
+ execFileAsync2("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
355203
355770
  ]);
355204
355771
  prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
355205
355772
  for (const args of buildHostProxySetCommands(service, host, port)) {
355206
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355773
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355207
355774
  }
355208
355775
  onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
355209
355776
  return { configured: true, service, prior };
355210
355777
  } catch (err) {
355211
355778
  if (service && prior) {
355212
- onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
355779
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg2(err)}`);
355213
355780
  for (const args of buildHostProxyRestoreCommands(service, prior)) {
355214
355781
  try {
355215
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355782
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355216
355783
  } catch {
355217
355784
  }
355218
355785
  }
355219
355786
  }
355220
- return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
355787
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg2(err)}` };
355221
355788
  }
355222
355789
  }
355223
355790
  async function iosSimRestoreHostProxy(service, prior, onLog) {
@@ -355227,9 +355794,9 @@ async function iosSimRestoreHostProxy(service, prior, onLog) {
355227
355794
  };
355228
355795
  for (const args of buildHostProxyRestoreCommands(service, restore)) {
355229
355796
  try {
355230
- await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355797
+ await execFileAsync2("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
355231
355798
  } catch (err) {
355232
- onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
355799
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg2(err)}`);
355233
355800
  }
355234
355801
  }
355235
355802
  onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
@@ -355237,11 +355804,11 @@ async function iosSimRestoreHostProxy(service, prior, onLog) {
355237
355804
  var activeCleanups = /* @__PURE__ */ new Map();
355238
355805
  var exitHandlersInstalled = false;
355239
355806
  function markerPath(sessionKey) {
355240
- return join3(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
355807
+ return join4(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
355241
355808
  }
355242
355809
  async function writePendingCleanup(sessionKey, marker) {
355243
355810
  try {
355244
- await mkdir(PENDING_CLEANUP_DIR, { recursive: true });
355811
+ await mkdir2(PENDING_CLEANUP_DIR, { recursive: true });
355245
355812
  await writeFile(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
355246
355813
  } catch {
355247
355814
  }
@@ -355281,7 +355848,7 @@ function restoreDeviceSync(marker, onLog) {
355281
355848
  async function reconcilePendingCleanups(onLog) {
355282
355849
  let files;
355283
355850
  try {
355284
- files = await readdir(PENDING_CLEANUP_DIR);
355851
+ files = await readdir2(PENDING_CLEANUP_DIR);
355285
355852
  } catch {
355286
355853
  return;
355287
355854
  }
@@ -355289,7 +355856,7 @@ async function reconcilePendingCleanups(onLog) {
355289
355856
  if (!file.endsWith(".json")) continue;
355290
355857
  const sessionKey = file.slice(0, -".json".length);
355291
355858
  if (activeCleanups.has(sessionKey)) continue;
355292
- const full = join3(PENDING_CLEANUP_DIR, file);
355859
+ const full = join4(PENDING_CLEANUP_DIR, file);
355293
355860
  try {
355294
355861
  const raw = await readFile(full, "utf-8");
355295
355862
  const marker = JSON.parse(raw);
@@ -355342,7 +355909,7 @@ async function startMobileNetworkCapture(options) {
355342
355909
  let caKeyPem = null;
355343
355910
  let caCertPem = null;
355344
355911
  try {
355345
- tmpDir = await mkdtemp(join3(tmpdir2(), "validateqa-mobile-ca-"));
355912
+ tmpDir = await mkdtemp(join4(tmpdir2(), "validateqa-mobile-ca-"));
355346
355913
  const ca = await generateCaCert(tmpDir, onLog);
355347
355914
  if (ca) {
355348
355915
  caCertPath = ca.caCertPath;
@@ -355350,7 +355917,7 @@ async function startMobileNetworkCapture(options) {
355350
355917
  caCertPem = ca.caCertPem;
355351
355918
  }
355352
355919
  } catch (err) {
355353
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
355920
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg2(err)}`);
355354
355921
  }
355355
355922
  if (!caKeyPem || !caCertPem) {
355356
355923
  try {
@@ -355358,9 +355925,9 @@ async function startMobileNetworkCapture(options) {
355358
355925
  caKeyPem = fallback.key;
355359
355926
  caCertPem = fallback.cert;
355360
355927
  } catch (err) {
355361
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
355928
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg2(err)}`);
355362
355929
  if (tmpDir) {
355363
- await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355930
+ await rm2(tmpDir, { recursive: true, force: true }).catch(() => void 0);
355364
355931
  }
355365
355932
  throw err instanceof Error ? err : new Error(String(err));
355366
355933
  }
@@ -355415,7 +355982,7 @@ async function startMobileNetworkCapture(options) {
355415
355982
  }
355416
355983
  }
355417
355984
  } catch (err) {
355418
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
355985
+ wiringReason = `device wiring degraded: ${errMsg2(err)}`;
355419
355986
  onLog?.(`[mobile-net] ${wiringReason}`);
355420
355987
  }
355421
355988
  if (proxySet || caInstalledOnDevice || hostProxySet) {
@@ -355504,9 +356071,9 @@ async function startMobileNetworkCapture(options) {
355504
356071
  await proxy.close();
355505
356072
  if (capturedTmpDir) {
355506
356073
  try {
355507
- await rm(capturedTmpDir, { recursive: true, force: true });
356074
+ await rm2(capturedTmpDir, { recursive: true, force: true });
355508
356075
  } catch (err) {
355509
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
356076
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg2(err)}`);
355510
356077
  }
355511
356078
  }
355512
356079
  const apiCount = finalCalls.filter((c) => c.isApi).length;
@@ -355539,11 +356106,11 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
355539
356106
  })();
355540
356107
  var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
355541
356108
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
355542
- var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
356109
+ var WD_SESSION_CREATE_TIMEOUT_MS = Number(process.env.VALIDATEQA_WD_SESSION_CREATE_TIMEOUT_MS) || 3e5;
355543
356110
  var WD_ACTION_TIMEOUT_MS = 3e4;
355544
356111
  var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
355545
356112
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
355546
- var execFileAsync2 = promisify2(execFile2);
356113
+ var execFileAsync3 = promisify3(execFile3);
355547
356114
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
355548
356115
  var WebDriverTransportError = class extends Error {
355549
356116
  isTransport = true;
@@ -355555,6 +356122,13 @@ var WebDriverTransportError = class extends Error {
355555
356122
  function isTransportError(err) {
355556
356123
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
355557
356124
  }
356125
+ var MobileAmbiguousMatchError = class extends Error {
356126
+ isAmbiguousMatch = true;
356127
+ constructor(message) {
356128
+ super(message);
356129
+ this.name = "MobileAmbiguousMatchError";
356130
+ }
356131
+ };
355558
356132
  function crossPlatformRunReadinessError(steps, authoringPlatform, runPlatform) {
355559
356133
  if (!authoringPlatform || authoringPlatform === runPlatform) return null;
355560
356134
  const missing = stepsMissingPlatformLocators(steps, runPlatform);
@@ -355672,11 +356246,11 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
355672
356246
  return { "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200) };
355673
356247
  }
355674
356248
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
355675
- const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join4(tmpdir3(), "validateqa-wda");
356249
+ const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join5(tmpdir3(), "validateqa-wda");
355676
356250
  return {
355677
356251
  "appium:wdaLocalPort": readPort("APPIUM_WDA_LOCAL_PORT", 8100),
355678
356252
  "appium:mjpegServerPort": readPort("APPIUM_MJPEG_SERVER_PORT", 9100),
355679
- "appium:derivedDataPath": join4(derivedDataRoot, safeDevice)
356253
+ "appium:derivedDataPath": join5(derivedDataRoot, safeDevice)
355680
356254
  };
355681
356255
  }
355682
356256
  function isAndroidSystemPortBusyError(error2) {
@@ -355700,6 +356274,7 @@ function androidSystemPortForwardRemovalArgs(target, error2, env = process.env)
355700
356274
  return ["-s", deviceId, "forward", "--remove", `tcp:${systemPort}`];
355701
356275
  }
355702
356276
  async function createSession(target) {
356277
+ assertDeviceNotQuarantined(target.deviceId);
355703
356278
  const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
355704
356279
  const parallelCaps = mobileParallelSessionCapabilities(
355705
356280
  target.platform,
@@ -355721,6 +356296,13 @@ async function createSession(target) {
355721
356296
  // whole survey. The runner owns session teardown explicitly, so a long
355722
356297
  // idle window is safe. Env-overridable.
355723
356298
  "appium:newCommandTimeout": MOBILE_NEW_COMMAND_TIMEOUT_SECONDS,
356299
+ // Xcode 27+ relocates Simulator.app out of <DEVELOPER_DIR>/Applications, so
356300
+ // XCUITest's default "open Simulator UI" step fails ("Simulator.app does not
356301
+ // exist") on this toolchain. The simulator is already booted and driven
356302
+ // headlessly via CoreSimulator, so tell XCUITest to skip opening the GUI.
356303
+ // Scoped to simulator UDIDs (a physical iOS device has no such step);
356304
+ // override with APPIUM_IOS_HEADLESS=false to force a visible Simulator run.
356305
+ ...isIosSimulatorUdid(target.deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
355724
356306
  ...parallelCaps,
355725
356307
  ...realCaps
355726
356308
  } : {
@@ -355760,7 +356342,7 @@ async function removeStaleAndroidSystemPortForward(target, error2) {
355760
356342
  const args = androidSystemPortForwardRemovalArgs(target, error2);
355761
356343
  if (!args) return false;
355762
356344
  try {
355763
- await execFileAsync2("adb", args, { timeout: 1e4, windowsHide: true });
356345
+ await execFileAsync3("adb", args, { timeout: 1e4, windowsHide: true });
355764
356346
  return true;
355765
356347
  } catch {
355766
356348
  return false;
@@ -355927,6 +356509,9 @@ async function deleteSession(sessionId) {
355927
356509
  } catch {
355928
356510
  }
355929
356511
  }
356512
+ async function deleteAppiumSession(sessionId) {
356513
+ await deleteSession(sessionId);
356514
+ }
355930
356515
  async function takeScreenshot(sessionId) {
355931
356516
  try {
355932
356517
  const result = await wdRequest(`/session/${sessionId}/screenshot`);
@@ -355998,10 +356583,9 @@ async function stopScreenRecording(sessionId, log2) {
355998
356583
  return void 0;
355999
356584
  }
356000
356585
  }
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;
356586
+ function parseMobileBoundsLike(value) {
356587
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.x === "number" && typeof value.y === "number" && typeof value.width === "number" && typeof value.height === "number") {
356588
+ const parsed = value;
356005
356589
  if ([parsed.x, parsed.y, parsed.width, parsed.height].every(Number.isFinite)) {
356006
356590
  return {
356007
356591
  x: parsed.x,
@@ -356013,6 +356597,9 @@ function boundsFromStep(step) {
356013
356597
  }
356014
356598
  return null;
356015
356599
  }
356600
+ function boundsFromStep(step) {
356601
+ return parseMobileBoundsLike(step.metadata?.bounds);
356602
+ }
356016
356603
  function boundsForRun(step, platform3, isCrossPlatform) {
356017
356604
  if (isCrossPlatform) return null;
356018
356605
  if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
@@ -356164,6 +356751,131 @@ async function revealLikelyOffscreenElement(sessionId) {
356164
356751
  });
356165
356752
  await new Promise((resolve2) => setTimeout(resolve2, 250));
356166
356753
  }
356754
+ function strictMatchMode() {
356755
+ const raw = (process.env.VALIDATEQA_MOBILE_STRICT_MATCH ?? "").trim().toLowerCase();
356756
+ if (raw === "strict") return "strict";
356757
+ if (raw === "off") return "off";
356758
+ return "lenient";
356759
+ }
356760
+ var MOBILE_MAX_DISAMBIG_CANDIDATES = 20;
356761
+ var COINCIDENT_CENTER_EPSILON_PX = 2;
356762
+ function isRealElementRect(rect) {
356763
+ return rect.width > 1 && rect.height > 1;
356764
+ }
356765
+ function rectContainsPoint(rect, x, y) {
356766
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
356767
+ }
356768
+ function rectCenter(rect) {
356769
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
356770
+ }
356771
+ function centerDistance(rect, point) {
356772
+ const center = rectCenter(rect);
356773
+ return Math.hypot(center.x - point.x, center.y - point.y);
356774
+ }
356775
+ function chooseRecordedMatch(candidates, opts) {
356776
+ if (candidates.length === 0) return { kind: "ambiguous", count: 0 };
356777
+ if (candidates.length === 1) {
356778
+ return { kind: "matched", elementId: candidates[0].elementId, reason: "single" };
356779
+ }
356780
+ if (typeof opts.matchIndex === "number" && Number.isInteger(opts.matchIndex) && opts.matchIndex >= 0 && opts.matchIndex < candidates.length && typeof opts.expectedMatchCount === "number" && opts.expectedMatchCount === candidates.length) {
356781
+ return { kind: "matched", elementId: candidates[opts.matchIndex].elementId, reason: "matchIndex" };
356782
+ }
356783
+ {
356784
+ const withRect = candidates.filter(
356785
+ (candidate) => candidate.rect != null
356786
+ );
356787
+ if (withRect.length === candidates.length) {
356788
+ const real = withRect.filter((candidate) => isRealElementRect(candidate.rect));
356789
+ if (real.length === 1) {
356790
+ return { kind: "matched", elementId: real[0].elementId, reason: "visible" };
356791
+ }
356792
+ const centers = withRect.map((candidate) => rectCenter(candidate.rect));
356793
+ 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);
356794
+ if (coincident) {
356795
+ return { kind: "matched", elementId: withRect[0].elementId, reason: "coincident" };
356796
+ }
356797
+ }
356798
+ }
356799
+ if (opts.recordedBounds) {
356800
+ const bounds = opts.recordedBounds;
356801
+ const center = rectCenter(bounds);
356802
+ const withRect = candidates.filter(
356803
+ (candidate) => candidate.rect != null
356804
+ );
356805
+ const containing = withRect.filter((candidate) => rectContainsPoint(candidate.rect, center.x, center.y));
356806
+ if (containing.length > 0) {
356807
+ const best = [...containing].sort(
356808
+ (a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height || centerDistance(a.rect, center) - centerDistance(b.rect, center)
356809
+ )[0];
356810
+ return { kind: "matched", elementId: best.elementId, reason: "bounds" };
356811
+ }
356812
+ if (withRect.length > 0) {
356813
+ const tolerance = Math.max(bounds.height, 1);
356814
+ const nearest = [...withRect].sort(
356815
+ (a, b) => centerDistance(a.rect, center) - centerDistance(b.rect, center)
356816
+ )[0];
356817
+ if (centerDistance(nearest.rect, center) <= tolerance) {
356818
+ return { kind: "matched", elementId: nearest.elementId, reason: "bounds" };
356819
+ }
356820
+ }
356821
+ }
356822
+ return opts.strict === "strict" ? { kind: "ambiguous", count: candidates.length } : { kind: "fallback", elementId: candidates[0].elementId, count: candidates.length };
356823
+ }
356824
+ function extractElementIdsFromPluralResponse(result) {
356825
+ const rawList = result?.value;
356826
+ if (!Array.isArray(rawList)) return [];
356827
+ const ids = [];
356828
+ for (const entry of rawList) {
356829
+ const record = entry;
356830
+ const id = validateElementId(record?.ELEMENT ?? record?.["element-6066-11e4-a52e-4f735466cecf"]);
356831
+ if (id) ids.push(id);
356832
+ }
356833
+ return ids;
356834
+ }
356835
+ async function findElements(sessionId, strategy) {
356836
+ const result = await wdRequest(`/session/${sessionId}/elements`, {
356837
+ method: "POST",
356838
+ body: JSON.stringify({ using: strategy.using, value: strategy.value })
356839
+ });
356840
+ return extractElementIdsFromPluralResponse(result);
356841
+ }
356842
+ async function findWebViewElements(sessionPath, css) {
356843
+ const result = await wdRequest(`${sessionPath}/elements`, {
356844
+ method: "POST",
356845
+ body: JSON.stringify({ using: "css selector", value: css })
356846
+ });
356847
+ return extractElementIdsFromPluralResponse(result);
356848
+ }
356849
+ async function readElementRect(sessionId, elementId) {
356850
+ try {
356851
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/rect`);
356852
+ return parseMobileBoundsLike(result?.value);
356853
+ } catch (err) {
356854
+ if (isTransportError(err)) throw err;
356855
+ return null;
356856
+ }
356857
+ }
356858
+ async function disambiguateElementIds(sessionId, candidateIds, opts) {
356859
+ const indexOnly = candidateIds.map((elementId) => ({ elementId }));
356860
+ const indexResult = chooseRecordedMatch(indexOnly, {
356861
+ recordedBounds: null,
356862
+ matchIndex: opts.matchIndex,
356863
+ expectedMatchCount: opts.expectedMatchCount,
356864
+ strict: opts.strict
356865
+ });
356866
+ if (indexResult.kind === "matched" || candidateIds.length > MOBILE_MAX_DISAMBIG_CANDIDATES) {
356867
+ return indexResult;
356868
+ }
356869
+ const withRects = await Promise.all(
356870
+ candidateIds.map(async (elementId) => ({ elementId, rect: await readElementRect(sessionId, elementId) }))
356871
+ );
356872
+ return chooseRecordedMatch(withRects, {
356873
+ recordedBounds: opts.recordedBounds ?? null,
356874
+ matchIndex: opts.matchIndex,
356875
+ expectedMatchCount: opts.expectedMatchCount,
356876
+ strict: opts.strict
356877
+ });
356878
+ }
356167
356879
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
356168
356880
  const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
356169
356881
  if (strategies.length === 0) {
@@ -356171,22 +356883,45 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356171
356883
  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
356884
  );
356173
356885
  }
356886
+ const mode = strictMatchMode();
356887
+ const matchIndex = typeof target?.matchIndex === "number" ? target.matchIndex : void 0;
356888
+ const expectedMatchCount = typeof target?.expectedMatchCount === "number" ? target.expectedMatchCount : void 0;
356174
356889
  const startTime = Date.now();
356175
356890
  let lastError = "";
356891
+ let lastAmbiguity = null;
356176
356892
  let revealAttempted = false;
356177
356893
  while (Date.now() - startTime < timeoutMs) {
356178
356894
  for (const strategy of strategies) {
356179
356895
  try {
356180
- const result = await wdRequest(`/session/${sessionId}/element`, {
356181
- method: "POST",
356182
- body: JSON.stringify({ using: strategy.using, value: strategy.value })
356896
+ const ids = await findElements(sessionId, strategy);
356897
+ if (ids.length === 0) continue;
356898
+ if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
356899
+ return ids[0];
356900
+ }
356901
+ const result = await disambiguateElementIds(sessionId, ids, {
356902
+ recordedBounds: opts?.recordedBounds,
356903
+ matchIndex,
356904
+ expectedMatchCount,
356905
+ strict: mode
356183
356906
  });
356184
- const elementId = validateElementId(
356185
- result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
356907
+ if (result.kind === "matched") return result.elementId;
356908
+ const shouldFailClosed = opts?.failClosedOnAmbiguity || result.kind === "ambiguous" && !opts?.tolerateAmbiguity;
356909
+ if (shouldFailClosed) {
356910
+ throw new MobileAmbiguousMatchError(
356911
+ `ambiguous locator: ${result.count} matches, cannot identify the recorded instance (strategy=${strategy.using} value=${strategy.value})`
356912
+ );
356913
+ }
356914
+ opts?.log?.(
356915
+ `\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
356916
  );
356187
- if (elementId) return elementId;
356917
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
356188
356918
  } catch (err) {
356189
356919
  if (isTransportError(err)) throw err;
356920
+ if (err instanceof MobileAmbiguousMatchError) {
356921
+ lastAmbiguity = err;
356922
+ lastError = err.message;
356923
+ continue;
356924
+ }
356190
356925
  lastError = err instanceof Error ? err.message : String(err);
356191
356926
  }
356192
356927
  }
@@ -356201,6 +356936,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
356201
356936
  }
356202
356937
  await new Promise((resolve2) => setTimeout(resolve2, 500));
356203
356938
  }
356939
+ if (lastAmbiguity) throw lastAmbiguity;
356204
356940
  throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
356205
356941
  }
356206
356942
  async function tapCoordinates(sessionId, bounds) {
@@ -356234,7 +356970,7 @@ async function prepareAndroidInputEnvironment(deviceId) {
356234
356970
  ];
356235
356971
  for (const [namespace, key, value] of settingsToDisable) {
356236
356972
  try {
356237
- await execFileAsync2("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356973
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
356238
356974
  timeout: 8e3,
356239
356975
  windowsHide: true
356240
356976
  });
@@ -356246,14 +356982,14 @@ async function sendAndroidTextToFocusedInput(deviceId, value) {
356246
356982
  if (!deviceId) {
356247
356983
  throw new Error("Android keyboard fallback requires a device id");
356248
356984
  }
356249
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356985
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "text", encodeAndroidInputText(value)], {
356250
356986
  timeout: 1e4,
356251
356987
  windowsHide: true
356252
356988
  });
356253
356989
  }
356254
356990
  async function pressAndroidKeyboardEnter(deviceId) {
356255
356991
  if (!deviceId) throw new Error("Android keyboard Enter fallback requires a device id");
356256
- await execFileAsync2("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356992
+ await execFileAsync3("adb", ["-s", deviceId, "shell", "input", "keyevent", "66"], {
356257
356993
  timeout: 1e4,
356258
356994
  windowsHide: true
356259
356995
  });
@@ -356341,21 +357077,40 @@ async function executeInWebViewContext(sessionId, step, css, body) {
356341
357077
  const cleanCss = css.startsWith("css=") ? css.slice(4) : css;
356342
357078
  await withWebViewContext(sessionPath, webview, () => body(sessionPath, cleanCss));
356343
357079
  }
356344
- async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
357080
+ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
357081
+ const mode = strictMatchMode();
356345
357082
  const startedAt = Date.now();
356346
357083
  let lastError = "";
356347
357084
  while (Date.now() - startedAt < timeoutMs) {
356348
357085
  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;
357086
+ const ids = await findWebViewElements(sessionPath, css);
357087
+ if (ids.length === 0) {
357088
+ lastError = `no match for css=${css}`;
357089
+ } else if (ids.length === 1 || opts?.disambiguate === false || mode === "off") {
357090
+ return ids[0];
357091
+ } else {
357092
+ const candidates = ids.map((elementId) => ({ elementId }));
357093
+ const result = chooseRecordedMatch(candidates, {
357094
+ recordedBounds: null,
357095
+ matchIndex: opts?.matchIndex,
357096
+ expectedMatchCount: opts?.expectedMatchCount,
357097
+ strict: mode
357098
+ });
357099
+ if (result.kind === "matched") {
357100
+ return result.elementId;
357101
+ }
357102
+ if (result.kind === "ambiguous" && !opts?.tolerateAmbiguity) {
357103
+ throw new MobileAmbiguousMatchError(
357104
+ `ambiguous WEBVIEW locator: ${result.count} matches for css=${css}, cannot identify the recorded instance`
357105
+ );
357106
+ }
357107
+ opts?.log?.(
357108
+ `\u26A0 ambiguous WEBVIEW locator (${result.count} matches for css=${css}) \u2014 using the first match. Set VALIDATEQA_MOBILE_STRICT_MATCH=strict to fail instead.`
357109
+ );
357110
+ return result.kind === "ambiguous" ? ids[0] : result.elementId;
357111
+ }
356357
357112
  } catch (err) {
356358
- if (isTransportError(err)) throw err;
357113
+ if (isTransportError(err) || err instanceof MobileAmbiguousMatchError) throw err;
356359
357114
  lastError = err instanceof Error ? err.message : String(err);
356360
357115
  }
356361
357116
  await new Promise((resolve2) => setTimeout(resolve2, 250));
@@ -356364,21 +357119,42 @@ async function findWebViewElement(sessionPath, css, timeoutMs = DEFAULT_STEP_TIM
356364
357119
  `WEBVIEW element not found for css=${css} after ${timeoutMs}ms` + (lastError ? `. Last error: ${lastError}` : "")
356365
357120
  );
356366
357121
  }
357122
+ function isWebViewHydrationTransient(err) {
357123
+ const message = err instanceof Error ? err.message : String(err);
357124
+ 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);
357125
+ }
357126
+ var WEBVIEW_HYDRATION_TIMEOUT_MS = Number(process.env.VALIDATEQA_WEBVIEW_HYDRATION_TIMEOUT_MS) || 3e4;
357127
+ async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, body, timeoutMs) {
357128
+ const deadline = Date.now() + Math.max(1e3, timeoutMs);
357129
+ for (; ; ) {
357130
+ try {
357131
+ await executeInWebViewContext(sessionId, step, css, body);
357132
+ return;
357133
+ } catch (err) {
357134
+ if (!isWebViewHydrationTransient(err) || Date.now() >= deadline) throw err;
357135
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
357136
+ }
357137
+ }
357138
+ }
356367
357139
  async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
356368
357140
  const webCss = stepWebViewCss(step);
356369
357141
  if (webCss) {
356370
- await executeInWebViewContext(
357142
+ await executeInWebViewContextWithHydrationRetry(
356371
357143
  sessionId,
356372
357144
  step,
356373
357145
  webCss,
356374
- (sessionPath, css) => clickCssInCurrentContext(sessionPath, css)
357146
+ (sessionPath, css) => clickCssInCurrentContext(sessionPath, css),
357147
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356375
357148
  );
356376
357149
  return;
356377
357150
  }
356378
357151
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
356379
357152
  if (step.target) {
356380
357153
  try {
356381
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357154
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357155
+ platform: platform3,
357156
+ recordedBounds: bounds
357157
+ });
356382
357158
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356383
357159
  method: "POST",
356384
357160
  body: "{}"
@@ -356400,11 +357176,12 @@ async function executeType(sessionId, step, context) {
356400
357176
  const keysValue = keyboardSubmitOnly ? WEBDRIVER_ENTER_KEY : value;
356401
357177
  const webCss = stepWebViewCss(step);
356402
357178
  if (webCss) {
356403
- await executeInWebViewContext(
357179
+ await executeInWebViewContextWithHydrationRetry(
356404
357180
  sessionId,
356405
357181
  step,
356406
357182
  webCss,
356407
- (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value)
357183
+ (sessionPath2, css) => keyboardSubmitOnly ? submitCssKeyboardInCurrentContext(sessionPath2, css) : typeCssInCurrentContext(sessionPath2, css, value),
357184
+ Math.max(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS)
356408
357185
  );
356409
357186
  return;
356410
357187
  }
@@ -356415,7 +357192,9 @@ async function executeType(sessionId, step, context) {
356415
357192
  const locatorStrategies = buildLocatorStrategies(step.target, context.platform);
356416
357193
  if (step.target && locatorStrategies.length > 0) {
356417
357194
  const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
356418
- platform: context.platform
357195
+ platform: context.platform,
357196
+ recordedBounds: bounds,
357197
+ log: context.log
356419
357198
  });
356420
357199
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
356421
357200
  method: "POST",
@@ -356456,22 +357235,28 @@ async function executeType(sessionId, step, context) {
356456
357235
  }
356457
357236
  async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
356458
357237
  const webCss = stepWebViewCss(step);
357238
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
356459
357239
  if (webCss) {
356460
357240
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356461
- const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357241
+ const elementId = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357242
+ matchIndex: step.target?.matchIndex,
357243
+ expectedMatchCount: step.target?.expectedMatchCount
357244
+ });
356462
357245
  await wdRequest(`${sessionPath}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356463
357246
  });
356464
357247
  return;
356465
357248
  }
356466
357249
  const locatorStrategies = buildLocatorStrategies(step.target, platform3);
356467
357250
  if (step.target && locatorStrategies.length > 0) {
356468
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
357251
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357252
+ platform: platform3,
357253
+ recordedBounds: bounds
357254
+ });
356469
357255
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
356470
357256
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
356471
357257
  await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
356472
357258
  return;
356473
357259
  }
356474
- const bounds = boundsForRun(step, platform3, isCrossPlatform);
356475
357260
  await clearField(`/session/${sessionId}`, bounds ?? void 0);
356476
357261
  }
356477
357262
  async function executeSwipe(sessionId, step) {
@@ -356532,34 +357317,47 @@ async function executeWaitForElement(sessionId, step, platform3) {
356532
357317
  sessionId,
356533
357318
  step,
356534
357319
  webCss,
356535
- (sessionPath, css) => findWebViewElement(sessionPath, css, timeout).then(() => void 0)
357320
+ (sessionPath, css) => findWebViewElement(sessionPath, css, timeout, { disambiguate: false }).then(() => void 0)
356536
357321
  );
356537
357322
  return;
356538
357323
  }
356539
- await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
357324
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3, disambiguate: false });
356540
357325
  }
356541
- async function executeAssertVisible(sessionId, step, platform3) {
357326
+ async function executeAssertVisible(sessionId, step, platform3, isCrossPlatform = false) {
356542
357327
  const webCss = stepWebViewCss(step);
356543
357328
  if (webCss) {
356544
357329
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356545
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357330
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357331
+ matchIndex: step.target?.matchIndex,
357332
+ expectedMatchCount: step.target?.expectedMatchCount,
357333
+ tolerateAmbiguity: true
357334
+ });
356546
357335
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/displayed`);
356547
357336
  if (!result2?.value) throw new Error(`WEBVIEW element found but not visible: css=${css}`);
356548
357337
  });
356549
357338
  return;
356550
357339
  }
356551
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357340
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357341
+ identityOnly: true,
357342
+ platform: platform3,
357343
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357344
+ tolerateAmbiguity: true
357345
+ });
356552
357346
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
356553
357347
  if (!result?.value) {
356554
357348
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
356555
357349
  }
356556
357350
  }
356557
- async function executeAssertText(sessionId, step, platform3) {
357351
+ async function executeAssertText(sessionId, step, platform3, isCrossPlatform = false) {
356558
357352
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
356559
357353
  const webCss = stepWebViewCss(step);
356560
357354
  if (webCss) {
356561
357355
  await executeInWebViewContext(sessionId, step, webCss, async (sessionPath, css) => {
356562
- const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step));
357356
+ const elementId2 = await findWebViewElement(sessionPath, css, readStepTimeout(step), {
357357
+ matchIndex: step.target?.matchIndex,
357358
+ expectedMatchCount: step.target?.expectedMatchCount,
357359
+ tolerateAmbiguity: true
357360
+ });
356563
357361
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/text`);
356564
357362
  const actualText2 = typeof result2?.value === "string" ? result2.value : "";
356565
357363
  const textCandidates2 = await readElementTextCandidates(sessionId, elementId2, actualText2);
@@ -356571,7 +357369,12 @@ async function executeAssertText(sessionId, step, platform3) {
356571
357369
  });
356572
357370
  return;
356573
357371
  }
356574
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
357372
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357373
+ identityOnly: true,
357374
+ platform: platform3,
357375
+ recordedBounds: boundsForRun(step, platform3, isCrossPlatform),
357376
+ tolerateAmbiguity: true
357377
+ });
356575
357378
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
356576
357379
  const actualText = typeof result?.value === "string" ? result.value : "";
356577
357380
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -356611,6 +357414,8 @@ function acquireControlPortSlot(deviceId) {
356611
357414
  function releaseControlPortSlot(deviceId) {
356612
357415
  controlPortSlotsByDevice.delete(deviceId);
356613
357416
  }
357417
+ var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357418
+ var pinnedDeviceSnapshot = null;
356614
357419
  async function executeMobileTest(options) {
356615
357420
  const logLines = [];
356616
357421
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -356618,6 +357423,7 @@ async function executeMobileTest(options) {
356618
357423
  const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
356619
357424
  const runPlatform = options.target.platform;
356620
357425
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
357426
+ const captureStepScreenshots = options.captureStepScreenshots !== false;
356621
357427
  if (missingVariableKeys.length > 0) {
356622
357428
  log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
356623
357429
  }
@@ -356682,32 +357488,42 @@ async function executeMobileTest(options) {
356682
357488
  await startAppiumServer();
356683
357489
  log2("Appium server started");
356684
357490
  }
356685
- log2("Discovering devices...");
356686
- const devices = discoverAllDevices({ includeApps: true });
356687
357491
  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.`
357492
+ const pinnedDeviceId = options.target.assignedDeviceId;
357493
+ const candidateSnapshot = pinnedDeviceSnapshot;
357494
+ const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && candidateSnapshot.deviceId === pinnedDeviceId && Date.now() - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
357495
+ if (reusablePinnedSnapshot) {
357496
+ assertDeviceNotQuarantined(reusablePinnedSnapshot.device.id);
357497
+ selectedDevice = reusablePinnedSnapshot.device;
357498
+ log2(`Reusing known device (skipped device re-scan): ${selectedDevice.name} (${selectedDevice.id})`);
357499
+ } else {
357500
+ log2("Discovering devices...");
357501
+ const devices = discoverAllDevices({ includeApps: true });
357502
+ const matchingDevices = devices.filter(
357503
+ (d) => d.platform === platformFilter && d.isAvailable && d.state === "BOOTED"
356694
357504
  );
356695
- }
356696
- if (options.target.assignedDeviceId) {
356697
- selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
356698
- if (!selectedDevice) {
357505
+ if (matchingDevices.length === 0) {
356699
357506
  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.`
357507
+ `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
356701
357508
  );
356702
357509
  }
357510
+ if (options.target.assignedDeviceId) {
357511
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
357512
+ if (!selectedDevice) {
357513
+ throw new Error(
357514
+ `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.`
357515
+ );
357516
+ }
357517
+ }
357518
+ if (!selectedDevice && options.target.recordedDeviceId) {
357519
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
357520
+ }
357521
+ if (!selectedDevice) {
357522
+ selectedDevice = matchingDevices[0];
357523
+ }
357524
+ log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
357525
+ pinnedDeviceSnapshot = options.target.assignedDeviceId ? { deviceId: options.target.assignedDeviceId, device: selectedDevice, at: Date.now() } : null;
356703
357526
  }
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
357527
  try {
356712
357528
  capture = await startMobileNetworkCapture({
356713
357529
  platform: options.target.platform,
@@ -356774,7 +357590,14 @@ async function executeMobileTest(options) {
356774
357590
  const resetAllowed = shouldResetMobileAppData(options.target.platform, selectedDevice.id);
356775
357591
  if (resetAllowed) {
356776
357592
  try {
356777
- await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357593
+ if (options.target.platform === "IOS") {
357594
+ await terminateTargetApp(sessionId, "IOS", options.target.appId).catch(() => {
357595
+ });
357596
+ const wiped = await clearIosSimulatorAppData(selectedDevice.id, options.target.appId);
357597
+ if (!wiped.ok) throw new Error(wiped.detail);
357598
+ } else {
357599
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
357600
+ }
356778
357601
  clearedAppDataBeforeLaunch = true;
356779
357602
  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
357603
  } catch (err) {
@@ -356827,17 +357650,21 @@ async function executeMobileTest(options) {
356827
357650
  const entryStep = selectEntryDriftGateStep(runSteps, runPlatform);
356828
357651
  if (entryStep) {
356829
357652
  try {
356830
- const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
357653
+ const entryTimeout = Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS);
356831
357654
  const entryWebCss = stepWebViewCss(entryStep);
356832
357655
  if (entryWebCss) {
356833
357656
  await executeInWebViewContext(
356834
357657
  sessionId,
356835
357658
  entryStep,
356836
357659
  entryWebCss,
356837
- (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout).then(() => void 0)
357660
+ (sessionPath, css) => findWebViewElement(sessionPath, css, entryTimeout, { disambiguate: false }).then(() => void 0)
356838
357661
  );
356839
357662
  } else {
356840
- await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
357663
+ await findElement(sessionId, entryStep.target, entryTimeout, {
357664
+ identityOnly: true,
357665
+ platform: runPlatform,
357666
+ disambiguate: false
357667
+ });
356841
357668
  }
356842
357669
  } catch (err) {
356843
357670
  if (isTransportError(err)) throw err;
@@ -356897,10 +357724,10 @@ async function executeMobileTest(options) {
356897
357724
  await executeWaitForElement(sessionId, step, runPlatform);
356898
357725
  break;
356899
357726
  case "assertVisible":
356900
- await executeAssertVisible(sessionId, step, runPlatform);
357727
+ await executeAssertVisible(sessionId, step, runPlatform, isCrossPlatform);
356901
357728
  break;
356902
357729
  case "assertText":
356903
- await executeAssertText(sessionId, step, runPlatform);
357730
+ await executeAssertText(sessionId, step, runPlatform, isCrossPlatform);
356904
357731
  break;
356905
357732
  default: {
356906
357733
  const _exhaustive = step.action;
@@ -356919,7 +357746,7 @@ async function executeMobileTest(options) {
356919
357746
  environmental = stepError;
356920
357747
  }
356921
357748
  }
356922
- const screenshot = await takeScreenshot(sessionId).catch(() => null);
357749
+ const screenshot = captureStepScreenshots ? await takeScreenshot(sessionId).catch(() => null) : null;
356923
357750
  screenshots.push(screenshot ?? "");
356924
357751
  if (status === "failed") {
356925
357752
  await recordFirstFailingSource();
@@ -357181,10 +358008,10 @@ async function driveHarvestStep(sessionId, step, elementId, platform3) {
357181
358008
  await executeWaitForElement(sessionId, step, platform3);
357182
358009
  return true;
357183
358010
  case "assertVisible":
357184
- await executeAssertVisible(sessionId, step, platform3);
358011
+ await executeAssertVisible(sessionId, step, platform3, true);
357185
358012
  return true;
357186
358013
  case "assertText":
357187
- await executeAssertText(sessionId, step, platform3);
358014
+ await executeAssertText(sessionId, step, platform3, true);
357188
358015
  return true;
357189
358016
  default:
357190
358017
  break;
@@ -357266,7 +358093,15 @@ async function harvestPlatformLocators(options) {
357266
358093
  const identityStrategies = webViewCss ? [] : buildIdentityLocatorStrategies(step.target);
357267
358094
  if (!webViewCss && identityStrategies.length > 0 && step.target) {
357268
358095
  try {
357269
- elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
358096
+ elementId = await findElement(
358097
+ session.sessionId,
358098
+ step.target,
358099
+ Math.max(DEFAULT_STEP_TIMEOUT_MS, WEBVIEW_HYDRATION_TIMEOUT_MS),
358100
+ {
358101
+ identityOnly: true,
358102
+ failClosedOnAmbiguity: true
358103
+ }
358104
+ );
357270
358105
  } catch (err) {
357271
358106
  if (isTransportError(err)) throw err;
357272
358107
  elementId = null;
@@ -357362,6 +358197,86 @@ function validateRunnerMobileTarget(value) {
357362
358197
  };
357363
358198
  }
357364
358199
 
358200
+ // src/services/heartbeat-keepalive.ts
358201
+ import { Worker } from "worker_threads";
358202
+ var KEEPALIVE_INTERVAL_MS = (() => {
358203
+ const raw = Number(process.env.VALIDATEQA_HEARTBEAT_KEEPALIVE_MS);
358204
+ return Number.isFinite(raw) && raw > 0 ? raw : 2e4;
358205
+ })();
358206
+ function isHeartbeatKeepaliveEnabled(env = process.env) {
358207
+ return (env.VALIDATEQA_HEARTBEAT_KEEPALIVE ?? "").trim().toLowerCase() !== "off";
358208
+ }
358209
+ var WORKER_SOURCE = `
358210
+ const { parentPort } = require('node:worker_threads');
358211
+ let url = null;
358212
+ let headers = null;
358213
+ let body = null;
358214
+ let intervalMs = 20000;
358215
+ let timer = null;
358216
+
358217
+ async function post() {
358218
+ if (!url || !headers || !body) return; // inert until the first real heartbeat
358219
+ try {
358220
+ await fetch(url, {
358221
+ method: 'POST',
358222
+ headers,
358223
+ body,
358224
+ signal: AbortSignal.timeout(10000),
358225
+ });
358226
+ } catch {
358227
+ // Best-effort by design: the next tick retries; the main loop's own
358228
+ // heartbeat error handling owns logging/upgrade signals.
358229
+ }
358230
+ }
358231
+
358232
+ parentPort.on('message', (msg) => {
358233
+ if (!msg || typeof msg !== 'object') return;
358234
+ if (msg.type === 'update') {
358235
+ url = msg.url;
358236
+ headers = msg.headers;
358237
+ body = msg.body;
358238
+ intervalMs = msg.intervalMs || intervalMs;
358239
+ if (!timer) {
358240
+ timer = setInterval(post, intervalMs);
358241
+ if (timer.unref) timer.unref();
358242
+ }
358243
+ } else if (msg.type === 'stop') {
358244
+ if (timer) clearInterval(timer);
358245
+ timer = null;
358246
+ parentPort.close();
358247
+ }
358248
+ });
358249
+ `;
358250
+ var NOOP_KEEPALIVE = { update: () => {
358251
+ }, stop: () => {
358252
+ } };
358253
+ function startHeartbeatKeepalive(options = {}) {
358254
+ if (!isHeartbeatKeepaliveEnabled(options.env)) return NOOP_KEEPALIVE;
358255
+ const intervalMs = options.intervalMs ?? KEEPALIVE_INTERVAL_MS;
358256
+ let worker;
358257
+ try {
358258
+ worker = new Worker(WORKER_SOURCE, { eval: true });
358259
+ worker.unref();
358260
+ worker.on("error", () => {
358261
+ worker = null;
358262
+ });
358263
+ } catch {
358264
+ return NOOP_KEEPALIVE;
358265
+ }
358266
+ return {
358267
+ update(url, headers, body) {
358268
+ worker?.postMessage({ type: "update", url, headers, body, intervalMs });
358269
+ },
358270
+ stop() {
358271
+ try {
358272
+ worker?.postMessage({ type: "stop" });
358273
+ } catch {
358274
+ }
358275
+ worker = null;
358276
+ }
358277
+ };
358278
+ }
358279
+
357365
358280
  // src/mobile-device-queue.ts
357366
358281
  function isNativeMobileDiscoveryContext(ctx) {
357367
358282
  if (!ctx) return false;
@@ -357435,7 +358350,7 @@ import { createServer } from "http";
357435
358350
  import { execFileSync as execFileSync4 } from "child_process";
357436
358351
  import { randomBytes, timingSafeEqual } from "crypto";
357437
358352
  import { homedir as homedir2 } from "os";
357438
- import { join as join5, dirname } from "path";
358353
+ import { join as join6, dirname } from "path";
357439
358354
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3 } from "fs";
357440
358355
  init_dist();
357441
358356
  var MOBILE_BRIDGE_PORT2 = 9515;
@@ -357542,7 +358457,7 @@ async function getWindowSize3() {
357542
358457
  }
357543
358458
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
357544
358459
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
357545
- var BRIDGE_PID_FILE = join5(homedir2(), ".validate.qa", "mobile-bridge.pid");
358460
+ var BRIDGE_PID_FILE = join6(homedir2(), ".validate.qa", "mobile-bridge.pid");
357546
358461
  function readBridgePidFile() {
357547
358462
  try {
357548
358463
  const raw = readFileSync2(BRIDGE_PID_FILE, "utf-8").trim();
@@ -357854,6 +358769,13 @@ async function startSession(validated, res) {
357854
358769
  ...deviceId ? { "appium:udid": deviceId } : {},
357855
358770
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
357856
358771
  "appium:noReset": true,
358772
+ // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
358773
+ // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
358774
+ // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
358775
+ // skip the GUI open. Scoped to simulator UDIDs; APPIUM_IOS_HEADLESS=false
358776
+ // overrides. Verified live: without this, recorder/mirror sessions on iOS
358777
+ // die with "Simulator.app does not exist" while executor sessions work.
358778
+ ...isIosSimulatorUdid(deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
357857
358779
  ...realCaps
357858
358780
  } : {
357859
358781
  platformName: "Android",
@@ -357872,7 +358794,12 @@ async function startSession(validated, res) {
357872
358794
  capabilities: {
357873
358795
  alwaysMatch: capabilities
357874
358796
  }
357875
- })
358797
+ }),
358798
+ // Session creation is the one legitimately slow WebDriver call (iOS WDA
358799
+ // build/launch can take minutes on a cold toolchain) — same ceiling as
358800
+ // the executor's wdRequest session-create path. Without this, the shared
358801
+ // appiumRequest 10s default aborts every iOS recorder session.
358802
+ signal: AbortSignal.timeout(WD_SESSION_CREATE_TIMEOUT_MS)
357876
358803
  });
357877
358804
  state.appiumSessionId = result?.value?.sessionId ?? result?.sessionId ?? null;
357878
358805
  if (!state.appiumSessionId) {
@@ -358650,6 +359577,9 @@ function detachMirrorSession(deviceId) {
358650
359577
  stopAllMirrorClients();
358651
359578
  stopSessionReaper();
358652
359579
  }
359580
+ function getMirrorSessionId(deviceId) {
359581
+ return runnerChannels.get(deviceId)?.sessionId;
359582
+ }
358653
359583
 
358654
359584
  // src/services/mobile/mobile-bridge-driver.ts
358655
359585
  var import_runner_core9 = __toESM(require_dist2());
@@ -358680,6 +359610,7 @@ var MobileBridgeDriver = class {
358680
359610
  sessionId;
358681
359611
  platform;
358682
359612
  appId;
359613
+ deviceId;
358683
359614
  allowAppReset;
358684
359615
  recoverSession;
358685
359616
  maxSessionRecoveries;
@@ -358700,6 +359631,7 @@ var MobileBridgeDriver = class {
358700
359631
  this.sessionId = config.sessionId;
358701
359632
  this.platform = config.platform;
358702
359633
  this.appId = config.appId;
359634
+ this.deviceId = config.deviceId;
358703
359635
  this.allowAppReset = config.allowAppReset === true;
358704
359636
  this.recoverSession = config.recoverSession;
358705
359637
  this.maxSessionRecoveries = Math.max(0, Math.floor(config.maxSessionRecoveries ?? 1));
@@ -358997,17 +359929,34 @@ var MobileBridgeDriver = class {
358997
359929
  });
358998
359930
  }
358999
359931
  /**
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.
359932
+ * Deterministic state reset: clear app data + relaunch, per the
359933
+ * runner-computed allowAppReset policy (Android emulators / iOS simulators
359934
+ * only — a personal physical device is never wiped). Android clears via
359935
+ * `mobile: clearApp`; iOS terminates the app and wipes its simulator data
359936
+ * container + keychain through `simctl` (see ios-simulator-reset.ts).
359937
+ * Returns false when unsupported/disallowed or when the clear fails —
359938
+ * callers fall back to relaunch(), preserving today's behavior. On success,
359939
+ * waits for cold-boot content so recording and replay agree on the true
359940
+ * entry screen.
359005
359941
  */
359006
359942
  async resetAppState() {
359007
- if (!this.allowAppReset || this.platform !== "ANDROID") return false;
359943
+ if (!this.allowAppReset) return false;
359944
+ if (this.platform === "IOS" && !isIosSimulatorUdid(this.deviceId)) return false;
359008
359945
  return this.withSessionRecovery("app data reset", async () => {
359009
359946
  try {
359010
- await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359947
+ if (this.platform === "IOS") {
359948
+ try {
359949
+ await this.executeScript("mobile: terminateApp", [{ ...this.appLifecycleArgs(), timeout: 5e3 }]);
359950
+ } catch (err) {
359951
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
359952
+ }
359953
+ const wiped = await clearIosSimulatorAppData(this.deviceId ?? "", this.appId);
359954
+ if (!wiped.ok) {
359955
+ throw new Error(`iOS simulator app-data reset failed: ${wiped.detail}`);
359956
+ }
359957
+ } else {
359958
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
359959
+ }
359011
359960
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
359012
359961
  try {
359013
359962
  await waitForColdBootSettle(this.sessionPath);
@@ -359598,6 +360547,113 @@ function envInt(name, fallback) {
359598
360547
  }
359599
360548
  var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
359600
360549
  var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
360550
+ var MOBILE_RUN_WATCHDOG_MS = Math.max(0, envInt("MOBILE_RUN_WATCHDOG_MS", 30 * 6e4));
360551
+ var MOBILE_RUN_ABORT_GRACE_MS = Math.max(0, envInt("MOBILE_RUN_ABORT_GRACE_MS", 2 * 6e4));
360552
+ var MOBILE_CANCEL_POLL_MS = Math.max(0, envInt("MOBILE_CANCEL_POLL_MS", 15e3));
360553
+ var MobileRunWatchdog = class {
360554
+ constructor(options) {
360555
+ this.options = options;
360556
+ }
360557
+ phase = "idle";
360558
+ released = false;
360559
+ quarantined = false;
360560
+ cancelAbortTimer = null;
360561
+ cancelGraceTimer = null;
360562
+ /** Current phase (exposed for tests / observability). */
360563
+ getPhase() {
360564
+ return this.phase;
360565
+ }
360566
+ /** Arm the wall-clock abort timer. Call once, immediately after dispatch. */
360567
+ start() {
360568
+ if (this.phase !== "idle") return;
360569
+ this.phase = "active";
360570
+ if (this.options.abortMs > 0) {
360571
+ this.cancelAbortTimer = this.options.schedule(
360572
+ () => this.triggerAbort(`exceeded the ${Math.round(this.options.abortMs / 6e4)}min wall-clock ceiling`),
360573
+ this.options.abortMs
360574
+ );
360575
+ }
360576
+ }
360577
+ /** Stage 1. Idempotent (a no-op unless still ACTIVE). Also called by the
360578
+ * cancellation observer. Quarantines the device (blocking the run's own
360579
+ * session-recovery from resurrecting the killed session — observed live),
360580
+ * kills the run's session (best-effort), and starts the grace clock;
360581
+ * deliberately does NOT release the lease. */
360582
+ triggerAbort(reason) {
360583
+ if (this.phase !== "active") return;
360584
+ this.phase = "aborting";
360585
+ this.clearAbortTimer();
360586
+ if (this.options.deviceId) {
360587
+ this.quarantined = true;
360588
+ this.options.callbacks.quarantine(this.options.deviceId);
360589
+ this.options.callbacks.abort(this.options.deviceId, this.options.runId, reason);
360590
+ } else {
360591
+ this.options.callbacks.log("warn", `Run ${this.options.runId} ${reason} \u2014 no leased device to abort; awaiting the run's own settle.`);
360592
+ }
360593
+ if (this.options.graceMs > 0) {
360594
+ this.cancelGraceTimer = this.options.schedule(() => this.forceRelease(), this.options.graceMs);
360595
+ } else {
360596
+ this.forceRelease();
360597
+ }
360598
+ }
360599
+ /** The run's promise settled (normal completion, or after an abort). The single
360600
+ * release owner: releases at most once and lifts any quarantine. Idempotent. */
360601
+ settle() {
360602
+ if (this.phase === "settled") return;
360603
+ const wasForceReleased = this.released;
360604
+ this.phase = "settled";
360605
+ this.clearAbortTimer();
360606
+ this.clearGraceTimer();
360607
+ if (!this.released) {
360608
+ this.released = true;
360609
+ this.options.callbacks.release();
360610
+ }
360611
+ if (this.quarantined && this.options.deviceId) {
360612
+ this.quarantined = false;
360613
+ this.options.callbacks.unquarantine(this.options.deviceId);
360614
+ if (wasForceReleased) {
360615
+ this.options.callbacks.log("info", `Run ${this.options.runId} finally settled \u2014 lifted the quarantine on device ${this.options.deviceId}.`);
360616
+ }
360617
+ }
360618
+ }
360619
+ /** Stage 2. The run did not settle within the grace window after the abort. Force-
360620
+ * release the slot to unwedge capacity; the device stays quarantined (since the
360621
+ * stage-1 abort) until the promise eventually settles. Idempotent and guarded
360622
+ * against a late settle. */
360623
+ forceRelease() {
360624
+ if (this.released || this.phase === "settled") return;
360625
+ this.clearGraceTimer();
360626
+ this.released = true;
360627
+ this.options.callbacks.release();
360628
+ if (this.options.deviceId) {
360629
+ if (!this.quarantined) {
360630
+ this.quarantined = true;
360631
+ this.options.callbacks.quarantine(this.options.deviceId);
360632
+ }
360633
+ this.options.callbacks.log(
360634
+ "warn",
360635
+ `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.`
360636
+ );
360637
+ } else {
360638
+ this.options.callbacks.log("warn", `Run ${this.options.runId} did not settle after abort \u2014 force-released its slot to unwedge mobile capacity.`);
360639
+ }
360640
+ }
360641
+ clearAbortTimer() {
360642
+ if (this.cancelAbortTimer) {
360643
+ this.cancelAbortTimer();
360644
+ this.cancelAbortTimer = null;
360645
+ }
360646
+ }
360647
+ clearGraceTimer() {
360648
+ if (this.cancelGraceTimer) {
360649
+ this.cancelGraceTimer();
360650
+ this.cancelGraceTimer = null;
360651
+ }
360652
+ }
360653
+ };
360654
+ function isServerCancelledRunStatus(status) {
360655
+ return status === "ERROR" || status === "CANCELLED" || status === "CANCELLING";
360656
+ }
359601
360657
  function scrubAuditMessages(messages, testVariables) {
359602
360658
  if (messages === void 0 || messages === null) return messages;
359603
360659
  const compacted = compactForAudit(messages);
@@ -359673,7 +360729,7 @@ var log = {
359673
360729
  dim: (msg) => console.log(`${C2.gray}${msg}${C2.reset}`)
359674
360730
  };
359675
360731
  function getRunsDir() {
359676
- const dir = process.env.RUNS_DIR || join6(homedir3(), ".validate.qa", "runs");
360732
+ const dir = process.env.RUNS_DIR || join7(homedir3(), ".validate.qa", "runs");
359677
360733
  (0, import_runner_core12.ensurePrivateDirectory)(dir);
359678
360734
  return dir;
359679
360735
  }
@@ -361159,9 +362215,15 @@ ${finalVerifyResult.logs ?? ""}`;
361159
362215
  // flip the test to needs_manual_authoring), so an environmentally
361160
362216
  // aborted pass — wrong screen, system dialog, transport flake —
361161
362217
  // 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,
362218
+ // The same applies to a harvest that ADVANCED THROUGH NOTHING
362219
+ // (reachedOrder null): it never got past the entry screen, so its
362220
+ // step judgments are cold-boot/entry noise, not app knowledge —
362221
+ // observed live: a slow WKWebView hydration marked step 1
362222
+ // divergent and permanently dead-ended the test in
362223
+ // needs_manual_authoring with no retry path. Harvested bundles
362224
+ // are still posted: they were positively resolved and the merge
362225
+ // is idempotent.
362226
+ divergentOrders: harvest.environmentalError || harvest.reachedOrder == null ? [] : harvest.divergentOrders,
361165
362227
  reachedOrder: harvest.reachedOrder,
361166
362228
  logs: harvest.logs,
361167
362229
  ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
@@ -361605,9 +362667,11 @@ ${finalVerifyResult.logs ?? ""}`;
361605
362667
  sessionId: session.sessionId,
361606
362668
  platform: platform3,
361607
362669
  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.
362670
+ deviceId: session.deviceId,
362671
+ // Deterministic-state authorization: Android emulators / iOS
362672
+ // simulators only (VALIDATEQA_MOBILE_APP_RESET overrides the
362673
+ // Android default) — a personal physical device must never have
362674
+ // its app data wiped.
361611
362675
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
361612
362676
  maxSessionRecoveries: 1,
361613
362677
  recoverSession: async (cause) => {
@@ -362123,6 +363187,7 @@ ${finalVerifyResult.logs ?? ""}`;
362123
363187
  sessionId: opened.sessionId,
362124
363188
  platform: target.platform,
362125
363189
  appId: opened.appId,
363190
+ deviceId: opened.deviceId,
362126
363191
  allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, opened.deviceId)
362127
363192
  });
362128
363193
  };
@@ -362735,6 +363800,7 @@ async function startRunner(opts) {
362735
363800
  else activeMobileOpsByPlatform.set(p, next);
362736
363801
  };
362737
363802
  const leasedDeviceIds = /* @__PURE__ */ new Map();
363803
+ const activeMobileRuns = /* @__PURE__ */ new Map();
362738
363804
  let activeMobileDiscoveries = 0;
362739
363805
  const mobileHealHold = /* @__PURE__ */ new Map();
362740
363806
  const pruneMobileHealHolds = () => {
@@ -362815,6 +363881,7 @@ async function startRunner(opts) {
362815
363881
  }
362816
363882
  }
362817
363883
  };
363884
+ const heartbeatKeepalive = startHeartbeatKeepalive();
362818
363885
  const sendHeartbeat = async () => {
362819
363886
  try {
362820
363887
  const devices = enableMobile ? getCachedDeviceSnapshot() : [];
@@ -362829,41 +363896,44 @@ async function startRunner(opts) {
362829
363896
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
362830
363897
  lastInventoryKinds.clear();
362831
363898
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
362832
- const response = await fetch(`${serverUrl}/api/runner/heartbeat`, {
363899
+ const heartbeatUrl = `${serverUrl}/api/runner/heartbeat`;
363900
+ const heartbeatBody = JSON.stringify({
363901
+ runnerName: RUNNER_NAME,
363902
+ runnerVersion: RUNNER_VERSION,
363903
+ runnerBuildId: RUNNER_BUILD_ID,
363904
+ runsCompleted,
363905
+ activeRuns,
363906
+ maxConcurrent: MAX_CONCURRENT,
363907
+ uptimeMs: Date.now() - startedAt,
363908
+ capabilities,
363909
+ devices,
363910
+ mobileBridge: bridge ? {
363911
+ online: bridge.running,
363912
+ port: bridge.port,
363913
+ tunnelUrl: bridge.tunnelUrl,
363914
+ tunnelToken: bridge.tunnelToken
363915
+ } : void 0,
363916
+ activeByType: Object.fromEntries(activeByType),
363917
+ activeByProject: Object.fromEntries(activeByProject),
363918
+ typeLimits: TYPE_LIMITS,
363919
+ maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
363920
+ resourceSnapshot: resourceStatus.snapshot,
363921
+ resourceGovernor: resourceStatus.governor,
363922
+ processInventory,
363923
+ aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
363924
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
363925
+ // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
363926
+ // Cached & cheap — see getCachedDoctor above.
363927
+ doctor: getCachedDoctor()
363928
+ });
363929
+ const response = await fetch(heartbeatUrl, {
362833
363930
  method: "POST",
362834
363931
  headers,
362835
- body: JSON.stringify({
362836
- runnerName: RUNNER_NAME,
362837
- runnerVersion: RUNNER_VERSION,
362838
- runnerBuildId: RUNNER_BUILD_ID,
362839
- runsCompleted,
362840
- activeRuns,
362841
- maxConcurrent: MAX_CONCURRENT,
362842
- uptimeMs: Date.now() - startedAt,
362843
- capabilities,
362844
- devices,
362845
- mobileBridge: bridge ? {
362846
- online: bridge.running,
362847
- port: bridge.port,
362848
- tunnelUrl: bridge.tunnelUrl,
362849
- tunnelToken: bridge.tunnelToken
362850
- } : void 0,
362851
- activeByType: Object.fromEntries(activeByType),
362852
- activeByProject: Object.fromEntries(activeByProject),
362853
- typeLimits: TYPE_LIMITS,
362854
- maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
362855
- resourceSnapshot: resourceStatus.snapshot,
362856
- resourceGovernor: resourceStatus.governor,
362857
- processInventory,
362858
- aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
362859
- liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
362860
- // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
362861
- // Cached & cheap — see getCachedDoctor above.
362862
- doctor: getCachedDoctor()
362863
- }),
363932
+ body: heartbeatBody,
362864
363933
  signal: AbortSignal.timeout(1e4)
362865
363934
  });
362866
363935
  if (response.ok) {
363936
+ heartbeatKeepalive.update(heartbeatUrl, headers, heartbeatBody);
362867
363937
  const body = await response.json().catch(() => null);
362868
363938
  if (body && Array.isArray(body.commands) && body.commands.length > 0) {
362869
363939
  void handleControlCommands(body.commands);
@@ -362934,6 +364004,7 @@ async function startRunner(opts) {
362934
364004
  process.exit(1);
362935
364005
  }, SHUTDOWN_TIMEOUT_MS).unref();
362936
364006
  clearInterval(heartbeat);
364007
+ heartbeatKeepalive.stop();
362937
364008
  if (liveBrowserHeartbeatTimer) {
362938
364009
  clearTimeout(liveBrowserHeartbeatTimer);
362939
364010
  liveBrowserHeartbeatTimer = null;
@@ -362985,6 +364056,32 @@ async function startRunner(opts) {
362985
364056
  }
362986
364057
  }, PIDS_WATCHDOG_INTERVAL_MS);
362987
364058
  pidsWatchdog.unref();
364059
+ const pollMobileRunCancellations = async () => {
364060
+ if (shuttingDown || activeMobileRuns.size === 0) return;
364061
+ const runIds = [...activeMobileRuns.keys()];
364062
+ try {
364063
+ const res = await fetch(`${serverUrl}/api/runner/runs/status`, {
364064
+ method: "POST",
364065
+ headers: { ...headers, "Content-Type": "application/json" },
364066
+ body: JSON.stringify({ runIds }),
364067
+ signal: AbortSignal.timeout(1e4)
364068
+ });
364069
+ if (!res.ok) return;
364070
+ const body = await res.json().catch(() => null);
364071
+ if (!body || !Array.isArray(body.runs)) return;
364072
+ for (const row of body.runs) {
364073
+ if (!row || typeof row.id !== "string" || typeof row.status !== "string") continue;
364074
+ if (!isServerCancelledRunStatus(row.status)) continue;
364075
+ const watchdog = activeMobileRuns.get(row.id);
364076
+ if (watchdog) watchdog.triggerAbort(`was cancelled server-side (status ${row.status})`);
364077
+ }
364078
+ } catch {
364079
+ }
364080
+ };
364081
+ const mobileCancelPoll = enableMobile && MOBILE_CANCEL_POLL_MS > 0 ? setInterval(() => {
364082
+ void pollMobileRunCancellations();
364083
+ }, MOBILE_CANCEL_POLL_MS) : null;
364084
+ mobileCancelPoll?.unref?.();
362988
364085
  const deferralLogged = /* @__PURE__ */ new Map();
362989
364086
  const logDeferredOnce = (runId, line) => {
362990
364087
  if (deferralLogged.get(runId) === line) return;
@@ -363067,14 +364164,17 @@ async function startRunner(opts) {
363067
364164
  });
363068
364165
  const capacity = MOBILE_DEVICE_CEILING > 0 ? Math.min(derived, MOBILE_DEVICE_CEILING) : derived;
363069
364166
  const leaseTarget = run2.target ?? run2.deviceRequirements;
363070
- const leaseSelection = selectFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot, {
364167
+ const leaseExclusions = {
364168
+ has: (id) => leasedDeviceIds.has(id) || isDeviceQuarantined(id)
364169
+ };
364170
+ const leaseSelection = selectFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot, {
363071
364171
  appId: typeof leaseTarget?.appId === "string" ? leaseTarget.appId : void 0,
363072
364172
  recordedDeviceId: typeof leaseTarget?.recordedDeviceId === "string" ? leaseTarget.recordedDeviceId : void 0,
363073
364173
  platformVersion: typeof leaseTarget?.platformVersion === "string" ? leaseTarget.platformVersion : void 0,
363074
364174
  deviceName: typeof run2.deviceRequirements?.deviceName === "string" ? run2.deviceRequirements.deviceName : void 0,
363075
364175
  resetPolicy: leaseTarget?.resetPolicy
363076
364176
  });
363077
- const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leasedDeviceIds, leaseSnapshot) : null;
364177
+ const freeDeviceId = leaseSelection.kind === "selected" ? leaseSelection.deviceId : leaseSelection.kind === "unsatisfiable" ? pickFreeDevice(mobilePlatform, leaseExclusions, leaseSnapshot) : null;
363078
364178
  if (leaseSelection.kind === "unsatisfiable") {
363079
364179
  mobileLeaseError = leaseSelection.reason;
363080
364180
  log.warn(` Mobile requirements unsatisfiable for ${run2.runId}: ${leaseSelection.reason}; claiming once to report the configuration error`);
@@ -363132,6 +364232,54 @@ async function startRunner(opts) {
363132
364232
  await new Promise((r) => setTimeout(r, staggerMs));
363133
364233
  }
363134
364234
  claimedThisPoll++;
364235
+ const applyRelease = () => {
364236
+ activeRuns--;
364237
+ decrementType(runMode);
364238
+ decrementProject(runProject);
364239
+ if (isMobileOp) {
364240
+ if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
364241
+ if (isDiscovery) activeMobileDiscoveries--;
364242
+ if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
364243
+ activeMobileRuns.delete(run2.runId);
364244
+ }
364245
+ };
364246
+ const runWatchdog = isMobileOp ? new MobileRunWatchdog({
364247
+ runId: run2.runId,
364248
+ deviceId: leasedDeviceId ?? void 0,
364249
+ abortMs: MOBILE_RUN_WATCHDOG_MS,
364250
+ graceMs: MOBILE_RUN_ABORT_GRACE_MS,
364251
+ // Real timers, unref'd so a pending watchdog never keeps the process
364252
+ // alive on shutdown.
364253
+ schedule: (fn, ms) => {
364254
+ const t = setTimeout(fn, ms);
364255
+ if (typeof t.unref === "function") t.unref();
364256
+ return () => clearTimeout(t);
364257
+ },
364258
+ callbacks: {
364259
+ abort: (deviceId, rid, reason) => {
364260
+ const sessionId = getMirrorSessionId(deviceId);
364261
+ log.warn(
364262
+ `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.`
364263
+ );
364264
+ if (sessionId) void deleteAppiumSession(sessionId);
364265
+ },
364266
+ release: applyRelease,
364267
+ quarantine: (deviceId) => {
364268
+ quarantineDevice(deviceId);
364269
+ },
364270
+ unquarantine: (deviceId) => {
364271
+ unquarantineDevice(deviceId);
364272
+ },
364273
+ log: (level, message) => {
364274
+ if (level === "warn") log.warn(message);
364275
+ else log.info(message);
364276
+ }
364277
+ }
364278
+ }) : null;
364279
+ if (runWatchdog) {
364280
+ activeMobileRuns.set(run2.runId, runWatchdog);
364281
+ runWatchdog.start();
364282
+ }
363135
364283
  executeRun(run2, opts, headers, requestLiveBrowserHeartbeat, (testCaseId) => {
363136
364284
  if (mobilePlatform) {
363137
364285
  mobileHealHold.set(testCaseId, { expiry: Date.now() + MOBILE_HEAL_HOLD_MS, platform: mobilePlatform });
@@ -363142,14 +364290,8 @@ async function startRunner(opts) {
363142
364290
  const msg = err instanceof Error ? err.message : String(err);
363143
364291
  log.warn(`Run ${run2.runId} crashed: ${msg}`);
363144
364292
  }).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
- }
364293
+ if (runWatchdog) runWatchdog.settle();
364294
+ else applyRelease();
363153
364295
  });
363154
364296
  }
363155
364297
  }