@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.js CHANGED
@@ -1856,6 +1856,102 @@ var init_mobile_test_generation = __esm({
1856
1856
  }
1857
1857
  });
1858
1858
 
1859
+ // ../shared/dist/mobile-cross-platform.js
1860
+ function isMobilePlatform(value) {
1861
+ return value === "IOS" || value === "ANDROID";
1862
+ }
1863
+ function mediumForMobilePlatform(platform3) {
1864
+ return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
1865
+ }
1866
+ function mobilePlatformFromMedium(medium) {
1867
+ if (medium === "IOS_NATIVE")
1868
+ return "IOS";
1869
+ if (medium === "ANDROID_NATIVE")
1870
+ return "ANDROID";
1871
+ return null;
1872
+ }
1873
+ function otherMobilePlatform(platform3) {
1874
+ return platform3 === "IOS" ? "ANDROID" : "IOS";
1875
+ }
1876
+ function platformTag(platform3) {
1877
+ return platform3 === "IOS" ? "@ios" : "@android";
1878
+ }
1879
+ function resolvePlatformLocatorBundle(target, platform3) {
1880
+ if (!target)
1881
+ return void 0;
1882
+ if (platform3) {
1883
+ const overlay = target.platformLocators?.[platform3];
1884
+ if (overlay)
1885
+ return overlay;
1886
+ }
1887
+ return target.locatorBundle;
1888
+ }
1889
+ function hasPlatformLocator(target, platform3) {
1890
+ return Boolean(target?.platformLocators?.[platform3]);
1891
+ }
1892
+ function hasCrossPlatformIdentity(bundle) {
1893
+ if (!bundle)
1894
+ return false;
1895
+ return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
1896
+ }
1897
+ function stepIsDivergentForPlatform(step, platform3) {
1898
+ return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
1899
+ }
1900
+ function stepAppliesToPlatform(step, platform3) {
1901
+ const platforms = step.platforms;
1902
+ if (!platforms || platforms.length === 0)
1903
+ return true;
1904
+ if (!platform3)
1905
+ return true;
1906
+ return platforms.includes(platform3);
1907
+ }
1908
+ function stepsMissingPlatformLocators(steps, platform3) {
1909
+ return steps.filter((step) => {
1910
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1911
+ return false;
1912
+ if (!stepAppliesToPlatform(step, platform3))
1913
+ return false;
1914
+ if (hasPlatformLocator(step.target, platform3))
1915
+ return false;
1916
+ if (stepIsDivergentForPlatform(step, platform3))
1917
+ return false;
1918
+ return hasCrossPlatformIdentity(step.target?.locatorBundle);
1919
+ });
1920
+ }
1921
+ function stepsNeedingReviewForPlatform(steps, platform3) {
1922
+ return steps.filter((step) => {
1923
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1924
+ return false;
1925
+ if (!stepAppliesToPlatform(step, platform3))
1926
+ return false;
1927
+ if (hasPlatformLocator(step.target, platform3))
1928
+ return false;
1929
+ if (stepIsDivergentForPlatform(step, platform3))
1930
+ return true;
1931
+ return !hasCrossPlatformIdentity(step.target?.locatorBundle);
1932
+ });
1933
+ }
1934
+ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
1935
+ if (runPlatform === authoringPlatform)
1936
+ return true;
1937
+ return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
1938
+ }
1939
+ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
1940
+ var init_mobile_cross_platform = __esm({
1941
+ "../shared/dist/mobile-cross-platform.js"() {
1942
+ "use strict";
1943
+ MOBILE_PLATFORMS = ["IOS", "ANDROID"];
1944
+ LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
1945
+ "tap",
1946
+ "type",
1947
+ "clear",
1948
+ "waitForElement",
1949
+ "assertVisible",
1950
+ "assertText"
1951
+ ]);
1952
+ }
1953
+ });
1954
+
1859
1955
  // ../shared/dist/mobile-deeplink.js
