@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.mjs CHANGED
@@ -1861,6 +1861,102 @@ var init_mobile_test_generation = __esm({
1861
1861
  }
1862
1862
  });
1863
1863
 
1864
+ // ../shared/dist/mobile-cross-platform.js
1865
+ function isMobilePlatform(value) {
1866
+ return value === "IOS" || value === "ANDROID";
1867
+ }
1868
+ function mediumForMobilePlatform(platform3) {
1869
+ return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
1870
+ }
1871
+ function mobilePlatformFromMedium(medium) {
1872
+ if (medium === "IOS_NATIVE")
1873
+ return "IOS";
1874
+ if (medium === "ANDROID_NATIVE")
1875
+ return "ANDROID";
1876
+ return null;
1877
+ }
1878
+ function otherMobilePlatform(platform3) {
1879
+ return platform3 === "IOS" ? "ANDROID" : "IOS";
1880
+ }
1881
+ function platformTag(platform3) {
1882
+ return platform3 === "IOS" ? "@ios" : "@android";
1883
+ }
1884
+ function resolvePlatformLocatorBundle(target, platform3) {
1885
+ if (!target)
1886
+ return void 0;
1887
+ if (platform3) {
1888
+ const overlay = target.platformLocators?.[platform3];
1889
+ if (overlay)
1890
+ return overlay;
1891
+ }
1892
+ return target.locatorBundle;
1893
+ }
1894
+ function hasPlatformLocator(target, platform3) {
1895
+ return Boolean(target?.platformLocators?.[platform3]);
1896
+ }
1897
+ function hasCrossPlatformIdentity(bundle) {
1898
+ if (!bundle)
1899
+ return false;
1900
+ return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
1901
+ }
1902
+ function stepIsDivergentForPlatform(step, platform3) {
1903
+ return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
1904
+ }
1905
+ function stepAppliesToPlatform(step, platform3) {
1906
+ const platforms = step.platforms;
1907
+ if (!platforms || platforms.length === 0)
1908
+ return true;
1909
+ if (!platform3)
1910
+ return true;
1911
+ return platforms.includes(platform3);
1912
+ }
1913
+ function stepsMissingPlatformLocators(steps, platform3) {
1914
+ return steps.filter((step) => {
1915
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1916
+ return false;
1917
+ if (!stepAppliesToPlatform(step, platform3))
1918
+ return false;
1919
+ if (hasPlatformLocator(step.target, platform3))
1920
+ return false;
1921
+ if (stepIsDivergentForPlatform(step, platform3))
1922
+ return false;
1923
+ return hasCrossPlatformIdentity(step.target?.locatorBundle);
1924
+ });
1925
+ }
1926
+ function stepsNeedingReviewForPlatform(steps, platform3) {
1927
+ return steps.filter((step) => {
1928
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1929
+ return false;
1930
+ if (!stepAppliesToPlatform(step, platform3))
1931
+ return false;
1932
+ if (hasPlatformLocator(step.target, platform3))
1933
+ return false;
1934
+ if (stepIsDivergentForPlatform(step, platform3))
1935
+ return true;
1936
+ return !hasCrossPlatformIdentity(step.target?.locatorBundle);
1937
+ });
1938
+ }
1939
+ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
1940
+ if (runPlatform === authoringPlatform)
1941
+ return true;
1942
+ return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
1943
+ }
1944
+ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
1945
+ var init_mobile_cross_platform = __esm({
1946
+ "../shared/dist/mobile-cross-platform.js"() {
1947
+ "use strict";
1948
+ MOBILE_PLATFORMS = ["IOS", "ANDROID"];
1949
+ LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
1950
+ "tap",
1951
+ "type",
1952
+ "clear",
1953
+ "waitForElement",
1954
+ "assertVisible",
1955
+ "assertText"
1956
+ ]);
1957
+ }
1958
+ });
1959
+
1864
1960
  // ../shared/dist/mobile-deeplink.js
