@validate.qa/runner 1.0.15 → 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 +743 -55
  2. package/dist/cli.mjs +743 -55
  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();
@@ -66797,16 +66908,16 @@ ${statePacket}` },
66797
66908
  }
66798
66909
  const screen = screenName();
66799
66910
  if (accessibilityId) {
66800
- const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral(accessibilityId)} or @name=${xpathLiteral(accessibilityId)}]`, className: "", accessibilityId };
66911
+ const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
66801
66912
  recordAction({ type: "ASSERT_VISIBLE", selector: accessibilityId, metadata: { screenName: screen, targetLabel: accessibilityId, locatorBundle: bundle2 } });
66802
66913
  return { ok: true, label: accessibilityId };
66803
66914
  }
66804
66915
  const safeText = text;
66805
- const bundle = { xpath: `//*[@text=${xpathLiteral(safeText)} or @label=${xpathLiteral(safeText)}]`, className: "", text: safeText };
66916
+ const bundle = { xpath: `//*[@text=${xpathLiteral2(safeText)} or @label=${xpathLiteral2(safeText)}]`, className: "", text: safeText };
66806
66917
  recordAction({ type: "ASSERT_VISIBLE", selector: bundle.xpath, metadata: { screenName: screen, targetLabel: safeText, locatorBundle: bundle } });
66807
66918
  return { ok: true, label: safeText };
66808
66919
  }