1860
1956
  function isSafeDeepLink(value) {
1861
1957
  if (typeof value !== "string")
@@ -3534,6 +3630,7 @@ __export(dist_exports, {
3534
3630
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
3535
3631
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
3536
3632
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
3633
+ MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
3537
3634
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
3538
3635
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
3539
3636
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
@@ -3587,6 +3684,8 @@ __export(dist_exports, {
3587
3684
  getPlanDefinition: () => getPlanDefinition,
3588
3685
  getPlanLabel: () => getPlanLabel,
3589
3686
  getTrialDaysRemaining: () => getTrialDaysRemaining,
3687
+ hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
3688
+ hasPlatformLocator: () => hasPlatformLocator,
3590
3689
  hostOfOrigin: () => hostOfOrigin,
3591
3690
  isAddonType: () => isAddonType,
3592
3691
  isApiTest: () => isApiTest,
@@ -3596,23 +3695,34 @@ __export(dist_exports, {
3596
3695
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3597
3696
  isDbPricingPlan: () => isDbPricingPlan,
3598
3697
  isEnvironmentalError: () => isEnvironmentalError,
3698
+ isMobilePlatform: () => isMobilePlatform,
3599
3699
  isPricingPlan: () => isPricingPlan,
3600
3700
  isRunnableStep: () => isRunnableStep,
3601
3701
  isSafeDeepLink: () => isSafeDeepLink,
3602
3702
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
3703
+ isTestReadyForPlatform: () => isTestReadyForPlatform,
3603
3704
  isTrialExpired: () => isTrialExpired,
3604
3705
  isUIVariant: () => isUIVariant,
3605
3706
  isValidMobileAppId: () => isValidMobileAppId,
3707
+ mediumForMobilePlatform: () => mediumForMobilePlatform,
3708
+ mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3606
3709
  normalizeControlName: () => normalizeControlName,
3710
+ otherMobilePlatform: () => otherMobilePlatform,
3607
3711
  parenDepthDelta: () => parenDepthDelta,
3608
3712
  planHasBillingPortal: () => planHasBillingPortal,
3609
3713
  planRequiresCheckout: () => planRequiresCheckout,
3714
+ platformTag: () => platformTag,
3610
3715
  registrableDomain: () => registrableDomain,
3611
3716
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3717
+ resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3612
3718
  safeOrigin: () => safeOrigin,
3613
3719
  sanitizeTestVariables: () => sanitizeTestVariables,
3614
3720
  selectorCandidatesForStep: () => selectorCandidatesForStep,
3615
3721
  selectorForValue: () => selectorForValue,
3722
+ stepAppliesToPlatform: () => stepAppliesToPlatform,
3723
+ stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
3724
+ stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
3725
+ stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
3616
3726
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
3617
3727
  stripLoginScaffolding: () => stripLoginScaffolding,
3618
3728
  summarizeHealth: () => summarizeHealth,
@@ -3626,6 +3736,7 @@ var init_dist = __esm({
3626
3736
  init_types();
3627
3737
  init_constants();
3628
3738
  init_mobile_test_generation();
3739
+ init_mobile_cross_platform();
3629
3740
  init_mobile_deeplink();
3630
3741
  init_plans();
3631
3742
  init_audit_pricing();
@@ -66792,16 +66903,16 @@ ${statePacket}` },
66792
66903
  }
66793
66904
  const screen = screenName();
66794
66905
  if (accessibilityId) {
66795
- const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral(accessibilityId)} or @name=${xpathLiteral(accessibilityId)}]`, className: "", accessibilityId };
66906
+ const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
66796
66907
  recordAction({ type: "ASSERT_VISIBLE", selector: accessibilityId, metadata: { screenName: screen, targetLabel: accessibilityId, locatorBundle: bundle2 } });
66797
66908
  return { ok: true, label: accessibilityId };
66798
66909
  }
66799
66910
  const safeText = text;
66800
- const bundle = { xpath: `//*[@text=${xpathLiteral(safeText)} or @label=${xpathLiteral(safeText)}]`, className: "", text: safeText };
66911
+ const bundle = { xpath: `//*[@text=${xpathLiteral2(safeText)} or @label=${xpathLiteral2(safeText)}]`, className: "", text: safeText };
66801
66912
  recordAction({ type: "ASSERT_VISIBLE", selector: bundle.xpath, metadata: { screenName: screen, targetLabel: safeText, locatorBundle: bundle } });
66802
66913
  return { ok: true, label: safeText };
66803
66914
  }
66804
- function xpathLiteral(value) {
66915
+ function xpathLiteral2(value) {
66805
66916
  if (!value.includes("'"))
66806
66917
  return `'${value}'`;
66807
66918
  if (!value.includes('"'))
@@ -66880,9 +66991,9 @@ ${statePacket}` },
66880
66991
  if (claimedVerified && !verified) {
66881
66992
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
66882
66993
  }
66883
- const platformTag = platform3 === "IOS" ? "@ios" : "@android";
66994
+ const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
66884
66995
  const reviewTag = verified ? "@verified" : "@needs-review";
66885
- const tags = ["@discovered", "@mobile", platformTag, reviewTag];
66996
+ const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
66886
66997
  const test = {
66887
66998
  name: compiled.name,
66888
66999
  description: recording.finish?.description ?? compiled.description,
@@ -328118,7 +328229,7 @@ var require_package4 = __commonJS({
328118
328229
  "package.json"(exports2, module2) {
328119
328230
  module2.exports = {
328120
328231
  name: "@validate.qa/runner",
328121
- version: "1.0.15",
328232
+ version: "1.0.17",
328122
328233
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328123
328234
  bin: {
328124
328235
  "validate-runner": "dist/cli.js"
@@ -329659,6 +329770,7 @@ async function startMobileNetworkCapture(options) {
329659
329770
 
329660
329771
  // src/services/appium-executor.ts
329661
329772
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
329773
+ var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
329662
329774
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
329663
329775
  var WD_ACTION_TIMEOUT_MS = 3e4;
329664
329776
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
@@ -329723,7 +329835,7 @@ async function getWindowSize(sessionId, retries = 3) {
329723
329835
  }
329724
329836
  throw new Error("Failed to get window size: invalid dimensions returned");
329725
329837
  }
329726
- function realDeviceCapabilities(platform3) {
329838
+ function realDeviceCapabilities(platform3, portOffset = 0) {
329727
329839
  const env = process.env;
329728
329840
  const caps = {};
329729
329841
  const num = (v) => {
@@ -329731,6 +329843,7 @@ function realDeviceCapabilities(platform3) {
329731
329843
  const n = Number.parseInt(v, 10);
329732
329844
  return Number.isFinite(n) ? n : void 0;
329733
329845
  };
329846
+ const offset = Number.isFinite(portOffset) && portOffset > 0 ? Math.floor(portOffset) : 0;
329734
329847
  if (platform3 === "IOS") {
329735
329848
  if (env.APPIUM_XCODE_ORG_ID) {
329736
329849
  caps["appium:xcodeOrgId"] = env.APPIUM_XCODE_ORG_ID;
@@ -329740,15 +329853,15 @@ function realDeviceCapabilities(platform3) {
329740
329853
  }
329741
329854
  if (env.APPIUM_UPDATED_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = env.APPIUM_UPDATED_WDA_BUNDLE_ID;
329742
329855
  const wdaPort = num(env.APPIUM_WDA_LOCAL_PORT);
329743
- if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort;
329856
+ if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort + offset;
329744
329857
  } else {
329745
329858
  const systemPort = num(env.APPIUM_SYSTEM_PORT);
329746
- if (systemPort !== void 0) caps["appium:systemPort"] = systemPort;
329859
+ if (systemPort !== void 0) caps["appium:systemPort"] = systemPort + offset;
329747
329860
  }
329748
329861
  return caps;
329749
329862
  }
329750
329863
  async function createSession(target) {
329751
- const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
329864
+ const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform, target.portOffset) : {};
329752
329865
  const capabilities = target.platform === "IOS" ? {
329753
329866
  platformName: "iOS",
329754
329867
  "appium:automationName": "XCUITest",
@@ -329986,6 +330099,11 @@ function boundsFromStep(step) {
329986
330099
  }
329987
330100
  return null;
329988
330101
  }
330102
+ function boundsForRun(step, platform3, isCrossPlatform) {
330103
+ if (isCrossPlatform) return null;
330104
+ if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
330105
+ return boundsFromStep(step);
330106
+ }
329989
330107
  function locatorStrategyForValue(value) {
329990
330108
  const trimmed = value?.trim();
329991
330109
  if (!trimmed) {
@@ -330005,24 +330123,68 @@ function locatorStrategyForValue(value) {
330005
330123
  { using: "id", value: trimmed }
330006
330124
  ];
330007
330125
  }
330008
- function buildLocatorStrategies(target) {
330126
+ function buildLocatorStrategies(target, platform3) {
330009
330127
  if (!target) return [];
330010
330128
  const strategies = [];
330011
- const bundle = target.locatorBundle;
330129
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330012
330130
  if (bundle) {
330013
330131
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330014
330132
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330015
330133
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
330016
330134
  if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
330017
330135
  }
330018
- strategies.push(...locatorStrategyForValue(target.primary));
330019
- for (const fallback of target.fallbacks ?? []) {
330020
- strategies.push(...locatorStrategyForValue(fallback));
330136
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330137
+ strategies.push(...locatorStrategyForValue(target.primary));
330138
+ for (const fallback of target.fallbacks ?? []) {
330139
+ strategies.push(...locatorStrategyForValue(fallback));
330140
+ }
330021
330141
  }
330022
330142
  return strategies.filter(
330023
330143
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330024
330144
  );
330025
330145
  }
330146
+ function dedupeStrategies(strategies) {
330147
+ return strategies.filter(
330148
+ (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330149
+ );
330150
+ }
330151
+ function xpathLiteral(value) {
330152
+ if (!value.includes("'")) return `'${value}'`;
330153
+ if (!value.includes('"')) return `"${value}"`;
330154
+ const parts = value.split("'").map((p) => `'${p}'`);
330155
+ return `concat(${parts.join(`, "'", `)})`;
330156
+ }
330157
+ function identityTextXpath(value) {
330158
+ const lit = xpathLiteral(value);
330159
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330160
+ }
330161
+ function isIdentityStrategy(strategy) {
330162
+ if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330163
+ if (strategy.using === "xpath") return strategy.value.includes("@");
330164
+ return false;
330165
+ }
330166
+ function buildIdentityLocatorStrategies(target, platform3) {
330167
+ if (!target) return [];
330168
+ const strategies = [];
330169
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330170
+ if (bundle) {
330171
+ if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330172
+ if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330173
+ for (const value of [bundle.text, bundle.contentDesc]) {
330174
+ const trimmed = value?.trim();
330175
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330176
+ }
330177
+ if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330178
+ }
330179
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330180
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330181
+ for (const strategy of locatorStrategyForValue(value)) {
330182
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330183
+ }
330184
+ }
330185
+ }
330186
+ return dedupeStrategies(strategies);
330187
+ }
330026
330188
  function readStepTimeout(step) {
330027
330189
  const value = step.metadata?.timeout;
330028
330190
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -330030,10 +330192,12 @@ function readStepTimeout(step) {
330030
330192
  }
330031
330193
  return void 0;
330032
330194
  }
330033
- async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
330034
- const strategies = buildLocatorStrategies(target);
330195
+ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330196
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
330035
330197
  if (strategies.length === 0) {
330036
- throw new Error("No locator strategies available for this step");
330198
+ throw new Error(
330199
+ opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
330200
+ );
330037
330201
  }
330038
330202
  const startTime = Date.now();
330039
330203
  let lastError = "";
@@ -330131,18 +330295,18 @@ function shouldAbortAfterStepFailure(action) {
330131
330295
  return false;
330132
330296
  }
330133
330297
  }
330134
- async function executeTap(sessionId, step) {
330135
- const bounds = boundsFromStep(step);
330298
+ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
330299
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330136
330300
  if (step.target) {
330137
330301
  try {
330138
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330302
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330139
330303
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
330140
330304
  method: "POST",
330141
330305
  body: "{}"
330142
330306
  });
330143
330307
  return;
330144
330308
  } catch (error2) {
330145
- if (!bounds) throw error2;
330309
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330146
330310
  }
330147
330311
  }
330148
330312
  if (!bounds) {
@@ -330153,7 +330317,7 @@ async function executeTap(sessionId, step) {
330153
330317
  async function executeType(sessionId, step, context) {
330154
330318
  if (step.value === void 0 || step.value === null || step.value === "") return;
330155
330319
  const value = String(step.value);
330156
- const bounds = boundsFromStep(step);
330320
+ const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
330157
330321
  let elementId = null;
330158
330322
  let lastInputError = null;
330159
330323
  let tappedBounds = false;
@@ -330170,12 +330334,12 @@ async function executeType(sessionId, step, context) {
330170
330334
  }
330171
330335
  if (step.target) {
330172
330336
  try {
330173
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330337
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
330174
330338
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330175
330339
  if (!lastInputError) return;
330176
330340
  elementId = null;
330177
330341
  } catch (error2) {
330178
- if (!bounds) throw error2;
330342
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
330179
330343
  lastInputError = error2;
330180
330344
  }
330181
330345
  }
@@ -330223,14 +330387,14 @@ async function executeType(sessionId, step, context) {
330223
330387
  }
330224
330388
  throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
330225
330389
  }
330226
- async function executeClear(sessionId, step) {
330227
- const bounds = boundsFromStep(step);
330390
+ async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
330391
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330228
330392
  let elementId = null;
330229
330393
  if (step.target) {
330230
330394
  try {
330231
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330395
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330232
330396
  } catch (error2) {
330233
- if (!bounds) throw error2;
330397
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330234
330398
  }
330235
330399
  }
330236
330400
  if (!elementId && bounds) {
@@ -330299,20 +330463,20 @@ async function executeHome(sessionId, platform3) {
330299
330463
  body: JSON.stringify({ keycode: 3 })
330300
330464
  });
330301
330465
  }
330302
- async function executeWaitForElement(sessionId, step) {
330466
+ async function executeWaitForElement(sessionId, step, platform3) {
330303
330467
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330304
- await findElement(sessionId, step.target, timeout);
330468
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
330305
330469
  }
330306
- async function executeAssertVisible(sessionId, step) {
330307
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330470
+ async function executeAssertVisible(sessionId, step, platform3) {
330471
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330308
330472
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330309
330473
  if (!result?.value) {
330310
330474
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
330311
330475
  }
330312
330476
  }
330313
- async function executeAssertText(sessionId, step) {
330477
+ async function executeAssertText(sessionId, step, platform3) {
330314
330478
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330315
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330479
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330316
330480
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330317
330481
  const actualText = typeof result?.value === "string" ? result.value : "";
330318
330482
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330347,6 +330511,7 @@ async function executeMobileTest(options) {
330347
330511
  const screenshots = [];
330348
330512
  let apiCalls = [];
330349
330513
  let sessionId = null;
330514
+ let selectedDevice;
330350
330515
  let capture = null;
330351
330516
  let captureStopped = false;
330352
330517
  let recordingStarted = false;
@@ -330394,7 +330559,6 @@ async function executeMobileTest(options) {
330394
330559
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
330395
330560
  );
330396
330561
  }
330397
- let selectedDevice;
330398
330562
  if (options.target.assignedDeviceId) {
330399
330563
  selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
330400
330564
  if (!selectedDevice) {
@@ -330435,13 +330599,15 @@ async function executeMobileTest(options) {
330435
330599
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
330436
330600
  }
330437
330601
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
330602
+ const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
330438
330603
  try {
330439
330604
  sessionId = await createSessionWithRetry({
330440
330605
  appId: options.target.appId,
330441
330606
  platform: options.target.platform,
330442
330607
  deviceId: selectedDevice.id,
330443
330608
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
330444
- isPhysical: selectedDevice.isPhysical
330609
+ isPhysical: selectedDevice.isPhysical,
330610
+ portOffset: leasedPortOffset
330445
330611
  }, (msg) => log2(msg));
330446
330612
  } catch (err) {
330447
330613
  const base2 = err instanceof Error ? err.message : String(err);
@@ -330457,6 +330623,12 @@ async function executeMobileTest(options) {
330457
330623
  throw err;
330458
330624
  }
330459
330625
  log2(`Session created: ${sessionId}`);
330626
+ if (selectedDevice.id) {
330627
+ try {
330628
+ options.onSessionReady?.(sessionId, selectedDevice.id, options.target.platform);
330629
+ } catch {
330630
+ }
330631
+ }
330460
330632
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
330461
330633
  log2("Opening a fresh app instance for the test...");
330462
330634
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
@@ -330469,8 +330641,34 @@ async function executeMobileTest(options) {
330469
330641
  }
330470
330642
  }
330471
330643
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330644
+ let entryDrift = false;
330645
+ const runPlatform = options.target.platform;
330646
+ const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330647
+ const entryStep = options.steps.find(
330648
+ (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330649
+ );
330650
+ if (entryStep) {
330651
+ try {
330652
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
330653
+ } catch (err) {
330654
+ if (isTransportError(err)) throw err;
330655
+ entryDrift = true;
330656
+ 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).`;
330657
+ log2(` \u2717 Entry-screen drift: ${driftMsg}`);
330658
+ stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
330659
+ const driftShot = await takeScreenshot(sessionId).catch(() => null);
330660
+ screenshots.push(driftShot ?? "");
330661
+ }
330662
+ }
330472
330663
  let environmental = null;
330473
330664
  for (const step of options.steps) {
330665
+ if (entryDrift) break;
330666
+ if (!stepAppliesToPlatform(step, runPlatform)) {
330667
+ log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
330668
+ stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
330669
+ screenshots.push("");
330670
+ continue;
330671
+ }
330474
330672
  const stepStart = Date.now();
330475
330673
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330476
330674
  let status = "passed";
@@ -330482,17 +330680,18 @@ async function executeMobileTest(options) {
330482
330680
  log2(" App launched (handled by session creation)");
330483
330681
  break;
330484
330682
  case "tap":
330485
- await executeTap(sessionId, step);
330683
+ await executeTap(sessionId, step, runPlatform, isCrossPlatform);
330486
330684
  break;
330487
330685
  case "type":
330488
330686
  await executeType(sessionId, step, {
330489
- platform: options.target.platform,
330687
+ platform: runPlatform,
330490
330688
  deviceId: selectedDevice.id,
330491
- log: log2
330689
+ log: log2,
330690
+ isCrossPlatform
330492
330691
  });
330493
330692
  break;
330494
330693
  case "clear":
330495
- await executeClear(sessionId, step);
330694
+ await executeClear(sessionId, step, runPlatform, isCrossPlatform);
330496
330695
  break;
330497
330696
  case "swipe":
330498
330697
  case "scroll":
@@ -330502,16 +330701,16 @@ async function executeMobileTest(options) {
330502
330701
  await executeBack(sessionId);
330503
330702
  break;
330504
330703
  case "home":
330505
- await executeHome(sessionId, options.target.platform);
330704
+ await executeHome(sessionId, runPlatform);
330506
330705
  break;
330507
330706
  case "waitForElement":
330508
- await executeWaitForElement(sessionId, step);
330707
+ await executeWaitForElement(sessionId, step, runPlatform);
330509
330708
  break;
330510
330709
  case "assertVisible":
330511
- await executeAssertVisible(sessionId, step);
330710
+ await executeAssertVisible(sessionId, step, runPlatform);
330512
330711
  break;
330513
330712
  case "assertText":
330514
- await executeAssertText(sessionId, step);
330713
+ await executeAssertText(sessionId, step, runPlatform);
330515
330714
  break;
330516
330715
  default: {
330517
330716
  const _exhaustive = step.action;
@@ -330548,8 +330747,14 @@ async function executeMobileTest(options) {
330548
330747
  break;
330549
330748
  }
330550
330749
  }
330551
- const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330552
- const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330750
+ const executedResults = stepResults.filter((r) => r.status !== "skipped");
330751
+ const verifyOrders = new Set(
330752
+ options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330753
+ );
330754
+ const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330755
+ const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
330756
+ const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330757
+ const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null) ?? (crossPlatformUnverified ? `Cross-platform run on ${runPlatform} verified no outcome \u2014 every assertion/wait step was skipped or unresolved. Author a ${runPlatform} assertion or resolve the divergence.` : null);
330553
330758
  await stopCapture();
330554
330759
  await stopRecording();
330555
330760
  return {
@@ -330561,6 +330766,8 @@ async function executeMobileTest(options) {
330561
330766
  logs: logLines.join("\n"),
330562
330767
  error: firstError,
330563
330768
  duration: Date.now() - startTime,
330769
+ deviceId: selectedDevice?.id,
330770
+ deviceName: selectedDevice?.name,
330564
330771
  environmental: environmental ?? void 0
330565
330772
  };
330566
330773
  } catch (err) {
@@ -330577,6 +330784,8 @@ async function executeMobileTest(options) {
330577
330784
  logs: logLines.join("\n"),
330578
330785
  error: errorMsg,
330579
330786
  duration: Date.now() - startTime,
330787
+ deviceId: selectedDevice?.id,
330788
+ deviceName: selectedDevice?.name,
330580
330789
  // A throw before/around the step loop is a setup/environment problem
330581
330790
  // (no device booted, app not installed, session could not be created,
330582
330791
  // Appium unreachable) — never a genuine test assertion failure.
@@ -330586,6 +330795,12 @@ async function executeMobileTest(options) {
330586
330795
  await stopCapture();
330587
330796
  await stopRecording();
330588
330797
  if (sessionId) {
330798
+ if (selectedDevice?.id) {
330799
+ try {
330800
+ options.onSessionEnding?.(selectedDevice.id);
330801
+ } catch {
330802
+ }
330803
+ }
330589
330804
  try {
330590
330805
  log2("Closing the app on the device...");
330591
330806
  await terminateTargetApp(sessionId, options.target.platform, options.target.appId);
@@ -330644,7 +330859,9 @@ async function createMobileDiscoverySession(target, options) {
330644
330859
  platform: target.platform,
330645
330860
  deviceId: selectedDevice.id,
330646
330861
  platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
330647
- isPhysical: selectedDevice.isPhysical
330862
+ isPhysical: selectedDevice.isPhysical,
330863
+ // Distinct control port per concurrent physical device (no-op for emulators/sims).
330864
+ portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
330648
330865
  });
330649
330866
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
330650
330867
  try {
@@ -330656,6 +330873,7 @@ async function createMobileDiscoverySession(target, options) {
330656
330873
  return {
330657
330874
  sessionId,
330658
330875
  deviceId: selectedDevice.id,
330876
+ deviceName: selectedDevice.name,
330659
330877
  isPhysical: selectedDevice.isPhysical === true,
330660
330878
  platform: target.platform,
330661
330879
  appId: target.appId,
@@ -330670,6 +330888,184 @@ async function createMobileDiscoverySession(target, options) {
330670
330888
  }
330671
330889
  };
330672
330890
  }
330891
+ async function readElementAttribute(sessionId, elementId, name) {
330892
+ try {
330893
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
330894
+ const value = result?.value;
330895
+ if (typeof value !== "string") return void 0;
330896
+ const trimmed = value.trim();
330897
+ return trimmed.length > 0 ? trimmed : void 0;
330898
+ } catch (err) {
330899
+ if (isTransportError(err)) throw err;
330900
+ return void 0;
330901
+ }
330902
+ }
330903
+ function harvestFallbackXpath(platform3, attrs) {
330904
+ if (platform3 === "IOS") {
330905
+ if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
330906
+ const text = attrs.label ?? attrs.value;
330907
+ if (text) return identityTextXpath(text);
330908
+ return "//*";
330909
+ }
330910
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
330911
+ if (attrs.text) return identityTextXpath(attrs.text);
330912
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
330913
+ return "//*";
330914
+ }
330915
+ async function readNativeElementBundle(sessionId, elementId, platform3) {
330916
+ const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
330917
+ if (platform3 === "IOS") {
330918
+ const name = await readElementAttribute(sessionId, elementId, "name");
330919
+ const label = await readElementAttribute(sessionId, elementId, "label");
330920
+ const value = await readElementAttribute(sessionId, elementId, "value");
330921
+ const text2 = label ?? value;
330922
+ if (!name && !text2) return null;
330923
+ const bundle2 = {
330924
+ xpath: harvestFallbackXpath("IOS", { name, label, value }),
330925
+ className
330926
+ };
330927
+ if (name) bundle2.accessibilityId = name;
330928
+ if (text2) bundle2.text = text2;
330929
+ return bundle2;
330930
+ }
330931
+ const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
330932
+ const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
330933
+ const text = await readElementAttribute(sessionId, elementId, "text");
330934
+ if (!resourceId && !contentDesc && !text) return null;
330935
+ const bundle = {
330936
+ xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
330937
+ className
330938
+ };
330939
+ if (contentDesc) bundle.accessibilityId = contentDesc;
330940
+ if (resourceId) bundle.resourceId = resourceId;
330941
+ if (text) bundle.text = text;
330942
+ if (contentDesc) bundle.contentDesc = contentDesc;
330943
+ return bundle;
330944
+ }
330945
+ function isStateAdvancingAction(action) {
330946
+ return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
330947
+ }
330948
+ function stepNeedsLocator(action) {
330949
+ return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
330950
+ }
330951
+ async function driveHarvestStep(sessionId, step, elementId, platform3) {
330952
+ switch (step.action) {
330953
+ case "tap":
330954
+ if (!elementId) return false;
330955
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
330956
+ return true;
330957
+ case "type": {
330958
+ if (step.value === void 0 || step.value === null || step.value === "") return true;
330959
+ if (!elementId) return false;
330960
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
330961
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330962
+ const err = await trySendElementValue(sessionId, elementId, String(step.value));
330963
+ return !err;
330964
+ }
330965
+ case "clear":
330966
+ if (!elementId) return false;
330967
+ await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
330968
+ return true;
330969
+ case "swipe":
330970
+ case "scroll":
330971
+ await executeSwipe(sessionId, step);
330972
+ return true;
330973
+ case "back":
330974
+ await executeBack(sessionId);
330975
+ return true;
330976
+ case "home":
330977
+ await executeHome(sessionId, platform3);
330978
+ return true;
330979
+ case "launchApp":
330980
+ case "waitForElement":
330981
+ case "assertVisible":
330982
+ case "assertText":
330983
+ return true;
330984
+ }
330985
+ }
330986
+ async function harvestPlatformLocators(options) {
330987
+ const platform3 = options.target.platform;
330988
+ const logLines = [];
330989
+ const log2 = (msg) => {
330990
+ logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330991
+ };
330992
+ const bundles = {};
330993
+ const divergentOrders = [];
330994
+ let reachedOrder = null;
330995
+ let environmentalError;
330996
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
330997
+ const session = await createMobileDiscoverySession({
330998
+ appId: options.target.appId,
330999
+ platform: platform3,
331000
+ platformVersion: options.target.platformVersion,
331001
+ recordedDeviceId: options.target.recordedDeviceId,
331002
+ assignedDeviceId: options.target.assignedDeviceId,
331003
+ launchMode: "LAUNCH_APP"
331004
+ });
331005
+ options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
331006
+ try {
331007
+ await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
331008
+ if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
331009
+ try {
331010
+ await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
331011
+ } catch (err) {
331012
+ log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
331013
+ }
331014
+ }
331015
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331016
+ for (const step of options.steps) {
331017
+ if (step.action === "launchApp") continue;
331018
+ if (!stepAppliesToPlatform(step, platform3)) continue;
331019
+ let elementId = null;
331020
+ const identityStrategies = buildIdentityLocatorStrategies(step.target);
331021
+ if (identityStrategies.length > 0 && step.target) {
331022
+ try {
331023
+ elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
331024
+ } catch (err) {
331025
+ if (isTransportError(err)) throw err;
331026
+ elementId = null;
331027
+ }
331028
+ }
331029
+ if (elementId) {
331030
+ const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
331031
+ if (bundle) {
331032
+ bundles[step.order] = bundle;
331033
+ log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
331034
+ } else {
331035
+ divergentOrders.push(step.order);
331036
+ log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
331037
+ }
331038
+ } else if (stepNeedsLocator(step.action)) {
331039
+ divergentOrders.push(step.order);
331040
+ log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
331041
+ }
331042
+ const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
331043
+ if (!advanced && isStateAdvancingAction(step.action)) {
331044
+ log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
331045
+ break;
331046
+ }
331047
+ reachedOrder = step.order;
331048
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
331049
+ }
331050
+ } catch (err) {
331051
+ environmentalError = err instanceof Error ? err.message : String(err);
331052
+ log2(` \u2717 Harvest aborted: ${environmentalError}`);
331053
+ } finally {
331054
+ options.onSessionEnding?.(session.deviceId);
331055
+ await session.stop().catch(() => {
331056
+ });
331057
+ }
331058
+ return {
331059
+ platform: platform3,
331060
+ bundles,
331061
+ divergentOrders,
331062
+ reachedOrder,
331063
+ deviceId: session.deviceId,
331064
+ deviceName: session.deviceName,
331065
+ logs: logLines.join("\n"),
331066
+ ...environmentalError ? { environmentalError } : {}
331067
+ };
331068
+ }
330673
331069
 
330674
331070
  // src/mobile-device-queue.ts
330675
331071
  function isNativeMobileDiscoveryContext(ctx) {
@@ -331381,7 +331777,146 @@ async function handleSessionStop(_req, res) {
331381
331777
  stopSessionReaper();
331382
331778
  sendJSON(res, 200, { ok: true });
331383
331779
  }
331780
+ var runnerChannels = /* @__PURE__ */ new Map();
331781
+ function readDeviceIdParam(req) {
331782
+ try {
331783
+ const url = new URL(req.url || "", "http://localhost");
331784
+ const raw = url.searchParams.get("deviceId");
331785
+ if (!raw) return null;
331786
+ const trimmed = raw.trim();
331787
+ if (!trimmed || trimmed.length > MAX_DEVICE_ID_LENGTH || !DEVICE_ID_PATTERN.test(trimmed)) return null;
331788
+ return trimmed;
331789
+ } catch {
331790
+ return null;
331791
+ }
331792
+ }
331793
+ async function isSessionAlive(sessionId) {
331794
+ try {
331795
+ validateSessionId(sessionId);
331796
+ await appiumRequest2(`/session/${sessionId}/window/rect`);
331797
+ return true;
331798
+ } catch (err) {
331799
+ const msg = err instanceof Error ? err.message : String(err);
331800
+ return !/no such session|invalid session id|session is either terminated|deleted session/i.test(msg);
331801
+ }
331802
+ }
331803
+ function writeFrameToChannelClient(ch, client, frame) {
331804
+ try {
331805
+ const buffered = typeof client.writableLength === "number" ? client.writableLength : typeof client.socket?.writableLength === "number" ? client.socket.writableLength : 0;
331806
+ if (buffered > SSE_MAX_BUFFERED_BYTES) {
331807
+ try {
331808
+ client.end();
331809
+ } catch {
331810
+ }
331811
+ ch.clients.delete(client);
331812
+ stopChannelPollingIfIdle(ch);
331813
+ return;
331814
+ }
331815
+ client.write(`data: ${JSON.stringify(frame)}
331816
+
331817
+ `);
331818
+ } catch {
331819
+ }
331820
+ }
331821
+ async function pollRunnerChannel(ch) {
331822
+ if (ch.clients.size === 0 || ch.polling) return;
331823
+ ch.polling = true;
331824
+ try {
331825
+ validateSessionId(ch.sessionId);
331826
+ const [screenshot, source] = await Promise.all([
331827
+ getScreenshot(`/session/${ch.sessionId}`),
331828
+ getPageSource(`/session/${ch.sessionId}`)
331829
+ ]);
331830
+ if (!screenshot && !source) {
331831
+ ch.nullTicks++;
331832
+ if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
331833
+ const alive = await isSessionAlive(ch.sessionId);
331834
+ if (!alive) {
331835
+ closeRunnerChannel(ch);
331836
+ return;
331837
+ }
331838
+ ch.nullTicks = 0;
331839
+ }
331840
+ } else {
331841
+ ch.nullTicks = 0;
331842
+ }
331843
+ const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
331844
+ ch.lastFrame = frame;
331845
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
331846
+ } catch {
331847
+ } finally {
331848
+ ch.polling = false;
331849
+ }
331850
+ }
331851
+ function ensureChannelPolling(ch) {
331852
+ if (ch.pollTimer) return;
331853
+ ch.pollTimer = setInterval(() => {
331854
+ void pollRunnerChannel(ch);
331855
+ }, MOBILE_SCREENSHOT_INTERVAL_MS);
331856
+ ch.pollTimer.unref?.();
331857
+ }
331858
+ function stopChannelPollingIfIdle(ch) {
331859
+ if (ch.clients.size === 0 && ch.pollTimer) {
331860
+ clearInterval(ch.pollTimer);
331861
+ ch.pollTimer = null;
331862
+ }
331863
+ }
331864
+ function closeRunnerChannel(ch) {
331865
+ if (ch.pollTimer) {
331866
+ clearInterval(ch.pollTimer);
331867
+ ch.pollTimer = null;
331868
+ }
331869
+ for (const client of ch.clients) {
331870
+ try {
331871
+ client.end();
331872
+ } catch {
331873
+ }
331874
+ }
331875
+ ch.clients.clear();
331876
+ ch.lastFrame = null;
331877
+ runnerChannels.delete(ch.deviceId);
331878
+ }
331879
+ function closeAllRunnerChannels() {
331880
+ for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
331881
+ }
331882
+ async function serveRunnerMirror(req, res, ch) {
331883
+ res.writeHead(200, {
331884
+ "Content-Type": "text/event-stream",
331885
+ "Cache-Control": "no-cache",
331886
+ "Connection": "keep-alive",
331887
+ "Access-Control-Allow-Origin": getAllowedOrigin(req),
331888
+ "X-Content-Type-Options": "nosniff"
331889
+ });
331890
+ ch.clients.add(res);
331891
+ ensureChannelPolling(ch);
331892
+ if (ch.lastFrame) {
331893
+ writeFrameToChannelClient(ch, res, ch.lastFrame);
331894
+ } else {
331895
+ await pollRunnerChannel(ch);
331896
+ }
331897
+ req.on("close", () => {
331898
+ ch.clients.delete(res);
331899
+ stopChannelPollingIfIdle(ch);
331900
+ });
331901
+ }
331384
331902
  async function handleMirror(req, res) {
331903
+ const deviceId = readDeviceIdParam(req);
331904
+ if (deviceId) {
331905
+ const ch = runnerChannels.get(deviceId);
331906
+ if (ch) {
331907
+ await serveRunnerMirror(req, res, ch);
331908
+ return;
331909
+ }
331910
+ sendJSON(res, 404, { error: `No live mirror for device ${deviceId}.` });
331911
+ return;
331912
+ }
331913
+ if (!state.appiumSessionId && runnerChannels.size === 1) {
331914
+ const soleChannel = runnerChannels.values().next().value;
331915
+ if (soleChannel) {
331916
+ await serveRunnerMirror(req, res, soleChannel);
331917
+ return;
331918
+ }
331919
+ }
331385
331920
  if (!state.appiumSessionId) {
331386
331921
  sendJSON(res, 400, { error: "No active Appium session. Call /bridge/session/start first." });
331387
331922
  return;
@@ -331828,6 +332363,7 @@ async function startMobileBridge(options) {
331828
332363
  }
331829
332364
  async function stopMobileBridge() {
331830
332365
  stopAllMirrorClients();
332366
+ closeAllRunnerChannels();
331831
332367
  stopSessionReaper();
331832
332368
  clearBridgePidFile();
331833
332369
  if (state.appiumSessionId) {
@@ -331876,8 +332412,31 @@ function getBridgeState() {
331876
332412
  function setExecutorBusy(busy) {
331877
332413
  executorBusyCount = busy ? executorBusyCount + 1 : Math.max(0, executorBusyCount - 1);
331878
332414
  }
331879
- function attachMirrorSession(sessionId, platform3) {
332415
+ function attachMirrorSession(sessionId, platform3, deviceId) {
331880
332416
  validateSessionId(sessionId);
332417
+ if (deviceId) {
332418
+ const existing = runnerChannels.get(deviceId);
332419
+ if (existing && existing.sessionId !== sessionId) closeRunnerChannel(existing);
332420
+ const ch = runnerChannels.get(deviceId) ?? {
332421
+ deviceId,
332422
+ sessionId,
332423
+ platform: platform3,
332424
+ clients: /* @__PURE__ */ new Set(),
332425
+ lastFrame: null,
332426
+ pollTimer: null,
332427
+ nullTicks: 0,
332428
+ polling: false
332429
+ };
332430
+ ch.sessionId = sessionId;
332431
+ ch.platform = platform3;
332432
+ ch.nullTicks = 0;
332433
+ runnerChannels.set(deviceId, ch);
332434
+ if (ch.clients.size > 0) {
332435
+ ensureChannelPolling(ch);
332436
+ void pollRunnerChannel(ch);
332437
+ }
332438
+ return;
332439
+ }
331881
332440
  if (state.appiumSessionId && state.appiumSessionId !== sessionId) {
331882
332441
  stopAllMirrorClients();
331883
332442
  }
@@ -331892,8 +332451,12 @@ function attachMirrorSession(sessionId, platform3) {
331892
332451
  void pollAndBroadcastFrame();
331893
332452
  }
331894
332453
  }
331895
- function detachMirrorSession(sessionId) {
331896
- if (sessionId && state.appiumSessionId && state.appiumSessionId !== sessionId) return;
332454
+ function detachMirrorSession(deviceId) {
332455
+ if (deviceId) {
332456
+ const ch = runnerChannels.get(deviceId);
332457
+ if (ch) closeRunnerChannel(ch);
332458
+ return;
332459
+ }
331897
332460
  state.appiumSessionId = null;
331898
332461
  state.platform = null;
331899
332462
  mirrorSessionOwner = null;
@@ -333760,6 +334323,102 @@ ${finalVerifyResult.logs ?? ""}`;
333760
334323
  log.fail("ERROR - No discovery context");
333761
334324
  return;
333762
334325
  }
334326
+ const locatorCtx = ctx;
334327
+ if (locatorCtx.mode === "locator-discovery") {
334328
+ const harvestTarget = locatorCtx.target;
334329
+ const targetTestCaseId = locatorCtx.targetTestCaseId;
334330
+ const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
334331
+ if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
334332
+ await postResult(opts.serverUrl, headers, run2.runId, {
334333
+ status: "ERROR",
334334
+ error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
334335
+ });
334336
+ log.fail("ERROR - malformed locator-discovery context");
334337
+ return;
334338
+ }
334339
+ if (getBridgeState().hasActiveSession) {
334340
+ await postResult(opts.serverUrl, headers, run2.runId, {
334341
+ status: "ERROR",
334342
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
334343
+ });
334344
+ log.fail("ERROR - device busy (recording) for locator discovery");
334345
+ return;
334346
+ }
334347
+ const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334348
+ liveBrowserHandle.patch({
334349
+ mode: "discovery",
334350
+ testName: "Cross-platform locator discovery",
334351
+ note: `${platform3} locator discovery`,
334352
+ surface: "mobile",
334353
+ mobilePlatform: platform3,
334354
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334355
+ ...leasedName ? { deviceName: leasedName } : {}
334356
+ });
334357
+ requestLiveBrowserHeartbeat(true);
334358
+ log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
334359
+ setExecutorBusy(true);
334360
+ try {
334361
+ const harvest = await harvestPlatformLocators({
334362
+ steps: locatorCtx.sourceSteps ?? [],
334363
+ target: {
334364
+ appId: harvestTarget.appId,
334365
+ platform: platform3,
334366
+ platformVersion: harvestTarget.platformVersion,
334367
+ recordedDeviceId: harvestTarget.recordedDeviceId,
334368
+ assignedDeviceId,
334369
+ launchMode: harvestTarget.launchMode,
334370
+ deeplink: harvestTarget.deeplink
334371
+ },
334372
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334373
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334374
+ const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
334375
+ liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
334376
+ requestLiveBrowserHeartbeat(true);
334377
+ },
334378
+ onSessionEnding: (deviceId) => {
334379
+ detachMirrorSession(deviceId);
334380
+ }
334381
+ });
334382
+ let queuedRunId = null;
334383
+ let resultPosted = false;
334384
+ try {
334385
+ const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
334386
+ method: "POST",
334387
+ headers: { ...headers, "Content-Type": "application/json" },
334388
+ body: JSON.stringify({
334389
+ bundles: harvest.bundles,
334390
+ divergentOrders: harvest.divergentOrders,
334391
+ reachedOrder: harvest.reachedOrder,
334392
+ logs: harvest.logs,
334393
+ ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
334394
+ }),
334395
+ signal: AbortSignal.timeout(3e4)
334396
+ });
334397
+ if (res.ok) {
334398
+ resultPosted = true;
334399
+ const data = await res.json().catch(() => null);
334400
+ queuedRunId = data?.queuedRunId ?? null;
334401
+ } else {
334402
+ log.warn(`[locator-discovery] result POST returned ${res.status}`);
334403
+ }
334404
+ } catch (postErr) {
334405
+ log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
334406
+ }
334407
+ const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
334408
+ await postResult(opts.serverUrl, headers, run2.runId, {
334409
+ status: passed ? "PASSED" : "ERROR",
334410
+ error: !resultPosted ? "CONFIG_ISSUE: failed to persist harvested cross-platform locators (result POST did not succeed); retriable." : harvest.environmentalError ? `CONFIG_ISSUE: ${harvest.environmentalError}` : harvest.reachedOrder == null ? "CONFIG_ISSUE: locator discovery could not reach any step on the target platform." : void 0,
334411
+ logs: harvest.logs,
334412
+ duration: Date.now() - startTime
334413
+ });
334414
+ log[passed ? "ok" : "fail"](
334415
+ `Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
334416
+ );
334417
+ } finally {
334418
+ setExecutorBusy(false);
334419
+ }
334420
+ return;
334421
+ }
333763
334422
  log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
333764
334423
  const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
333765
334424
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
@@ -334079,7 +334738,8 @@ ${finalVerifyResult.logs ?? ""}`;
334079
334738
  testName: ctx.featureName ?? "Mobile discovery",
334080
334739
  note: `${platform3} mobile discovery`,
334081
334740
  surface: "mobile",
334082
- mobilePlatform: platform3
334741
+ mobilePlatform: platform3,
334742
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {}
334083
334743
  });
334084
334744
  requestLiveBrowserHeartbeat(true);
334085
334745
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
@@ -334097,6 +334757,7 @@ ${finalVerifyResult.logs ?? ""}`;
334097
334757
  deeplink: mobileTarget.deeplink
334098
334758
  }, {
334099
334759
  onDeviceSelected: async (device) => {
334760
+ liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
334100
334761
  try {
334101
334762
  capture = await startMobileNetworkCapture({
334102
334763
  platform: platform3,
@@ -334115,7 +334776,7 @@ ${finalVerifyResult.logs ?? ""}`;
334115
334776
  }
334116
334777
  });
334117
334778
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
334118
- attachMirrorSession(session.sessionId, platform3);
334779
+ attachMirrorSession(session.sessionId, platform3, session.deviceId);
334119
334780
  const driver = new MobileBridgeDriver({
334120
334781
  sessionId: session.sessionId,
334121
334782
  platform: platform3,
@@ -334159,7 +334820,7 @@ ${finalVerifyResult.logs ?? ""}`;
334159
334820
  }
334160
334821
  }
334161
334822
  if (session) {
334162
- detachMirrorSession(session.sessionId);
334823
+ detachMirrorSession(session.deviceId);
334163
334824
  try {
334164
334825
  await session.stop();
334165
334826
  } catch {
@@ -334436,6 +335097,14 @@ ${finalVerifyResult.logs ?? ""}`;
334436
335097
  return;
334437
335098
  }
334438
335099
  log.info(`\u{1F4F1} Mobile test - ${target.platform} / ${target.appId}`);
335100
+ const leasedDeviceName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
335101
+ liveBrowserHandle.patch({
335102
+ surface: "mobile",
335103
+ mobilePlatform: target.platform,
335104
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
335105
+ ...leasedDeviceName ? { deviceName: leasedDeviceName } : {}
335106
+ });
335107
+ requestLiveBrowserHeartbeat(true);
334439
335108
  const mobileSteps = run2.steps ?? [];
334440
335109
  if (mobileSteps.length === 0) {
334441
335110
  await postResult(opts.serverUrl, headers, run2.runId, {
@@ -334463,7 +335132,20 @@ ${finalVerifyResult.logs ?? ""}`;
334463
335132
  recordedDeviceId: target.recordedDeviceId,
334464
335133
  assignedDeviceId,
334465
335134
  launchMode: target.launchMode,
334466
- deeplink: target.deeplink
335135
+ deeplink: target.deeplink,
335136
+ // Authoring platform (test's session medium). When it differs from
335137
+ // target.platform this is a cross-platform run: no blind coordinate
335138
+ // taps on foreign geometry, and the run must verify an outcome.
335139
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
335140
+ },
335141
+ // Stream this run to its OWN per-device dashboard tile (keyed by the
335142
+ // leased device id) so N parallel Appium runs each show a live mirror.
335143
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
335144
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
335145
+ requestLiveBrowserHeartbeat(true);
335146
+ },
335147
+ onSessionEnding: (deviceId) => {
335148
+ detachMirrorSession(deviceId);
334467
335149
  }
334468
335150
  });
334469
335151
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
@@ -334479,6 +335161,12 @@ ${result2.logs}`);
334479
335161
  if (attemptLogs.length > 1) {
334480
335162
  result2 = { ...result2, logs: attemptLogs.join("\n\n") };
334481
335163
  }
335164
+ if (result2.deviceId || result2.deviceName) {
335165
+ liveBrowserHandle.patch({
335166
+ ...result2.deviceId ? { deviceId: result2.deviceId } : {},
335167
+ ...result2.deviceName ? { deviceName: result2.deviceName } : {}
335168
+ });
335169
+ }
334482
335170
  const duration2 = Date.now() - startTime;
334483
335171
  const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
334484
335172
  const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;