1865
1961
  function isSafeDeepLink(value) {
1866
1962
  if (typeof value !== "string")
@@ -3539,6 +3635,7 @@ __export(dist_exports, {
3539
3635
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
3540
3636
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
3541
3637
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
3638
+ MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
3542
3639
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
3543
3640
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
3544
3641
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
@@ -3592,6 +3689,8 @@ __export(dist_exports, {
3592
3689
  getPlanDefinition: () => getPlanDefinition,
3593
3690
  getPlanLabel: () => getPlanLabel,
3594
3691
  getTrialDaysRemaining: () => getTrialDaysRemaining,
3692
+ hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
3693
+ hasPlatformLocator: () => hasPlatformLocator,
3595
3694
  hostOfOrigin: () => hostOfOrigin,
3596
3695
  isAddonType: () => isAddonType,
3597
3696
  isApiTest: () => isApiTest,
@@ -3601,23 +3700,34 @@ __export(dist_exports, {
3601
3700
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3602
3701
  isDbPricingPlan: () => isDbPricingPlan,
3603
3702
  isEnvironmentalError: () => isEnvironmentalError,
3703
+ isMobilePlatform: () => isMobilePlatform,
3604
3704
  isPricingPlan: () => isPricingPlan,
3605
3705
  isRunnableStep: () => isRunnableStep,
3606
3706
  isSafeDeepLink: () => isSafeDeepLink,
3607
3707
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
3708
+ isTestReadyForPlatform: () => isTestReadyForPlatform,
3608
3709
  isTrialExpired: () => isTrialExpired,
3609
3710
  isUIVariant: () => isUIVariant,
3610
3711
  isValidMobileAppId: () => isValidMobileAppId,
3712
+ mediumForMobilePlatform: () => mediumForMobilePlatform,
3713
+ mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3611
3714
  normalizeControlName: () => normalizeControlName,
3715
+ otherMobilePlatform: () => otherMobilePlatform,
3612
3716
  parenDepthDelta: () => parenDepthDelta,
3613
3717
  planHasBillingPortal: () => planHasBillingPortal,
3614
3718
  planRequiresCheckout: () => planRequiresCheckout,
3719
+ platformTag: () => platformTag,
3615
3720
  registrableDomain: () => registrableDomain,
3616
3721
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3722
+ resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3617
3723
  safeOrigin: () => safeOrigin,
3618
3724
  sanitizeTestVariables: () => sanitizeTestVariables,
3619
3725
  selectorCandidatesForStep: () => selectorCandidatesForStep,
3620
3726
  selectorForValue: () => selectorForValue,
3727
+ stepAppliesToPlatform: () => stepAppliesToPlatform,
3728
+ stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
3729
+ stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
3730
+ stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
3621
3731
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
3622
3732
  stripLoginScaffolding: () => stripLoginScaffolding,
3623
3733
  summarizeHealth: () => summarizeHealth,
@@ -3631,6 +3741,7 @@ var init_dist = __esm({
3631
3741
  init_types();
3632
3742
  init_constants();
3633
3743
  init_mobile_test_generation();
3744
+ init_mobile_cross_platform();
3634
3745
  init_mobile_deeplink();
3635
3746
  init_plans();
3636
3747
  init_audit_pricing();
@@ -66885,9 +66996,9 @@ ${statePacket}` },
66885
66996
  if (claimedVerified && !verified) {
66886
66997
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
66887
66998
  }
66888
- const platformTag = platform3 === "IOS" ? "@ios" : "@android";
66999
+ const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
66889
67000
  const reviewTag = verified ? "@verified" : "@needs-review";
66890
- const tags = ["@discovered", "@mobile", platformTag, reviewTag];
67001
+ const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
66891
67002
  const test = {
66892
67003
  name: compiled.name,
66893
67004
  description: recording.finish?.description ?? compiled.description,
@@ -328123,7 +328234,7 @@ var require_package4 = __commonJS({
328123
328234
  "package.json"(exports, module) {
328124
328235
  module.exports = {
328125
328236
  name: "@validate.qa/runner",
328126
- version: "1.0.16",
328237
+ version: "1.0.17",
328127
328238
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328128
328239
  bin: {
328129
328240
  "validate-runner": "dist/cli.js"
@@ -329667,6 +329778,7 @@ async function startMobileNetworkCapture(options) {
329667
329778
 
329668
329779
  // src/services/appium-executor.ts
329669
329780
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
329781
+ var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
329670
329782
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
329671
329783
  var WD_ACTION_TIMEOUT_MS = 3e4;
329672
329784
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
@@ -329995,6 +330107,11 @@ function boundsFromStep(step) {
329995
330107
  }
329996
330108
  return null;
329997
330109
  }
330110
+ function boundsForRun(step, platform3, isCrossPlatform) {
330111
+ if (isCrossPlatform) return null;
330112
+ if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
330113
+ return boundsFromStep(step);
330114
+ }
329998
330115
  function locatorStrategyForValue(value) {
329999
330116
  const trimmed = value?.trim();
330000
330117
  if (!trimmed) {
@@ -330014,19 +330131,21 @@ function locatorStrategyForValue(value) {
330014
330131
  { using: "id", value: trimmed }
330015
330132
  ];
330016
330133
  }
330017
- function buildLocatorStrategies(target) {
330134
+ function buildLocatorStrategies(target, platform3) {
330018
330135
  if (!target) return [];
330019
330136
  const strategies = [];
330020
- const bundle = target.locatorBundle;
330137
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330021
330138
  if (bundle) {
330022
330139
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330023
330140
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330024
330141
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
330025
330142
  if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
330026
330143
  }
330027
- strategies.push(...locatorStrategyForValue(target.primary));
330028
- for (const fallback of target.fallbacks ?? []) {
330029
- strategies.push(...locatorStrategyForValue(fallback));
330144
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330145
+ strategies.push(...locatorStrategyForValue(target.primary));
330146
+ for (const fallback of target.fallbacks ?? []) {
330147
+ strategies.push(...locatorStrategyForValue(fallback));
330148
+ }
330030
330149
  }
330031
330150
  return strategies.filter(
330032
330151
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
@@ -330052,10 +330171,10 @@ function isIdentityStrategy(strategy) {
330052
330171
  if (strategy.using === "xpath") return strategy.value.includes("@");
330053
330172
  return false;
330054
330173
  }
330055
- function buildIdentityLocatorStrategies(target) {
330174
+ function buildIdentityLocatorStrategies(target, platform3) {
330056
330175
  if (!target) return [];
330057
330176
  const strategies = [];
330058
- const bundle = target.locatorBundle;
330177
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330059
330178
  if (bundle) {
330060
330179
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330061
330180
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
@@ -330065,9 +330184,11 @@ function buildIdentityLocatorStrategies(target) {
330065
330184
  }
330066
330185
  if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330067
330186
  }
330068
- for (const value of [target.primary, ...target.fallbacks ?? []]) {
330069
- for (const strategy of locatorStrategyForValue(value)) {
330070
- if (isIdentityStrategy(strategy)) strategies.push(strategy);
330187
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330188
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330189
+ for (const strategy of locatorStrategyForValue(value)) {
330190
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330191
+ }
330071
330192
  }
330072
330193
  }
330073
330194
  return dedupeStrategies(strategies);
@@ -330080,7 +330201,7 @@ function readStepTimeout(step) {
330080
330201
  return void 0;
330081
330202
  }
330082
330203
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330083
- const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
330204
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
330084
330205
  if (strategies.length === 0) {
330085
330206
  throw new Error(
330086
330207
  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"
@@ -330182,18 +330303,18 @@ function shouldAbortAfterStepFailure(action) {
330182
330303
  return false;
330183
330304
  }
330184
330305
  }
330185
- async function executeTap(sessionId, step) {
330186
- const bounds = boundsFromStep(step);
330306
+ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
330307
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330187
330308
  if (step.target) {
330188
330309
  try {
330189
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330310
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330190
330311
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
330191
330312
  method: "POST",
330192
330313
  body: "{}"
330193
330314
  });
330194
330315
  return;
330195
330316
  } catch (error2) {
330196
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330317
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330197
330318
  }
330198
330319
  }
330199
330320
  if (!bounds) {
@@ -330204,7 +330325,7 @@ async function executeTap(sessionId, step) {
330204
330325
  async function executeType(sessionId, step, context) {
330205
330326
  if (step.value === void 0 || step.value === null || step.value === "") return;
330206
330327
  const value = String(step.value);
330207
- const bounds = boundsFromStep(step);
330328
+ const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
330208
330329
  let elementId = null;
330209
330330
  let lastInputError = null;
330210
330331
  let tappedBounds = false;
@@ -330221,12 +330342,12 @@ async function executeType(sessionId, step, context) {
330221
330342
  }
330222
330343
  if (step.target) {
330223
330344
  try {
330224
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330345
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
330225
330346
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330226
330347
  if (!lastInputError) return;
330227
330348
  elementId = null;
330228
330349
  } catch (error2) {
330229
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330350
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
330230
330351
  lastInputError = error2;
330231
330352
  }
330232
330353
  }
@@ -330274,14 +330395,14 @@ async function executeType(sessionId, step, context) {
330274
330395
  }
330275
330396
  throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
330276
330397
  }
330277
- async function executeClear(sessionId, step) {
330278
- const bounds = boundsFromStep(step);
330398
+ async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
330399
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330279
330400
  let elementId = null;
330280
330401
  if (step.target) {
330281
330402
  try {
330282
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330403
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330283
330404
  } catch (error2) {
330284
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330405
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330285
330406
  }
330286
330407
  }
330287
330408
  if (!elementId && bounds) {
@@ -330350,20 +330471,20 @@ async function executeHome(sessionId, platform3) {
330350
330471
  body: JSON.stringify({ keycode: 3 })
330351
330472
  });
330352
330473
  }
330353
- async function executeWaitForElement(sessionId, step) {
330474
+ async function executeWaitForElement(sessionId, step, platform3) {
330354
330475
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330355
- await findElement(sessionId, step.target, timeout, { identityOnly: true });
330476
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
330356
330477
  }
330357
- async function executeAssertVisible(sessionId, step) {
330358
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330478
+ async function executeAssertVisible(sessionId, step, platform3) {
330479
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330359
330480
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330360
330481
  if (!result?.value) {
330361
330482
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
330362
330483
  }
330363
330484
  }
330364
- async function executeAssertText(sessionId, step) {
330485
+ async function executeAssertText(sessionId, step, platform3) {
330365
330486
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330366
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330487
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330367
330488
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330368
330489
  const actualText = typeof result?.value === "string" ? result.value : "";
330369
330490
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330529,12 +330650,14 @@ async function executeMobileTest(options) {
330529
330650
  }
330530
330651
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330531
330652
  let entryDrift = false;
330653
+ const runPlatform = options.target.platform;
330654
+ const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330532
330655
  const entryStep = options.steps.find(
330533
- (s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
330656
+ (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330534
330657
  );
330535
330658
  if (entryStep) {
330536
330659
  try {
330537
- await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
330660
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
330538
330661
  } catch (err) {
330539
330662
  if (isTransportError(err)) throw err;
330540
330663
  entryDrift = true;
@@ -330548,6 +330671,12 @@ async function executeMobileTest(options) {
330548
330671
  let environmental = null;
330549
330672
  for (const step of options.steps) {
330550
330673
  if (entryDrift) break;
330674
+ if (!stepAppliesToPlatform(step, runPlatform)) {
330675
+ log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
330676
+ stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
330677
+ screenshots.push("");
330678
+ continue;
330679
+ }
330551
330680
  const stepStart = Date.now();
330552
330681
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330553
330682
  let status = "passed";
@@ -330559,17 +330688,18 @@ async function executeMobileTest(options) {
330559
330688
  log2(" App launched (handled by session creation)");
330560
330689
  break;
330561
330690
  case "tap":
330562
- await executeTap(sessionId, step);
330691
+ await executeTap(sessionId, step, runPlatform, isCrossPlatform);
330563
330692
  break;
330564
330693
  case "type":
330565
330694
  await executeType(sessionId, step, {
330566
- platform: options.target.platform,
330695
+ platform: runPlatform,
330567
330696
  deviceId: selectedDevice.id,
330568
- log: log2
330697
+ log: log2,
330698
+ isCrossPlatform
330569
330699
  });
330570
330700
  break;
330571
330701
  case "clear":
330572
- await executeClear(sessionId, step);
330702
+ await executeClear(sessionId, step, runPlatform, isCrossPlatform);
330573
330703
  break;
330574
330704
  case "swipe":
330575
330705
  case "scroll":
@@ -330579,16 +330709,16 @@ async function executeMobileTest(options) {
330579
330709
  await executeBack(sessionId);
330580
330710
  break;
330581
330711
  case "home":
330582
- await executeHome(sessionId, options.target.platform);
330712
+ await executeHome(sessionId, runPlatform);
330583
330713
  break;
330584
330714
  case "waitForElement":
330585
- await executeWaitForElement(sessionId, step);
330715
+ await executeWaitForElement(sessionId, step, runPlatform);
330586
330716
  break;
330587
330717
  case "assertVisible":
330588
- await executeAssertVisible(sessionId, step);
330718
+ await executeAssertVisible(sessionId, step, runPlatform);
330589
330719
  break;
330590
330720
  case "assertText":
330591
- await executeAssertText(sessionId, step);
330721
+ await executeAssertText(sessionId, step, runPlatform);
330592
330722
  break;
330593
330723
  default: {
330594
330724
  const _exhaustive = step.action;
@@ -330625,8 +330755,14 @@ async function executeMobileTest(options) {
330625
330755
  break;
330626
330756
  }
330627
330757
  }
330628
- const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330629
- const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330758
+ const executedResults = stepResults.filter((r) => r.status !== "skipped");
330759
+ const verifyOrders = new Set(
330760
+ options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330761
+ );
330762
+ const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330763
+ const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
330764
+ const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330765
+ 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);
330630
330766
  await stopCapture();
330631
330767
  await stopRecording();
330632
330768
  return {
@@ -330760,6 +330896,184 @@ async function createMobileDiscoverySession(target, options) {
330760
330896
  }
330761
330897
  };
330762
330898
  }
330899
+ async function readElementAttribute(sessionId, elementId, name) {
330900
+ try {
330901
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
330902
+ const value = result?.value;
330903
+ if (typeof value !== "string") return void 0;
330904
+ const trimmed = value.trim();
330905
+ return trimmed.length > 0 ? trimmed : void 0;
330906
+ } catch (err) {
330907
+ if (isTransportError(err)) throw err;
330908
+ return void 0;
330909
+ }
330910
+ }
330911
+ function harvestFallbackXpath(platform3, attrs) {
330912
+ if (platform3 === "IOS") {
330913
+ if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
330914
+ const text = attrs.label ?? attrs.value;
330915
+ if (text) return identityTextXpath(text);
330916
+ return "//*";
330917
+ }
330918
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
330919
+ if (attrs.text) return identityTextXpath(attrs.text);
330920
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
330921
+ return "//*";
330922
+ }
330923
+ async function readNativeElementBundle(sessionId, elementId, platform3) {
330924
+ const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
330925
+ if (platform3 === "IOS") {
330926
+ const name = await readElementAttribute(sessionId, elementId, "name");
330927
+ const label = await readElementAttribute(sessionId, elementId, "label");
330928
+ const value = await readElementAttribute(sessionId, elementId, "value");
330929
+ const text2 = label ?? value;
330930
+ if (!name && !text2) return null;
330931
+ const bundle2 = {
330932
+ xpath: harvestFallbackXpath("IOS", { name, label, value }),
330933
+ className
330934
+ };
330935
+ if (name) bundle2.accessibilityId = name;
330936
+ if (text2) bundle2.text = text2;
330937
+ return bundle2;
330938
+ }
330939
+ const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
330940
+ const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
330941
+ const text = await readElementAttribute(sessionId, elementId, "text");
330942
+ if (!resourceId && !contentDesc && !text) return null;
330943
+ const bundle = {
330944
+ xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
330945
+ className
330946
+ };
330947
+ if (contentDesc) bundle.accessibilityId = contentDesc;
330948
+ if (resourceId) bundle.resourceId = resourceId;
330949
+ if (text) bundle.text = text;
330950
+ if (contentDesc) bundle.contentDesc = contentDesc;
330951
+ return bundle;
330952
+ }
330953
+ function isStateAdvancingAction(action) {
330954
+ return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
330955
+ }
330956
+ function stepNeedsLocator(action) {
330957
+ return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
330958
+ }
330959
+ async function driveHarvestStep(sessionId, step, elementId, platform3) {
330960
+ switch (step.action) {
330961
+ case "tap":
330962
+ if (!elementId) return false;
330963
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
330964
+ return true;
330965
+ case "type": {
330966
+ if (step.value === void 0 || step.value === null || step.value === "") return true;
330967
+ if (!elementId) return false;
330968
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
330969
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330970
+ const err = await trySendElementValue(sessionId, elementId, String(step.value));
330971
+ return !err;
330972
+ }
330973
+ case "clear":
330974
+ if (!elementId) return false;
330975
+ await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
330976
+ return true;
330977
+ case "swipe":
330978
+ case "scroll":
330979
+ await executeSwipe(sessionId, step);
330980
+ return true;
330981
+ case "back":
330982
+ await executeBack(sessionId);
330983
+ return true;
330984
+ case "home":
330985
+ await executeHome(sessionId, platform3);
330986
+ return true;
330987
+ case "launchApp":
330988
+ case "waitForElement":
330989
+ case "assertVisible":
330990
+ case "assertText":
330991
+ return true;
330992
+ }
330993
+ }
330994
+ async function harvestPlatformLocators(options) {
330995
+ const platform3 = options.target.platform;
330996
+ const logLines = [];
330997
+ const log2 = (msg) => {
330998
+ logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330999
+ };
331000
+ const bundles = {};
331001
+ const divergentOrders = [];
331002
+ let reachedOrder = null;
331003
+ let environmentalError;
331004
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
331005
+ const session = await createMobileDiscoverySession({
331006
+ appId: options.target.appId,
331007
+ platform: platform3,
331008
+ platformVersion: options.target.platformVersion,
331009
+ recordedDeviceId: options.target.recordedDeviceId,
331010
+ assignedDeviceId: options.target.assignedDeviceId,
331011
+ launchMode: "LAUNCH_APP"
331012
+ });
331013
+ options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
331014
+ try {
331015
+ await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
331016
+ if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
331017
+ try {
331018
+ await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
331019
+ } catch (err) {
331020
+ log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
331021
+ }
331022
+ }
331023
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331024
+ for (const step of options.steps) {
331025
+ if (step.action === "launchApp") continue;
331026
+ if (!stepAppliesToPlatform(step, platform3)) continue;
331027
+ let elementId = null;
331028
+ const identityStrategies = buildIdentityLocatorStrategies(step.target);
331029
+ if (identityStrategies.length > 0 && step.target) {
331030
+ try {
331031
+ elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
331032
+ } catch (err) {
331033
+ if (isTransportError(err)) throw err;
331034
+ elementId = null;
331035
+ }
331036
+ }
331037
+ if (elementId) {
331038
+ const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
331039
+ if (bundle) {
331040
+ bundles[step.order] = bundle;
331041
+ log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
331042
+ } else {
331043
+ divergentOrders.push(step.order);
331044
+ log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
331045
+ }
331046
+ } else if (stepNeedsLocator(step.action)) {
331047
+ divergentOrders.push(step.order);
331048
+ log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
331049
+ }
331050
+ const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
331051
+ if (!advanced && isStateAdvancingAction(step.action)) {
331052
+ log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
331053
+ break;
331054
+ }
331055
+ reachedOrder = step.order;
331056
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
331057
+ }
331058
+ } catch (err) {
331059
+ environmentalError = err instanceof Error ? err.message : String(err);
331060
+ log2(` \u2717 Harvest aborted: ${environmentalError}`);
331061
+ } finally {
331062
+ options.onSessionEnding?.(session.deviceId);
331063
+ await session.stop().catch(() => {
331064
+ });
331065
+ }
331066
+ return {
331067
+ platform: platform3,
331068
+ bundles,
331069
+ divergentOrders,
331070
+ reachedOrder,
331071
+ deviceId: session.deviceId,
331072
+ deviceName: session.deviceName,
331073
+ logs: logLines.join("\n"),
331074
+ ...environmentalError ? { environmentalError } : {}
331075
+ };
331076
+ }
330763
331077
 
330764
331078
  // src/mobile-device-queue.ts
330765
331079
  function isNativeMobileDiscoveryContext(ctx) {
@@ -334017,6 +334331,102 @@ ${finalVerifyResult.logs ?? ""}`;
334017
334331
  log.fail("ERROR - No discovery context");
334018
334332
  return;
334019
334333
  }
334334
+ const locatorCtx = ctx;
334335
+ if (locatorCtx.mode === "locator-discovery") {
334336
+ const harvestTarget = locatorCtx.target;
334337
+ const targetTestCaseId = locatorCtx.targetTestCaseId;
334338
+ const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
334339
+ if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
334340
+ await postResult(opts.serverUrl, headers, run2.runId, {
334341
+ status: "ERROR",
334342
+ error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
334343
+ });
334344
+ log.fail("ERROR - malformed locator-discovery context");
334345
+ return;
334346
+ }
334347
+ if (getBridgeState().hasActiveSession) {
334348
+ await postResult(opts.serverUrl, headers, run2.runId, {
334349
+ status: "ERROR",
334350
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
334351
+ });
334352
+ log.fail("ERROR - device busy (recording) for locator discovery");
334353
+ return;
334354
+ }
334355
+ const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334356
+ liveBrowserHandle.patch({
334357
+ mode: "discovery",
334358
+ testName: "Cross-platform locator discovery",
334359
+ note: `${platform3} locator discovery`,
334360
+ surface: "mobile",
334361
+ mobilePlatform: platform3,
334362
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334363
+ ...leasedName ? { deviceName: leasedName } : {}
334364
+ });
334365
+ requestLiveBrowserHeartbeat(true);
334366
+ log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
334367
+ setExecutorBusy(true);
334368
+ try {
334369
+ const harvest = await harvestPlatformLocators({
334370
+ steps: locatorCtx.sourceSteps ?? [],
334371
+ target: {
334372
+ appId: harvestTarget.appId,
334373
+ platform: platform3,
334374
+ platformVersion: harvestTarget.platformVersion,
334375
+ recordedDeviceId: harvestTarget.recordedDeviceId,
334376
+ assignedDeviceId,
334377
+ launchMode: harvestTarget.launchMode,
334378
+ deeplink: harvestTarget.deeplink
334379
+ },
334380
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334381
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334382
+ const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
334383
+ liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
334384
+ requestLiveBrowserHeartbeat(true);
334385
+ },
334386
+ onSessionEnding: (deviceId) => {
334387
+ detachMirrorSession(deviceId);
334388
+ }
334389
+ });
334390
+ let queuedRunId = null;
334391
+ let resultPosted = false;
334392
+ try {
334393
+ const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
334394
+ method: "POST",
334395
+ headers: { ...headers, "Content-Type": "application/json" },
334396
+ body: JSON.stringify({
334397
+ bundles: harvest.bundles,
334398
+ divergentOrders: harvest.divergentOrders,
334399
+ reachedOrder: harvest.reachedOrder,
334400
+ logs: harvest.logs,
334401
+ ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
334402
+ }),
334403
+ signal: AbortSignal.timeout(3e4)
334404
+ });
334405
+ if (res.ok) {
334406
+ resultPosted = true;
334407
+ const data = await res.json().catch(() => null);
334408
+ queuedRunId = data?.queuedRunId ?? null;
334409
+ } else {
334410
+ log.warn(`[locator-discovery] result POST returned ${res.status}`);
334411
+ }
334412
+ } catch (postErr) {
334413
+ log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
334414
+ }
334415
+ const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
334416
+ await postResult(opts.serverUrl, headers, run2.runId, {
334417
+ status: passed ? "PASSED" : "ERROR",
334418
+ 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,
334419
+ logs: harvest.logs,
334420
+ duration: Date.now() - startTime
334421
+ });
334422
+ log[passed ? "ok" : "fail"](
334423
+ `Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
334424
+ );
334425
+ } finally {
334426
+ setExecutorBusy(false);
334427
+ }
334428
+ return;
334429
+ }
334020
334430
  log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
334021
334431
  const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
334022
334432
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
@@ -334730,7 +335140,11 @@ ${finalVerifyResult.logs ?? ""}`;
334730
335140
  recordedDeviceId: target.recordedDeviceId,
334731
335141
  assignedDeviceId,
334732
335142
  launchMode: target.launchMode,
334733
- deeplink: target.deeplink
335143
+ deeplink: target.deeplink,
335144
+ // Authoring platform (test's session medium). When it differs from
335145
+ // target.platform this is a cross-platform run: no blind coordinate
335146
+ // taps on foreign geometry, and the run must verify an outcome.
335147
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
334734
335148
  },
334735
335149
  // Stream this run to its OWN per-device dashboard tile (keyed by the
334736
335150
  // leased device id) so N parallel Appium runs each show a live mirror.