@validate.qa/runner 1.0.16 → 1.0.17

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 +458 -44
  2. package/dist/cli.mjs +458 -44
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1856,6 +1856,102 @@ var init_mobile_test_generation = __esm({
1856
1856
  }
1857
1857
  });
1858
1858
 
1859
+ // ../shared/dist/mobile-cross-platform.js
1860
+ function isMobilePlatform(value) {
1861
+ return value === "IOS" || value === "ANDROID";
1862
+ }
1863
+ function mediumForMobilePlatform(platform3) {
1864
+ return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
1865
+ }
1866
+ function mobilePlatformFromMedium(medium) {
1867
+ if (medium === "IOS_NATIVE")
1868
+ return "IOS";
1869
+ if (medium === "ANDROID_NATIVE")
1870
+ return "ANDROID";
1871
+ return null;
1872
+ }
1873
+ function otherMobilePlatform(platform3) {
1874
+ return platform3 === "IOS" ? "ANDROID" : "IOS";
1875
+ }
1876
+ function platformTag(platform3) {
1877
+ return platform3 === "IOS" ? "@ios" : "@android";
1878
+ }
1879
+ function resolvePlatformLocatorBundle(target, platform3) {
1880
+ if (!target)
1881
+ return void 0;
1882
+ if (platform3) {
1883
+ const overlay = target.platformLocators?.[platform3];
1884
+ if (overlay)
1885
+ return overlay;
1886
+ }
1887
+ return target.locatorBundle;
1888
+ }
1889
+ function hasPlatformLocator(target, platform3) {
1890
+ return Boolean(target?.platformLocators?.[platform3]);
1891
+ }
1892
+ function hasCrossPlatformIdentity(bundle) {
1893
+ if (!bundle)
1894
+ return false;
1895
+ return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
1896
+ }
1897
+ function stepIsDivergentForPlatform(step, platform3) {
1898
+ return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
1899
+ }
1900
+ function stepAppliesToPlatform(step, platform3) {
1901
+ const platforms = step.platforms;
1902
+ if (!platforms || platforms.length === 0)
1903
+ return true;
1904
+ if (!platform3)
1905
+ return true;
1906
+ return platforms.includes(platform3);
1907
+ }
1908
+ function stepsMissingPlatformLocators(steps, platform3) {
1909
+ return steps.filter((step) => {
1910
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1911
+ return false;
1912
+ if (!stepAppliesToPlatform(step, platform3))
1913
+ return false;
1914
+ if (hasPlatformLocator(step.target, platform3))
1915
+ return false;
1916
+ if (stepIsDivergentForPlatform(step, platform3))
1917
+ return false;
1918
+ return hasCrossPlatformIdentity(step.target?.locatorBundle);
1919
+ });
1920
+ }
1921
+ function stepsNeedingReviewForPlatform(steps, platform3) {
1922
+ return steps.filter((step) => {
1923
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1924
+ return false;
1925
+ if (!stepAppliesToPlatform(step, platform3))
1926
+ return false;
1927
+ if (hasPlatformLocator(step.target, platform3))
1928
+ return false;
1929
+ if (stepIsDivergentForPlatform(step, platform3))
1930
+ return true;
1931
+ return !hasCrossPlatformIdentity(step.target?.locatorBundle);
1932
+ });
1933
+ }
1934
+ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
1935
+ if (runPlatform === authoringPlatform)
1936
+ return true;
1937
+ return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
1938
+ }
1939
+ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
1940
+ var init_mobile_cross_platform = __esm({
1941
+ "../shared/dist/mobile-cross-platform.js"() {
1942
+ "use strict";
1943
+ MOBILE_PLATFORMS = ["IOS", "ANDROID"];
1944
+ LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
1945
+ "tap",
1946
+ "type",
1947
+ "clear",
1948
+ "waitForElement",
1949
+ "assertVisible",
1950
+ "assertText"
1951
+ ]);
1952
+ }
1953
+ });
1954
+
1859
1955
  // ../shared/dist/mobile-deeplink.js