66809
- function xpathLiteral(value) {
66920
+ function xpathLiteral2(value) {
66810
66921
  if (!value.includes("'"))
66811
66922
  return `'${value}'`;
66812
66923
  if (!value.includes('"'))
@@ -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.15",
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;
@@ -329731,7 +329843,7 @@ async function getWindowSize(sessionId, retries = 3) {
329731
329843
  }
329732
329844
  throw new Error("Failed to get window size: invalid dimensions returned");
329733
329845
  }
329734
- function realDeviceCapabilities(platform3) {
329846
+ function realDeviceCapabilities(platform3, portOffset = 0) {
329735
329847
  const env = process.env;
329736
329848
  const caps = {};
329737
329849
  const num = (v) => {
@@ -329739,6 +329851,7 @@ function realDeviceCapabilities(platform3) {
329739
329851
  const n = Number.parseInt(v, 10);
329740
329852
  return Number.isFinite(n) ? n : void 0;
329741
329853
  };
329854
+ const offset = Number.isFinite(portOffset) && portOffset > 0 ? Math.floor(portOffset) : 0;
329742
329855
  if (platform3 === "IOS") {
329743
329856
  if (env.APPIUM_XCODE_ORG_ID) {
329744
329857
  caps["appium:xcodeOrgId"] = env.APPIUM_XCODE_ORG_ID;
@@ -329748,15 +329861,15 @@ function realDeviceCapabilities(platform3) {
329748
329861
  }
329749
329862
  if (env.APPIUM_UPDATED_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = env.APPIUM_UPDATED_WDA_BUNDLE_ID;
329750
329863
  const wdaPort = num(env.APPIUM_WDA_LOCAL_PORT);
329751
- if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort;
329864
+ if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort + offset;
329752
329865
  } else {
329753
329866
  const systemPort = num(env.APPIUM_SYSTEM_PORT);
329754
- if (systemPort !== void 0) caps["appium:systemPort"] = systemPort;
329867
+ if (systemPort !== void 0) caps["appium:systemPort"] = systemPort + offset;
329755
329868
  }
329756
329869
  return caps;
329757
329870
  }
329758
329871
  async function createSession(target) {
329759
- const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
329872
+ const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform, target.portOffset) : {};
329760
329873
  const capabilities = target.platform === "IOS" ? {
329761
329874
  platformName: "iOS",
329762
329875
  "appium:automationName": "XCUITest",
@@ -329994,6 +330107,11 @@ function boundsFromStep(step) {
329994
330107
  }
329995
330108
  return null;
329996
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
+ }
329997
330115
  function locatorStrategyForValue(value) {
329998
330116
  const trimmed = value?.trim();
329999
330117
  if (!trimmed) {
@@ -330013,24 +330131,68 @@ function locatorStrategyForValue(value) {
330013
330131
  { using: "id", value: trimmed }
330014
330132
  ];
330015
330133
  }
330016
- function buildLocatorStrategies(target) {
330134
+ function buildLocatorStrategies(target, platform3) {
330017
330135
  if (!target) return [];
330018
330136
  const strategies = [];
330019
- const bundle = target.locatorBundle;
330137
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330020
330138
  if (bundle) {
330021
330139
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330022
330140
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330023
330141
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
330024
330142
  if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
330025
330143
  }
330026
- strategies.push(...locatorStrategyForValue(target.primary));
330027
- for (const fallback of target.fallbacks ?? []) {
330028
- 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
+ }
330029
330149
  }
330030
330150
  return strategies.filter(
330031
330151
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330032
330152
  );
330033
330153
  }
330154
+ function dedupeStrategies(strategies) {
330155
+ return strategies.filter(
330156
+ (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330157
+ );
330158
+ }
330159
+ function xpathLiteral(value) {
330160
+ if (!value.includes("'")) return `'${value}'`;
330161
+ if (!value.includes('"')) return `"${value}"`;
330162
+ const parts = value.split("'").map((p) => `'${p}'`);
330163
+ return `concat(${parts.join(`, "'", `)})`;
330164
+ }
330165
+ function identityTextXpath(value) {
330166
+ const lit = xpathLiteral(value);
330167
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330168
+ }
330169
+ function isIdentityStrategy(strategy) {
330170
+ if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330171
+ if (strategy.using === "xpath") return strategy.value.includes("@");
330172
+ return false;
330173
+ }
330174
+ function buildIdentityLocatorStrategies(target, platform3) {
330175
+ if (!target) return [];
330176
+ const strategies = [];
330177
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330178
+ if (bundle) {
330179
+ if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330180
+ if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330181
+ for (const value of [bundle.text, bundle.contentDesc]) {
330182
+ const trimmed = value?.trim();
330183
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330184
+ }
330185
+ if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330186
+ }
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
+ }
330192
+ }
330193
+ }
330194
+ return dedupeStrategies(strategies);
330195
+ }
330034
330196
  function readStepTimeout(step) {
330035
330197
  const value = step.metadata?.timeout;
330036
330198
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -330038,10 +330200,12 @@ function readStepTimeout(step) {
330038
330200
  }
330039
330201
  return void 0;
330040
330202
  }
330041
- async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
330042
- const strategies = buildLocatorStrategies(target);
330203
+ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330204
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
330043
330205
  if (strategies.length === 0) {
330044
- throw new Error("No locator strategies available for this step");
330206
+ throw new Error(
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"
330208
+ );
330045
330209
  }
330046
330210
  const startTime = Date.now();
330047
330211
  let lastError = "";
@@ -330139,18 +330303,18 @@ function shouldAbortAfterStepFailure(action) {
330139
330303
  return false;
330140
330304
  }
330141
330305
  }
330142
- async function executeTap(sessionId, step) {
330143
- const bounds = boundsFromStep(step);
330306
+ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
330307
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330144
330308
  if (step.target) {
330145
330309
  try {
330146
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330310
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330147
330311
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
330148
330312
  method: "POST",
330149
330313
  body: "{}"
330150
330314
  });
330151
330315
  return;
330152
330316
  } catch (error2) {
330153
- if (!bounds) throw error2;
330317
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330154
330318
  }
330155
330319
  }
330156
330320
  if (!bounds) {
@@ -330161,7 +330325,7 @@ async function executeTap(sessionId, step) {
330161
330325
  async function executeType(sessionId, step, context) {
330162
330326
  if (step.value === void 0 || step.value === null || step.value === "") return;
330163
330327
  const value = String(step.value);
330164
- const bounds = boundsFromStep(step);
330328
+ const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
330165
330329
  let elementId = null;
330166
330330
  let lastInputError = null;
330167
330331
  let tappedBounds = false;
@@ -330178,12 +330342,12 @@ async function executeType(sessionId, step, context) {
330178
330342
  }
330179
330343
  if (step.target) {
330180
330344
  try {
330181
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330345
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
330182
330346
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330183
330347
  if (!lastInputError) return;
330184
330348
  elementId = null;
330185
330349
  } catch (error2) {
330186
- if (!bounds) throw error2;
330350
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
330187
330351
  lastInputError = error2;
330188
330352
  }
330189
330353
  }
@@ -330231,14 +330395,14 @@ async function executeType(sessionId, step, context) {
330231
330395
  }
330232
330396
  throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
330233
330397
  }
330234
- async function executeClear(sessionId, step) {
330235
- const bounds = boundsFromStep(step);
330398
+ async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
330399
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330236
330400
  let elementId = null;
330237
330401
  if (step.target) {
330238
330402
  try {
330239
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330403
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330240
330404
  } catch (error2) {
330241
- if (!bounds) throw error2;
330405
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330242
330406
  }
330243
330407
  }
330244
330408
  if (!elementId && bounds) {
@@ -330307,20 +330471,20 @@ async function executeHome(sessionId, platform3) {
330307
330471
  body: JSON.stringify({ keycode: 3 })
330308
330472
  });
330309
330473
  }
330310
- async function executeWaitForElement(sessionId, step) {
330474
+ async function executeWaitForElement(sessionId, step, platform3) {
330311
330475
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330312
- await findElement(sessionId, step.target, timeout);
330476
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
330313
330477
  }
330314
- async function executeAssertVisible(sessionId, step) {
330315
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330478
+ async function executeAssertVisible(sessionId, step, platform3) {
330479
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330316
330480
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330317
330481
  if (!result?.value) {
330318
330482
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
330319
330483
  }
330320
330484
  }
330321
- async function executeAssertText(sessionId, step) {
330485
+ async function executeAssertText(sessionId, step, platform3) {
330322
330486
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330323
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330487
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330324
330488
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330325
330489
  const actualText = typeof result?.value === "string" ? result.value : "";
330326
330490
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330355,6 +330519,7 @@ async function executeMobileTest(options) {
330355
330519
  const screenshots = [];
330356
330520
  let apiCalls = [];
330357
330521
  let sessionId = null;
330522
+ let selectedDevice;
330358
330523
  let capture = null;
330359
330524
  let captureStopped = false;
330360
330525
  let recordingStarted = false;
@@ -330402,7 +330567,6 @@ async function executeMobileTest(options) {
330402
330567
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
330403
330568
  );
330404
330569
  }
330405
- let selectedDevice;
330406
330570
  if (options.target.assignedDeviceId) {
330407
330571
  selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
330408
330572
  if (!selectedDevice) {
@@ -330443,13 +330607,15 @@ async function executeMobileTest(options) {
330443
330607
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
330444
330608
  }
330445
330609
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
330610
+ const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
330446
330611
  try {
330447
330612
  sessionId = await createSessionWithRetry({
330448
330613
  appId: options.target.appId,
330449
330614
  platform: options.target.platform,
330450
330615
  deviceId: selectedDevice.id,
330451
330616
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
330452
- isPhysical: selectedDevice.isPhysical
330617
+ isPhysical: selectedDevice.isPhysical,
330618
+ portOffset: leasedPortOffset
330453
330619
  }, (msg) => log2(msg));
330454
330620
  } catch (err) {
330455
330621
  const base2 = err instanceof Error ? err.message : String(err);
@@ -330465,6 +330631,12 @@ async function executeMobileTest(options) {
330465
330631
  throw err;
330466
330632
  }
330467
330633
  log2(`Session created: ${sessionId}`);
330634
+ if (selectedDevice.id) {
330635
+ try {
330636
+ options.onSessionReady?.(sessionId, selectedDevice.id, options.target.platform);
330637
+ } catch {
330638
+ }
330639
+ }
330468
330640
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
330469
330641
  log2("Opening a fresh app instance for the test...");
330470
330642
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
@@ -330477,8 +330649,34 @@ async function executeMobileTest(options) {
330477
330649
  }
330478
330650
  }
330479
330651
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330652
+ let entryDrift = false;
330653
+ const runPlatform = options.target.platform;
330654
+ const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330655
+ const entryStep = options.steps.find(
330656
+ (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330657
+ );
330658
+ if (entryStep) {
330659
+ try {
330660
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
330661
+ } catch (err) {
330662
+ if (isTransportError(err)) throw err;
330663
+ entryDrift = true;
330664
+ const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
330665
+ log2(` \u2717 Entry-screen drift: ${driftMsg}`);
330666
+ stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
330667
+ const driftShot = await takeScreenshot(sessionId).catch(() => null);
330668
+ screenshots.push(driftShot ?? "");
330669
+ }
330670
+ }
330480
330671
  let environmental = null;
330481
330672
  for (const step of options.steps) {
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
+ }
330482
330680
  const stepStart = Date.now();
330483
330681
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330484
330682
  let status = "passed";
@@ -330490,17 +330688,18 @@ async function executeMobileTest(options) {
330490
330688
  log2(" App launched (handled by session creation)");
330491
330689
  break;
330492
330690
  case "tap":
330493
- await executeTap(sessionId, step);
330691
+ await executeTap(sessionId, step, runPlatform, isCrossPlatform);
330494
330692
  break;
330495
330693
  case "type":
330496
330694
  await executeType(sessionId, step, {
330497
- platform: options.target.platform,
330695
+ platform: runPlatform,
330498
330696
  deviceId: selectedDevice.id,
330499
- log: log2
330697
+ log: log2,
330698
+ isCrossPlatform
330500
330699
  });
330501
330700
  break;
330502
330701
  case "clear":
330503
- await executeClear(sessionId, step);
330702
+ await executeClear(sessionId, step, runPlatform, isCrossPlatform);
330504
330703
  break;
330505
330704
  case "swipe":
330506
330705
  case "scroll":
@@ -330510,16 +330709,16 @@ async function executeMobileTest(options) {
330510
330709
  await executeBack(sessionId);
330511
330710
  break;
330512
330711
  case "home":
330513
- await executeHome(sessionId, options.target.platform);
330712
+ await executeHome(sessionId, runPlatform);
330514
330713
  break;
330515
330714
  case "waitForElement":
330516
- await executeWaitForElement(sessionId, step);
330715
+ await executeWaitForElement(sessionId, step, runPlatform);
330517
330716
  break;
330518
330717
  case "assertVisible":
330519
- await executeAssertVisible(sessionId, step);
330718
+ await executeAssertVisible(sessionId, step, runPlatform);
330520
330719
  break;
330521
330720
  case "assertText":
330522
- await executeAssertText(sessionId, step);
330721
+ await executeAssertText(sessionId, step, runPlatform);
330523
330722
  break;
330524
330723
  default: {
330525
330724
  const _exhaustive = step.action;
@@ -330556,8 +330755,14 @@ async function executeMobileTest(options) {
330556
330755
  break;
330557
330756
  }
330558
330757
  }
330559
- const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330560
- 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);
330561
330766
  await stopCapture();
330562
330767
  await stopRecording();
330563
330768
  return {
@@ -330569,6 +330774,8 @@ async function executeMobileTest(options) {
330569
330774
  logs: logLines.join("\n"),
330570
330775
  error: firstError,
330571
330776
  duration: Date.now() - startTime,
330777
+ deviceId: selectedDevice?.id,
330778
+ deviceName: selectedDevice?.name,
330572
330779
  environmental: environmental ?? void 0
330573
330780
  };
330574
330781
  } catch (err) {
@@ -330585,6 +330792,8 @@ async function executeMobileTest(options) {
330585
330792
  logs: logLines.join("\n"),
330586
330793
  error: errorMsg,
330587
330794
  duration: Date.now() - startTime,
330795
+ deviceId: selectedDevice?.id,
330796
+ deviceName: selectedDevice?.name,
330588
330797
  // A throw before/around the step loop is a setup/environment problem
330589
330798
  // (no device booted, app not installed, session could not be created,
330590
330799
  // Appium unreachable) — never a genuine test assertion failure.
@@ -330594,6 +330803,12 @@ async function executeMobileTest(options) {
330594
330803
  await stopCapture();
330595
330804
  await stopRecording();
330596
330805
  if (sessionId) {
330806
+ if (selectedDevice?.id) {
330807
+ try {
330808
+ options.onSessionEnding?.(selectedDevice.id);
330809
+ } catch {
330810
+ }
330811
+ }
330597
330812
  try {
330598
330813
  log2("Closing the app on the device...");
330599
330814
  await terminateTargetApp(sessionId, options.target.platform, options.target.appId);
@@ -330652,7 +330867,9 @@ async function createMobileDiscoverySession(target, options) {
330652
330867
  platform: target.platform,
330653
330868
  deviceId: selectedDevice.id,
330654
330869
  platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
330655
- isPhysical: selectedDevice.isPhysical
330870
+ isPhysical: selectedDevice.isPhysical,
330871
+ // Distinct control port per concurrent physical device (no-op for emulators/sims).
330872
+ portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
330656
330873
  });
330657
330874
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
330658
330875
  try {
@@ -330664,6 +330881,7 @@ async function createMobileDiscoverySession(target, options) {
330664
330881
  return {
330665
330882
  sessionId,
330666
330883
  deviceId: selectedDevice.id,
330884
+ deviceName: selectedDevice.name,
330667
330885
  isPhysical: selectedDevice.isPhysical === true,
330668
330886
  platform: target.platform,
330669
330887
  appId: target.appId,
@@ -330678,6 +330896,184 @@ async function createMobileDiscoverySession(target, options) {
330678
330896
  }
330679
330897
  };
330680
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
+ }
330681
331077
 
330682
331078
  // src/mobile-device-queue.ts
330683
331079
  function isNativeMobileDiscoveryContext(ctx) {
@@ -331389,7 +331785,146 @@ async function handleSessionStop(_req, res) {
331389
331785
  stopSessionReaper();
331390
331786
  sendJSON(res, 200, { ok: true });
331391
331787
  }
331788
+ var runnerChannels = /* @__PURE__ */ new Map();
331789
+ function readDeviceIdParam(req) {
331790
+ try {
331791
+ const url = new URL(req.url || "", "http://localhost");
331792
+ const raw = url.searchParams.get("deviceId");
331793
+ if (!raw) return null;
331794
+ const trimmed = raw.trim();
331795
+ if (!trimmed || trimmed.length > MAX_DEVICE_ID_LENGTH || !DEVICE_ID_PATTERN.test(trimmed)) return null;
331796
+ return trimmed;
331797
+ } catch {
331798
+ return null;
331799
+ }
331800
+ }
331801
+ async function isSessionAlive(sessionId) {
331802
+ try {
331803
+ validateSessionId(sessionId);
331804
+ await appiumRequest2(`/session/${sessionId}/window/rect`);
331805
+ return true;
331806
+ } catch (err) {
331807
+ const msg = err instanceof Error ? err.message : String(err);
331808
+ return !/no such session|invalid session id|session is either terminated|deleted session/i.test(msg);
331809
+ }
331810
+ }
331811
+ function writeFrameToChannelClient(ch, client, frame) {
331812
+ try {
331813
+ const buffered = typeof client.writableLength === "number" ? client.writableLength : typeof client.socket?.writableLength === "number" ? client.socket.writableLength : 0;
331814
+ if (buffered > SSE_MAX_BUFFERED_BYTES) {
331815
+ try {
331816
+ client.end();
331817
+ } catch {
331818
+ }
331819
+ ch.clients.delete(client);
331820
+ stopChannelPollingIfIdle(ch);
331821
+ return;
331822
+ }
331823
+ client.write(`data: ${JSON.stringify(frame)}
331824
+
331825
+ `);
331826
+ } catch {
331827
+ }
331828
+ }
331829
+ async function pollRunnerChannel(ch) {
331830
+ if (ch.clients.size === 0 || ch.polling) return;
331831
+ ch.polling = true;
331832
+ try {
331833
+ validateSessionId(ch.sessionId);
331834
+ const [screenshot, source] = await Promise.all([
331835
+ getScreenshot(`/session/${ch.sessionId}`),
331836
+ getPageSource(`/session/${ch.sessionId}`)
331837
+ ]);
331838
+ if (!screenshot && !source) {
331839
+ ch.nullTicks++;
331840
+ if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
331841
+ const alive = await isSessionAlive(ch.sessionId);
331842
+ if (!alive) {
331843
+ closeRunnerChannel(ch);
331844
+ return;
331845
+ }
331846
+ ch.nullTicks = 0;
331847
+ }
331848
+ } else {
331849
+ ch.nullTicks = 0;
331850
+ }
331851
+ const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
331852
+ ch.lastFrame = frame;
331853
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
331854
+ } catch {
331855
+ } finally {
331856
+ ch.polling = false;
331857
+ }
331858
+ }
331859
+ function ensureChannelPolling(ch) {
331860
+ if (ch.pollTimer) return;
331861
+ ch.pollTimer = setInterval(() => {
331862
+ void pollRunnerChannel(ch);
331863
+ }, MOBILE_SCREENSHOT_INTERVAL_MS);
331864
+ ch.pollTimer.unref?.();
331865
+ }
331866
+ function stopChannelPollingIfIdle(ch) {
331867
+ if (ch.clients.size === 0 && ch.pollTimer) {
331868
+ clearInterval(ch.pollTimer);
331869
+ ch.pollTimer = null;
331870
+ }
331871
+ }
331872
+ function closeRunnerChannel(ch) {
331873
+ if (ch.pollTimer) {
331874
+ clearInterval(ch.pollTimer);
331875
+ ch.pollTimer = null;
331876
+ }
331877
+ for (const client of ch.clients) {
331878
+ try {
331879
+ client.end();
331880
+ } catch {
331881
+ }
331882
+ }
331883
+ ch.clients.clear();
331884
+ ch.lastFrame = null;
331885
+ runnerChannels.delete(ch.deviceId);
331886
+ }
331887
+ function closeAllRunnerChannels() {
331888
+ for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
331889
+ }
331890
+ async function serveRunnerMirror(req, res, ch) {
331891
+ res.writeHead(200, {
331892
+ "Content-Type": "text/event-stream",
331893
+ "Cache-Control": "no-cache",
331894
+ "Connection": "keep-alive",
331895
+ "Access-Control-Allow-Origin": getAllowedOrigin(req),
331896
+ "X-Content-Type-Options": "nosniff"
331897
+ });
331898
+ ch.clients.add(res);
331899
+ ensureChannelPolling(ch);
331900
+ if (ch.lastFrame) {
331901
+ writeFrameToChannelClient(ch, res, ch.lastFrame);
331902
+ } else {
331903
+ await pollRunnerChannel(ch);
331904
+ }
331905
+ req.on("close", () => {
331906
+ ch.clients.delete(res);
331907
+ stopChannelPollingIfIdle(ch);
331908
+ });
331909
+ }
331392
331910
  async function handleMirror(req, res) {
331911
+ const deviceId = readDeviceIdParam(req);
331912
+ if (deviceId) {
331913
+ const ch = runnerChannels.get(deviceId);
331914
+ if (ch) {
331915
+ await serveRunnerMirror(req, res, ch);
331916
+ return;
331917
+ }
331918
+ sendJSON(res, 404, { error: `No live mirror for device ${deviceId}.` });
331919
+ return;
331920
+ }
331921
+ if (!state.appiumSessionId && runnerChannels.size === 1) {
331922
+ const soleChannel = runnerChannels.values().next().value;
331923
+ if (soleChannel) {
331924
+ await serveRunnerMirror(req, res, soleChannel);
331925
+ return;
331926
+ }
331927
+ }
331393
331928
  if (!state.appiumSessionId) {
331394
331929
  sendJSON(res, 400, { error: "No active Appium session. Call /bridge/session/start first." });
331395
331930
  return;
@@ -331836,6 +332371,7 @@ async function startMobileBridge(options) {
331836
332371
  }
331837
332372
  async function stopMobileBridge() {
331838
332373
  stopAllMirrorClients();
332374
+ closeAllRunnerChannels();
331839
332375
  stopSessionReaper();
331840
332376
  clearBridgePidFile();
331841
332377
  if (state.appiumSessionId) {
@@ -331884,8 +332420,31 @@ function getBridgeState() {
331884
332420
  function setExecutorBusy(busy) {
331885
332421
  executorBusyCount = busy ? executorBusyCount + 1 : Math.max(0, executorBusyCount - 1);
331886
332422
  }
331887
- function attachMirrorSession(sessionId, platform3) {
332423
+ function attachMirrorSession(sessionId, platform3, deviceId) {
331888
332424
  validateSessionId(sessionId);
332425
+ if (deviceId) {
332426
+ const existing = runnerChannels.get(deviceId);
332427
+ if (existing && existing.sessionId !== sessionId) closeRunnerChannel(existing);
332428
+ const ch = runnerChannels.get(deviceId) ?? {
332429
+ deviceId,
332430
+ sessionId,
332431
+ platform: platform3,
332432
+ clients: /* @__PURE__ */ new Set(),
332433
+ lastFrame: null,
332434
+ pollTimer: null,
332435
+ nullTicks: 0,
332436
+ polling: false
332437
+ };
332438
+ ch.sessionId = sessionId;
332439
+ ch.platform = platform3;
332440
+ ch.nullTicks = 0;
332441
+ runnerChannels.set(deviceId, ch);
332442
+ if (ch.clients.size > 0) {
332443
+ ensureChannelPolling(ch);
332444
+ void pollRunnerChannel(ch);
332445
+ }
332446
+ return;
332447
+ }
331889
332448
  if (state.appiumSessionId && state.appiumSessionId !== sessionId) {
331890
332449
  stopAllMirrorClients();
331891
332450
  }
@@ -331900,8 +332459,12 @@ function attachMirrorSession(sessionId, platform3) {
331900
332459
  void pollAndBroadcastFrame();
331901
332460
  }
331902
332461
  }
331903
- function detachMirrorSession(sessionId) {
331904
- if (sessionId && state.appiumSessionId && state.appiumSessionId !== sessionId) return;
332462
+ function detachMirrorSession(deviceId) {
332463
+ if (deviceId) {
332464
+ const ch = runnerChannels.get(deviceId);
332465
+ if (ch) closeRunnerChannel(ch);
332466
+ return;
332467
+ }
331905
332468
  state.appiumSessionId = null;
331906
332469
  state.platform = null;
331907
332470
  mirrorSessionOwner = null;
@@ -333768,6 +334331,102 @@ ${finalVerifyResult.logs ?? ""}`;
333768
334331
  log.fail("ERROR - No discovery context");
333769
334332
  return;
333770
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
+ }
333771
334430
  log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
333772
334431
  const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
333773
334432
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
@@ -334087,7 +334746,8 @@ ${finalVerifyResult.logs ?? ""}`;
334087
334746
  testName: ctx.featureName ?? "Mobile discovery",
334088
334747
  note: `${platform3} mobile discovery`,
334089
334748
  surface: "mobile",
334090
- mobilePlatform: platform3
334749
+ mobilePlatform: platform3,
334750
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {}
334091
334751
  });
334092
334752
  requestLiveBrowserHeartbeat(true);
334093
334753
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
@@ -334105,6 +334765,7 @@ ${finalVerifyResult.logs ?? ""}`;
334105
334765
  deeplink: mobileTarget.deeplink
334106
334766
  }, {
334107
334767
  onDeviceSelected: async (device) => {
334768
+ liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
334108
334769
  try {
334109
334770
  capture = await startMobileNetworkCapture({
334110
334771
  platform: platform3,
@@ -334123,7 +334784,7 @@ ${finalVerifyResult.logs ?? ""}`;
334123
334784
  }
334124
334785
  });
334125
334786
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
334126
- attachMirrorSession(session.sessionId, platform3);
334787
+ attachMirrorSession(session.sessionId, platform3, session.deviceId);
334127
334788
  const driver = new MobileBridgeDriver({
334128
334789
  sessionId: session.sessionId,
334129
334790
  platform: platform3,
@@ -334167,7 +334828,7 @@ ${finalVerifyResult.logs ?? ""}`;
334167
334828
  }
334168
334829
  }
334169
334830
  if (session) {
334170
- detachMirrorSession(session.sessionId);
334831
+ detachMirrorSession(session.deviceId);
334171
334832
  try {
334172
334833
  await session.stop();
334173
334834
  } catch {
@@ -334444,6 +335105,14 @@ ${finalVerifyResult.logs ?? ""}`;
334444
335105
  return;
334445
335106
  }
334446
335107
  log.info(`\u{1F4F1} Mobile test - ${target.platform} / ${target.appId}`);
335108
+ const leasedDeviceName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
335109
+ liveBrowserHandle.patch({
335110
+ surface: "mobile",
335111
+ mobilePlatform: target.platform,
335112
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
335113
+ ...leasedDeviceName ? { deviceName: leasedDeviceName } : {}
335114
+ });
335115
+ requestLiveBrowserHeartbeat(true);
334447
335116
  const mobileSteps = run2.steps ?? [];
334448
335117
  if (mobileSteps.length === 0) {
334449
335118
  await postResult(opts.serverUrl, headers, run2.runId, {
@@ -334471,7 +335140,20 @@ ${finalVerifyResult.logs ?? ""}`;
334471
335140
  recordedDeviceId: target.recordedDeviceId,
334472
335141
  assignedDeviceId,
334473
335142
  launchMode: target.launchMode,
334474
- 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
335148
+ },
335149
+ // Stream this run to its OWN per-device dashboard tile (keyed by the
335150
+ // leased device id) so N parallel Appium runs each show a live mirror.
335151
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
335152
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
335153
+ requestLiveBrowserHeartbeat(true);
335154
+ },
335155
+ onSessionEnding: (deviceId) => {
335156
+ detachMirrorSession(deviceId);
334475
335157
  }
334476
335158
  });
334477
335159
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
@@ -334487,6 +335169,12 @@ ${result2.logs}`);
334487
335169
  if (attemptLogs.length > 1) {
334488
335170
  result2 = { ...result2, logs: attemptLogs.join("\n\n") };
334489
335171
  }
335172
+ if (result2.deviceId || result2.deviceName) {
335173
+ liveBrowserHandle.patch({
335174
+ ...result2.deviceId ? { deviceId: result2.deviceId } : {},
335175
+ ...result2.deviceName ? { deviceName: result2.deviceName } : {}
335176
+ });
335177
+ }
334490
335178
  const duration2 = Date.now() - startTime;
334491
335179
  const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
334492
335180
  const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;