1860
1956
  function isSafeDeepLink(value) {
1861
1957
  if (typeof value !== "string")
@@ -3534,6 +3630,7 @@ __export(dist_exports, {
3534
3630
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
3535
3631
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
3536
3632
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
3633
+ MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
3537
3634
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
3538
3635
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
3539
3636
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
@@ -3587,6 +3684,8 @@ __export(dist_exports, {
3587
3684
  getPlanDefinition: () => getPlanDefinition,
3588
3685
  getPlanLabel: () => getPlanLabel,
3589
3686
  getTrialDaysRemaining: () => getTrialDaysRemaining,
3687
+ hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
3688
+ hasPlatformLocator: () => hasPlatformLocator,
3590
3689
  hostOfOrigin: () => hostOfOrigin,
3591
3690
  isAddonType: () => isAddonType,
3592
3691
  isApiTest: () => isApiTest,
@@ -3596,23 +3695,34 @@ __export(dist_exports, {
3596
3695
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3597
3696
  isDbPricingPlan: () => isDbPricingPlan,
3598
3697
  isEnvironmentalError: () => isEnvironmentalError,
3698
+ isMobilePlatform: () => isMobilePlatform,
3599
3699
  isPricingPlan: () => isPricingPlan,
3600
3700
  isRunnableStep: () => isRunnableStep,
3601
3701
  isSafeDeepLink: () => isSafeDeepLink,
3602
3702
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
3703
+ isTestReadyForPlatform: () => isTestReadyForPlatform,
3603
3704
  isTrialExpired: () => isTrialExpired,
3604
3705
  isUIVariant: () => isUIVariant,
3605
3706
  isValidMobileAppId: () => isValidMobileAppId,
3707
+ mediumForMobilePlatform: () => mediumForMobilePlatform,
3708
+ mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3606
3709
  normalizeControlName: () => normalizeControlName,
3710
+ otherMobilePlatform: () => otherMobilePlatform,
3607
3711
  parenDepthDelta: () => parenDepthDelta,
3608
3712
  planHasBillingPortal: () => planHasBillingPortal,
3609
3713
  planRequiresCheckout: () => planRequiresCheckout,
3714
+ platformTag: () => platformTag,
3610
3715
  registrableDomain: () => registrableDomain,
3611
3716
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3717
+ resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3612
3718
  safeOrigin: () => safeOrigin,
3613
3719
  sanitizeTestVariables: () => sanitizeTestVariables,
3614
3720
  selectorCandidatesForStep: () => selectorCandidatesForStep,
3615
3721
  selectorForValue: () => selectorForValue,
3722
+ stepAppliesToPlatform: () => stepAppliesToPlatform,
3723
+ stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
3724
+ stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
3725
+ stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
3616
3726
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
3617
3727
  stripLoginScaffolding: () => stripLoginScaffolding,
3618
3728
  summarizeHealth: () => summarizeHealth,
@@ -3626,6 +3736,7 @@ var init_dist = __esm({
3626
3736
  init_types();
3627
3737
  init_constants();
3628
3738
  init_mobile_test_generation();
3739
+ init_mobile_cross_platform();
3629
3740
  init_mobile_deeplink();
3630
3741
  init_plans();
3631
3742
  init_audit_pricing();
@@ -66880,9 +66991,9 @@ ${statePacket}` },
66880
66991
  if (claimedVerified && !verified) {
66881
66992
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
66882
66993
  }
66883
- const platformTag = platform3 === "IOS" ? "@ios" : "@android";
66994
+ const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
66884
66995
  const reviewTag = verified ? "@verified" : "@needs-review";
66885
- const tags = ["@discovered", "@mobile", platformTag, reviewTag];
66996
+ const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
66886
66997
  const test = {
66887
66998
  name: compiled.name,
66888
66999
  description: recording.finish?.description ?? compiled.description,
@@ -328118,7 +328229,7 @@ var require_package4 = __commonJS({
328118
328229
  "package.json"(exports2, module2) {
328119
328230
  module2.exports = {
328120
328231
  name: "@validate.qa/runner",
328121
- version: "1.0.16",
328232
+ version: "1.0.17",
328122
328233
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328123
328234
  bin: {
328124
328235
  "validate-runner": "dist/cli.js"
@@ -329659,6 +329770,7 @@ async function startMobileNetworkCapture(options) {
329659
329770
 
329660
329771
  // src/services/appium-executor.ts
329661
329772
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
329773
+ var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
329662
329774
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
329663
329775
  var WD_ACTION_TIMEOUT_MS = 3e4;
329664
329776
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
@@ -329987,6 +330099,11 @@ function boundsFromStep(step) {
329987
330099
  }
329988
330100
  return null;
329989
330101
  }
330102
+ function boundsForRun(step, platform3, isCrossPlatform) {
330103
+ if (isCrossPlatform) return null;
330104
+ if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
330105
+ return boundsFromStep(step);
330106
+ }
329990
330107
  function locatorStrategyForValue(value) {
329991
330108
  const trimmed = value?.trim();
329992
330109
  if (!trimmed) {
@@ -330006,19 +330123,21 @@ function locatorStrategyForValue(value) {
330006
330123
  { using: "id", value: trimmed }
330007
330124
  ];
330008
330125
  }
330009
- function buildLocatorStrategies(target) {
330126
+ function buildLocatorStrategies(target, platform3) {
330010
330127
  if (!target) return [];
330011
330128
  const strategies = [];
330012
- const bundle = target.locatorBundle;
330129
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330013
330130
  if (bundle) {
330014
330131
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330015
330132
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330016
330133
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
330017
330134
  if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
330018
330135
  }
330019
- strategies.push(...locatorStrategyForValue(target.primary));
330020
- for (const fallback of target.fallbacks ?? []) {
330021
- strategies.push(...locatorStrategyForValue(fallback));
330136
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330137
+ strategies.push(...locatorStrategyForValue(target.primary));
330138
+ for (const fallback of target.fallbacks ?? []) {
330139
+ strategies.push(...locatorStrategyForValue(fallback));
330140
+ }
330022
330141
  }
330023
330142
  return strategies.filter(
330024
330143
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
@@ -330044,10 +330163,10 @@ function isIdentityStrategy(strategy) {
330044
330163
  if (strategy.using === "xpath") return strategy.value.includes("@");
330045
330164
  return false;
330046
330165
  }
330047
- function buildIdentityLocatorStrategies(target) {
330166
+ function buildIdentityLocatorStrategies(target, platform3) {
330048
330167
  if (!target) return [];
330049
330168
  const strategies = [];
330050
- const bundle = target.locatorBundle;
330169
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330051
330170
  if (bundle) {
330052
330171
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330053
330172
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
@@ -330057,9 +330176,11 @@ function buildIdentityLocatorStrategies(target) {
330057
330176
  }
330058
330177
  if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330059
330178
  }
330060
- for (const value of [target.primary, ...target.fallbacks ?? []]) {
330061
- for (const strategy of locatorStrategyForValue(value)) {
330062
- if (isIdentityStrategy(strategy)) strategies.push(strategy);
330179
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330180
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330181
+ for (const strategy of locatorStrategyForValue(value)) {
330182
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330183
+ }
330063
330184
  }
330064
330185
  }
330065
330186
  return dedupeStrategies(strategies);
@@ -330072,7 +330193,7 @@ function readStepTimeout(step) {
330072
330193
  return void 0;
330073
330194
  }
330074
330195
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330075
- const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
330196
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
330076
330197
  if (strategies.length === 0) {
330077
330198
  throw new Error(
330078
330199
  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"
@@ -330174,18 +330295,18 @@ function shouldAbortAfterStepFailure(action) {
330174
330295
  return false;
330175
330296
  }
330176
330297
  }
330177
- async function executeTap(sessionId, step) {
330178
- const bounds = boundsFromStep(step);
330298
+ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
330299
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330179
330300
  if (step.target) {
330180
330301
  try {
330181
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330302
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330182
330303
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
330183
330304
  method: "POST",
330184
330305
  body: "{}"
330185
330306
  });
330186
330307
  return;
330187
330308
  } catch (error2) {
330188
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330309
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330189
330310
  }
330190
330311
  }
330191
330312
  if (!bounds) {
@@ -330196,7 +330317,7 @@ async function executeTap(sessionId, step) {
330196
330317
  async function executeType(sessionId, step, context) {
330197
330318
  if (step.value === void 0 || step.value === null || step.value === "") return;
330198
330319
  const value = String(step.value);
330199
- const bounds = boundsFromStep(step);
330320
+ const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
330200
330321
  let elementId = null;
330201
330322
  let lastInputError = null;
330202
330323
  let tappedBounds = false;
@@ -330213,12 +330334,12 @@ async function executeType(sessionId, step, context) {
330213
330334
  }
330214
330335
  if (step.target) {
330215
330336
  try {
330216
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330337
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
330217
330338
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330218
330339
  if (!lastInputError) return;
330219
330340
  elementId = null;
330220
330341
  } catch (error2) {
330221
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330342
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
330222
330343
  lastInputError = error2;
330223
330344
  }
330224
330345
  }
@@ -330266,14 +330387,14 @@ async function executeType(sessionId, step, context) {
330266
330387
  }
330267
330388
  throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
330268
330389
  }
330269
- async function executeClear(sessionId, step) {
330270
- const bounds = boundsFromStep(step);
330390
+ async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
330391
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330271
330392
  let elementId = null;
330272
330393
  if (step.target) {
330273
330394
  try {
330274
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330395
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330275
330396
  } catch (error2) {
330276
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330397
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330277
330398
  }
330278
330399
  }
330279
330400
  if (!elementId && bounds) {
@@ -330342,20 +330463,20 @@ async function executeHome(sessionId, platform3) {
330342
330463
  body: JSON.stringify({ keycode: 3 })
330343
330464
  });
330344
330465
  }
330345
- async function executeWaitForElement(sessionId, step) {
330466
+ async function executeWaitForElement(sessionId, step, platform3) {
330346
330467
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330347
- await findElement(sessionId, step.target, timeout, { identityOnly: true });
330468
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
330348
330469
  }
330349
- async function executeAssertVisible(sessionId, step) {
330350
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330470
+ async function executeAssertVisible(sessionId, step, platform3) {
330471
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330351
330472
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330352
330473
  if (!result?.value) {
330353
330474
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
330354
330475
  }
330355
330476
  }
330356
- async function executeAssertText(sessionId, step) {
330477
+ async function executeAssertText(sessionId, step, platform3) {
330357
330478
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330358
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330479
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330359
330480
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330360
330481
  const actualText = typeof result?.value === "string" ? result.value : "";
330361
330482
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330521,12 +330642,14 @@ async function executeMobileTest(options) {
330521
330642
  }
330522
330643
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330523
330644
  let entryDrift = false;
330645
+ const runPlatform = options.target.platform;
330646
+ const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330524
330647
  const entryStep = options.steps.find(
330525
- (s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
330648
+ (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330526
330649
  );
330527
330650
  if (entryStep) {
330528
330651
  try {
330529
- await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
330652
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
330530
330653
  } catch (err) {
330531
330654
  if (isTransportError(err)) throw err;
330532
330655
  entryDrift = true;
@@ -330540,6 +330663,12 @@ async function executeMobileTest(options) {
330540
330663
  let environmental = null;
330541
330664
  for (const step of options.steps) {
330542
330665
  if (entryDrift) break;
330666
+ if (!stepAppliesToPlatform(step, runPlatform)) {
330667
+ log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
330668
+ stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
330669
+ screenshots.push("");
330670
+ continue;
330671
+ }
330543
330672
  const stepStart = Date.now();
330544
330673
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330545
330674
  let status = "passed";
@@ -330551,17 +330680,18 @@ async function executeMobileTest(options) {
330551
330680
  log2(" App launched (handled by session creation)");
330552
330681
  break;
330553
330682
  case "tap":
330554
- await executeTap(sessionId, step);
330683
+ await executeTap(sessionId, step, runPlatform, isCrossPlatform);
330555
330684
  break;
330556
330685
  case "type":
330557
330686
  await executeType(sessionId, step, {
330558
- platform: options.target.platform,
330687
+ platform: runPlatform,
330559
330688
  deviceId: selectedDevice.id,
330560
- log: log2
330689
+ log: log2,
330690
+ isCrossPlatform
330561
330691
  });
330562
330692
  break;
330563
330693
  case "clear":
330564
- await executeClear(sessionId, step);
330694
+ await executeClear(sessionId, step, runPlatform, isCrossPlatform);
330565
330695
  break;
330566
330696
  case "swipe":
330567
330697
  case "scroll":
@@ -330571,16 +330701,16 @@ async function executeMobileTest(options) {
330571
330701
  await executeBack(sessionId);
330572
330702
  break;
330573
330703
  case "home":
330574
- await executeHome(sessionId, options.target.platform);
330704
+ await executeHome(sessionId, runPlatform);
330575
330705
  break;
330576
330706
  case "waitForElement":
330577
- await executeWaitForElement(sessionId, step);
330707
+ await executeWaitForElement(sessionId, step, runPlatform);
330578
330708
  break;
330579
330709
  case "assertVisible":
330580
- await executeAssertVisible(sessionId, step);
330710
+ await executeAssertVisible(sessionId, step, runPlatform);
330581
330711
  break;
330582
330712
  case "assertText":
330583
- await executeAssertText(sessionId, step);
330713
+ await executeAssertText(sessionId, step, runPlatform);
330584
330714
  break;
330585
330715
  default: {
330586
330716
  const _exhaustive = step.action;
@@ -330617,8 +330747,14 @@ async function executeMobileTest(options) {
330617
330747
  break;
330618
330748
  }
330619
330749
  }
330620
- const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330621
- const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330750
+ const executedResults = stepResults.filter((r) => r.status !== "skipped");
330751
+ const verifyOrders = new Set(
330752
+ options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330753
+ );
330754
+ const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330755
+ const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
330756
+ const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330757
+ const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null) ?? (crossPlatformUnverified ? `Cross-platform run on ${runPlatform} verified no outcome \u2014 every assertion/wait step was skipped or unresolved. Author a ${runPlatform} assertion or resolve the divergence.` : null);
330622
330758
  await stopCapture();
330623
330759
  await stopRecording();
330624
330760
  return {
@@ -330752,6 +330888,184 @@ async function createMobileDiscoverySession(target, options) {
330752
330888
  }
330753
330889
  };
330754
330890
  }
330891
+ async function readElementAttribute(sessionId, elementId, name) {
330892
+ try {
330893
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
330894
+ const value = result?.value;
330895
+ if (typeof value !== "string") return void 0;
330896
+ const trimmed = value.trim();
330897
+ return trimmed.length > 0 ? trimmed : void 0;
330898
+ } catch (err) {
330899
+ if (isTransportError(err)) throw err;
330900
+ return void 0;
330901
+ }
330902
+ }
330903
+ function harvestFallbackXpath(platform3, attrs) {
330904
+ if (platform3 === "IOS") {
330905
+ if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
330906
+ const text = attrs.label ?? attrs.value;
330907
+ if (text) return identityTextXpath(text);
330908
+ return "//*";
330909
+ }
330910
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
330911
+ if (attrs.text) return identityTextXpath(attrs.text);
330912
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
330913
+ return "//*";
330914
+ }
330915
+ async function readNativeElementBundle(sessionId, elementId, platform3) {
330916
+ const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
330917
+ if (platform3 === "IOS") {
330918
+ const name = await readElementAttribute(sessionId, elementId, "name");
330919
+ const label = await readElementAttribute(sessionId, elementId, "label");
330920
+ const value = await readElementAttribute(sessionId, elementId, "value");
330921
+ const text2 = label ?? value;
330922
+ if (!name && !text2) return null;
330923
+ const bundle2 = {
330924
+ xpath: harvestFallbackXpath("IOS", { name, label, value }),
330925
+ className
330926
+ };
330927
+ if (name) bundle2.accessibilityId = name;
330928
+ if (text2) bundle2.text = text2;
330929
+ return bundle2;
330930
+ }
330931
+ const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
330932
+ const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
330933
+ const text = await readElementAttribute(sessionId, elementId, "text");
330934
+ if (!resourceId && !contentDesc && !text) return null;
330935
+ const bundle = {
330936
+ xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
330937
+ className
330938
+ };
330939
+ if (contentDesc) bundle.accessibilityId = contentDesc;
330940
+ if (resourceId) bundle.resourceId = resourceId;
330941
+ if (text) bundle.text = text;
330942
+ if (contentDesc) bundle.contentDesc = contentDesc;
330943
+ return bundle;
330944
+ }
330945
+ function isStateAdvancingAction(action) {
330946
+ return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
330947
+ }
330948
+ function stepNeedsLocator(action) {
330949
+ return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
330950
+ }
330951
+ async function driveHarvestStep(sessionId, step, elementId, platform3) {
330952
+ switch (step.action) {
330953
+ case "tap":
330954
+ if (!elementId) return false;
330955
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
330956
+ return true;
330957
+ case "type": {
330958
+ if (step.value === void 0 || step.value === null || step.value === "") return true;
330959
+ if (!elementId) return false;
330960
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
330961
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330962
+ const err = await trySendElementValue(sessionId, elementId, String(step.value));
330963
+ return !err;
330964
+ }
330965
+ case "clear":
330966
+ if (!elementId) return false;
330967
+ await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
330968
+ return true;
330969
+ case "swipe":
330970
+ case "scroll":
330971
+ await executeSwipe(sessionId, step);
330972
+ return true;
330973
+ case "back":
330974
+ await executeBack(sessionId);
330975
+ return true;
330976
+ case "home":
330977
+ await executeHome(sessionId, platform3);
330978
+ return true;
330979
+ case "launchApp":
330980
+ case "waitForElement":
330981
+ case "assertVisible":
330982
+ case "assertText":
330983
+ return true;
330984
+ }
330985
+ }
330986
+ async function harvestPlatformLocators(options) {
330987
+ const platform3 = options.target.platform;
330988
+ const logLines = [];
330989
+ const log2 = (msg) => {
330990
+ logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330991
+ };
330992
+ const bundles = {};
330993
+ const divergentOrders = [];
330994
+ let reachedOrder = null;
330995
+ let environmentalError;
330996
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
330997
+ const session = await createMobileDiscoverySession({
330998
+ appId: options.target.appId,
330999
+ platform: platform3,
331000
+ platformVersion: options.target.platformVersion,
331001
+ recordedDeviceId: options.target.recordedDeviceId,
331002
+ assignedDeviceId: options.target.assignedDeviceId,
331003
+ launchMode: "LAUNCH_APP"
331004
+ });
331005
+ options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
331006
+ try {
331007
+ await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
331008
+ if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
331009
+ try {
331010
+ await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
331011
+ } catch (err) {
331012
+ log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
331013
+ }
331014
+ }
331015
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331016
+ for (const step of options.steps) {
331017
+ if (step.action === "launchApp") continue;
331018
+ if (!stepAppliesToPlatform(step, platform3)) continue;
331019
+ let elementId = null;
331020
+ const identityStrategies = buildIdentityLocatorStrategies(step.target);
331021
+ if (identityStrategies.length > 0 && step.target) {
331022
+ try {
331023
+ elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
331024
+ } catch (err) {
331025
+ if (isTransportError(err)) throw err;
331026
+ elementId = null;
331027
+ }
331028
+ }
331029
+ if (elementId) {
331030
+ const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
331031
+ if (bundle) {
331032
+ bundles[step.order] = bundle;
331033
+ log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
331034
+ } else {
331035
+ divergentOrders.push(step.order);
331036
+ log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
331037
+ }
331038
+ } else if (stepNeedsLocator(step.action)) {
331039
+ divergentOrders.push(step.order);
331040
+ log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
331041
+ }
331042
+ const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
331043
+ if (!advanced && isStateAdvancingAction(step.action)) {
331044
+ log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
331045
+ break;
331046
+ }
331047
+ reachedOrder = step.order;
331048
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
331049
+ }
331050
+ } catch (err) {
331051
+ environmentalError = err instanceof Error ? err.message : String(err);
331052
+ log2(` \u2717 Harvest aborted: ${environmentalError}`);
331053
+ } finally {
331054
+ options.onSessionEnding?.(session.deviceId);
331055
+ await session.stop().catch(() => {
331056
+ });
331057
+ }
331058
+ return {
331059
+ platform: platform3,
331060
+ bundles,
331061
+ divergentOrders,
331062
+ reachedOrder,
331063
+ deviceId: session.deviceId,
331064
+ deviceName: session.deviceName,
331065
+ logs: logLines.join("\n"),
331066
+ ...environmentalError ? { environmentalError } : {}
331067
+ };
331068
+ }
330755
331069
 
330756
331070
  // src/mobile-device-queue.ts
330757
331071
  function isNativeMobileDiscoveryContext(ctx) {
@@ -334009,6 +334323,102 @@ ${finalVerifyResult.logs ?? ""}`;
334009
334323
  log.fail("ERROR - No discovery context");
334010
334324
  return;
334011
334325
  }
334326
+ const locatorCtx = ctx;
334327
+ if (locatorCtx.mode === "locator-discovery") {
334328
+ const harvestTarget = locatorCtx.target;
334329
+ const targetTestCaseId = locatorCtx.targetTestCaseId;
334330
+ const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
334331
+ if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
334332
+ await postResult(opts.serverUrl, headers, run2.runId, {
334333
+ status: "ERROR",
334334
+ error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
334335
+ });
334336
+ log.fail("ERROR - malformed locator-discovery context");
334337
+ return;
334338
+ }
334339
+ if (getBridgeState().hasActiveSession) {
334340
+ await postResult(opts.serverUrl, headers, run2.runId, {
334341
+ status: "ERROR",
334342
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
334343
+ });
334344
+ log.fail("ERROR - device busy (recording) for locator discovery");
334345
+ return;
334346
+ }
334347
+ const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334348
+ liveBrowserHandle.patch({
334349
+ mode: "discovery",
334350
+ testName: "Cross-platform locator discovery",
334351
+ note: `${platform3} locator discovery`,
334352
+ surface: "mobile",
334353
+ mobilePlatform: platform3,
334354
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334355
+ ...leasedName ? { deviceName: leasedName } : {}
334356
+ });
334357
+ requestLiveBrowserHeartbeat(true);
334358
+ log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
334359
+ setExecutorBusy(true);
334360
+ try {
334361
+ const harvest = await harvestPlatformLocators({
334362
+ steps: locatorCtx.sourceSteps ?? [],
334363
+ target: {
334364
+ appId: harvestTarget.appId,
334365
+ platform: platform3,
334366
+ platformVersion: harvestTarget.platformVersion,
334367
+ recordedDeviceId: harvestTarget.recordedDeviceId,
334368
+ assignedDeviceId,
334369
+ launchMode: harvestTarget.launchMode,
334370
+ deeplink: harvestTarget.deeplink
334371
+ },
334372
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334373
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334374
+ const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
334375
+ liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
334376
+ requestLiveBrowserHeartbeat(true);
334377
+ },
334378
+ onSessionEnding: (deviceId) => {
334379
+ detachMirrorSession(deviceId);
334380
+ }
334381
+ });
334382
+ let queuedRunId = null;
334383
+ let resultPosted = false;
334384
+ try {
334385
+ const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
334386
+ method: "POST",
334387
+ headers: { ...headers, "Content-Type": "application/json" },
334388
+ body: JSON.stringify({
334389
+ bundles: harvest.bundles,
334390
+ divergentOrders: harvest.divergentOrders,
334391
+ reachedOrder: harvest.reachedOrder,
334392
+ logs: harvest.logs,
334393
+ ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
334394
+ }),
334395
+ signal: AbortSignal.timeout(3e4)
334396
+ });
334397
+ if (res.ok) {
334398
+ resultPosted = true;
334399
+ const data = await res.json().catch(() => null);
334400
+ queuedRunId = data?.queuedRunId ?? null;
334401
+ } else {
334402
+ log.warn(`[locator-discovery] result POST returned ${res.status}`);
334403
+ }
334404
+ } catch (postErr) {
334405
+ log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
334406
+ }
334407
+ const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
334408
+ await postResult(opts.serverUrl, headers, run2.runId, {
334409
+ status: passed ? "PASSED" : "ERROR",
334410
+ error: !resultPosted ? "CONFIG_ISSUE: failed to persist harvested cross-platform locators (result POST did not succeed); retriable." : harvest.environmentalError ? `CONFIG_ISSUE: ${harvest.environmentalError}` : harvest.reachedOrder == null ? "CONFIG_ISSUE: locator discovery could not reach any step on the target platform." : void 0,
334411
+ logs: harvest.logs,
334412
+ duration: Date.now() - startTime
334413
+ });
334414
+ log[passed ? "ok" : "fail"](
334415
+ `Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
334416
+ );
334417
+ } finally {
334418
+ setExecutorBusy(false);
334419
+ }
334420
+ return;
334421
+ }
334012
334422
  log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
334013
334423
  const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
334014
334424
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
@@ -334722,7 +335132,11 @@ ${finalVerifyResult.logs ?? ""}`;
334722
335132
  recordedDeviceId: target.recordedDeviceId,
334723
335133
  assignedDeviceId,
334724
335134
  launchMode: target.launchMode,
334725
- deeplink: target.deeplink
335135
+ deeplink: target.deeplink,
335136
+ // Authoring platform (test's session medium). When it differs from
335137
+ // target.platform this is a cross-platform run: no blind coordinate
335138
+ // taps on foreign geometry, and the run must verify an outcome.
335139
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
334726
335140
  },
334727
335141
  // Stream this run to its OWN per-device dashboard tile (keyed by the
334728
335142
  // leased device id) so N parallel Appium runs each show a live mirror.