@validate.qa/runner 1.0.16 → 1.0.18

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 +730 -71
  2. package/dist/cli.mjs +730 -71
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1023,6 +1023,10 @@ var require_mobile_source_parser = __commonJS({
1023
1023
  }
1024
1024
  return fnv1a(parts.join("\n"));
1025
1025
  }
1026
+ var EDITABLE_CLASS_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
1027
+ function isEditableElement(el) {
1028
+ return EDITABLE_CLASS_RE.test(el.role.trim());
1029
+ }
1026
1030
  function buildLocatorBundle(el, platform3) {
1027
1031
  const bundle = {
1028
1032
  xpath: el.xpath,
@@ -1032,7 +1036,7 @@ var require_mobile_source_parser = __commonJS({
1032
1036
  bundle.accessibilityId = el.accessibilityId;
1033
1037
  if (platform3 === "ANDROID" && el.resourceId)
1034
1038
  bundle.resourceId = el.resourceId;
1035
- if (el.text)
1039
+ if (el.text && !isEditableElement(el))
1036
1040
  bundle.text = el.text;
1037
1041
  if (platform3 === "ANDROID" && el.accessibilityId)
1038
1042
  bundle.contentDesc = el.accessibilityId;
@@ -1043,7 +1047,7 @@ var require_mobile_source_parser = __commonJS({
1043
1047
  return el.accessibilityId;
1044
1048
  if (el.resourceId)
1045
1049
  return el.resourceId;
1046
- if (el.text)
1050
+ if (el.text && !isEditableElement(el))
1047
1051
  return el.text;
1048
1052
  return el.xpath;
1049
1053
  }
@@ -1363,6 +1367,31 @@ function toInlineCommentText(value) {
1363
1367
  function isPlainObject(value) {
1364
1368
  return typeof value === "object" && value !== null && !Array.isArray(value);
1365
1369
  }
1370
+ function isEditableMobileClassName(className) {
1371
+ if (!className)
1372
+ return false;
1373
+ return /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/.test(className.trim());
1374
+ }
1375
+ function mobileCredentialPlaceholder(key) {
1376
+ return `{{${key}}}`;
1377
+ }
1378
+ function resolveMobileStepValuePlaceholders(steps, variables) {
1379
+ const missing = /* @__PURE__ */ new Set();
1380
+ const vars = variables ?? {};
1381
+ const resolved = steps.map((step) => {
1382
+ if (typeof step.value !== "string" || !step.value.includes("{{"))
1383
+ return step;
1384
+ const value = step.value.replace(MOBILE_VALUE_PLACEHOLDER_RE, (token, key) => {
1385
+ const substitute = vars[key];
1386
+ if (typeof substitute === "string" && substitute.length > 0)
1387
+ return substitute;
1388
+ missing.add(key);
1389
+ return token;
1390
+ });
1391
+ return value === step.value ? step : { ...step, value };
1392
+ });
1393
+ return { steps: resolved, missingKeys: [...missing] };
1394
+ }
1366
1395
  function readMobileMetadata(value) {
1367
1396
  if (!isPlainObject(value)) {
1368
1397
  return { screenName: "UnknownScreen" };
@@ -1404,7 +1433,11 @@ function buildStepTarget(metadata, selector) {
1404
1433
  const primary = nonEmpty(locatorBundle.accessibilityId) ?? nonEmpty(locatorBundle.resourceId) ?? trimmedSelector ?? nonEmpty(locatorBundle.xpath) ?? locatorBundle.xpath;
1405
1434
  const fallbackCandidates = [
1406
1435
  locatorBundle.resourceId,
1407
- locatorBundle.text,
1436
+ // For editable fields the text attribute IS the currently-typed value — a
1437
+ // self-invalidating locator that only matches while the recorded text is
1438
+ // still in the field (and it can leak typed input into locators). Static
1439
+ // elements keep their text fallback.
1440
+ isEditableMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
1408
1441
  locatorBundle.contentDesc,
1409
1442
  locatorBundle.xpath,
1410
1443
  selector
@@ -1835,7 +1868,7 @@ function isRunnableStep(step) {
1835
1868
  return false;
1836
1869
  }
1837
1870
  }
1838
- var STEP_ACTION_BY_ACTION_TYPE;
1871
+ var STEP_ACTION_BY_ACTION_TYPE, MOBILE_VALUE_PLACEHOLDER_RE;
1839
1872
  var init_mobile_test_generation = __esm({
1840
1873
  "../shared/dist/mobile-test-generation.js"() {
1841
1874
  "use strict";
@@ -1853,6 +1886,103 @@ var init_mobile_test_generation = __esm({
1853
1886
  ASSERT_VISIBLE: "assertVisible",
1854
1887
  ASSERT_TEXT: "assertText"
1855
1888
  };
1889
+ MOBILE_VALUE_PLACEHOLDER_RE = /\{\{([A-Za-z0-9_]+)\}\}/g;
1890
+ }
1891
+ });
1892
+
1893
+ // ../shared/dist/mobile-cross-platform.js
1894
+ function isMobilePlatform(value) {
1895
+ return value === "IOS" || value === "ANDROID";
1896
+ }
1897
+ function mediumForMobilePlatform(platform3) {
1898
+ return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
1899
+ }
1900
+ function mobilePlatformFromMedium(medium) {
1901
+ if (medium === "IOS_NATIVE")
1902
+ return "IOS";
1903
+ if (medium === "ANDROID_NATIVE")
1904
+ return "ANDROID";
1905
+ return null;
1906
+ }
1907
+ function otherMobilePlatform(platform3) {
1908
+ return platform3 === "IOS" ? "ANDROID" : "IOS";
1909
+ }
1910
+ function platformTag(platform3) {
1911
+ return platform3 === "IOS" ? "@ios" : "@android";
1912
+ }
1913
+ function resolvePlatformLocatorBundle(target, platform3) {
1914
+ if (!target)
1915
+ return void 0;
1916
+ if (platform3) {
1917
+ const overlay = target.platformLocators?.[platform3];
1918
+ if (overlay)
1919
+ return overlay;
1920
+ }
1921
+ return target.locatorBundle;
1922
+ }
1923
+ function hasPlatformLocator(target, platform3) {
1924
+ return Boolean(target?.platformLocators?.[platform3]);
1925
+ }
1926
+ function hasCrossPlatformIdentity(bundle) {
1927
+ if (!bundle)
1928
+ return false;
1929
+ return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
1930
+ }
1931
+ function stepIsDivergentForPlatform(step, platform3) {
1932
+ return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
1933
+ }
1934
+ function stepAppliesToPlatform(step, platform3) {
1935
+ const platforms = step.platforms;
1936
+ if (!platforms || platforms.length === 0)
1937
+ return true;
1938
+ if (!platform3)
1939
+ return true;
1940
+ return platforms.includes(platform3);
1941
+ }
1942
+ function stepsMissingPlatformLocators(steps, platform3) {
1943
+ return steps.filter((step) => {
1944
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1945
+ return false;
1946
+ if (!stepAppliesToPlatform(step, platform3))
1947
+ return false;
1948
+ if (hasPlatformLocator(step.target, platform3))
1949
+ return false;
1950
+ if (stepIsDivergentForPlatform(step, platform3))
1951
+ return false;
1952
+ return hasCrossPlatformIdentity(step.target?.locatorBundle);
1953
+ });
1954
+ }
1955
+ function stepsNeedingReviewForPlatform(steps, platform3) {
1956
+ return steps.filter((step) => {
1957
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1958
+ return false;
1959
+ if (!stepAppliesToPlatform(step, platform3))
1960
+ return false;
1961
+ if (hasPlatformLocator(step.target, platform3))
1962
+ return false;
1963
+ if (stepIsDivergentForPlatform(step, platform3))
1964
+ return true;
1965
+ return !hasCrossPlatformIdentity(step.target?.locatorBundle);
1966
+ });
1967
+ }
1968
+ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
1969
+ if (runPlatform === authoringPlatform)
1970
+ return true;
1971
+ return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
1972
+ }
1973
+ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
1974
+ var init_mobile_cross_platform = __esm({
1975
+ "../shared/dist/mobile-cross-platform.js"() {
1976
+ "use strict";
1977
+ MOBILE_PLATFORMS = ["IOS", "ANDROID"];
1978
+ LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
1979
+ "tap",
1980
+ "type",
1981
+ "clear",
1982
+ "waitForElement",
1983
+ "assertVisible",
1984
+ "assertText"
1985
+ ]);
1856
1986
  }
1857
1987
  });
1858
1988
 
@@ -3534,6 +3664,7 @@ __export(dist_exports, {
3534
3664
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
3535
3665
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
3536
3666
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
3667
+ MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
3537
3668
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
3538
3669
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
3539
3670
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
@@ -3587,6 +3718,8 @@ __export(dist_exports, {
3587
3718
  getPlanDefinition: () => getPlanDefinition,
3588
3719
  getPlanLabel: () => getPlanLabel,
3589
3720
  getTrialDaysRemaining: () => getTrialDaysRemaining,
3721
+ hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
3722
+ hasPlatformLocator: () => hasPlatformLocator,
3590
3723
  hostOfOrigin: () => hostOfOrigin,
3591
3724
  isAddonType: () => isAddonType,
3592
3725
  isApiTest: () => isApiTest,
@@ -3595,24 +3728,38 @@ __export(dist_exports, {
3595
3728
  isCountedFailure: () => isCountedFailure,
3596
3729
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3597
3730
  isDbPricingPlan: () => isDbPricingPlan,
3731
+ isEditableMobileClassName: () => isEditableMobileClassName,
3598
3732
  isEnvironmentalError: () => isEnvironmentalError,
3733
+ isMobilePlatform: () => isMobilePlatform,
3599
3734
  isPricingPlan: () => isPricingPlan,
3600
3735
  isRunnableStep: () => isRunnableStep,
3601
3736
  isSafeDeepLink: () => isSafeDeepLink,
3602
3737
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
3738
+ isTestReadyForPlatform: () => isTestReadyForPlatform,
3603
3739
  isTrialExpired: () => isTrialExpired,
3604
3740
  isUIVariant: () => isUIVariant,
3605
3741
  isValidMobileAppId: () => isValidMobileAppId,
3742
+ mediumForMobilePlatform: () => mediumForMobilePlatform,
3743
+ mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
3744
+ mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3606
3745
  normalizeControlName: () => normalizeControlName,
3746
+ otherMobilePlatform: () => otherMobilePlatform,
3607
3747
  parenDepthDelta: () => parenDepthDelta,
3608
3748
  planHasBillingPortal: () => planHasBillingPortal,
3609
3749
  planRequiresCheckout: () => planRequiresCheckout,
3750
+ platformTag: () => platformTag,
3610
3751
  registrableDomain: () => registrableDomain,
3611
3752
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3753
+ resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
3754
+ resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3612
3755
  safeOrigin: () => safeOrigin,
3613
3756
  sanitizeTestVariables: () => sanitizeTestVariables,
3614
3757
  selectorCandidatesForStep: () => selectorCandidatesForStep,
3615
3758
  selectorForValue: () => selectorForValue,
3759
+ stepAppliesToPlatform: () => stepAppliesToPlatform,
3760
+ stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
3761
+ stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
3762
+ stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
3616
3763
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
3617
3764
  stripLoginScaffolding: () => stripLoginScaffolding,
3618
3765
  summarizeHealth: () => summarizeHealth,
@@ -3626,6 +3773,7 @@ var init_dist = __esm({
3626
3773
  init_types();
3627
3774
  init_constants();
3628
3775
  init_mobile_test_generation();
3776
+ init_mobile_cross_platform();
3629
3777
  init_mobile_deeplink();
3630
3778
  init_plans();
3631
3779
  init_audit_pricing();
@@ -65462,6 +65610,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65462
65610
  6. Do NOT merge screens into one feature just because they serve a similar broader goal. Treat areas as separate features when the evidence shows separate primary navigation destinations (e.g. separate tab-bar items or drawer links), separate screen families for distinct entities, or different CRUD entities/forms. Keep screens together when they share one navigational area and operate on the same entity (e.g. a list screen, its filter/sort sheet, and its detail screen all belong together).
65463
65611
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65464
65612
  8. Do NOT create standalone features for generic error / empty screens (network-error, permission-denied). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description.
65613
+ 9. COVER EVERY SCREEN: every explored screen except generic error / permission screens MUST appear in exactly one feature group's routes. Do not leave screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
65465
65614
 
65466
65615
  ## Output JSON Schema
65467
65616
  {
@@ -65669,7 +65818,8 @@ ${context.appBrief.slice(0, 500)}`);
65669
65818
  sections.push(`## App Under Test
65670
65819
  ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
65671
65820
  if (context.featureName) {
65672
- sections.push(`## Feature: ${context.featureName}`);
65821
+ sections.push(`## Feature: ${context.featureName}
65822
+ SCOPE: this is a feature-focused deep run. Plan scenarios ONLY for the "${context.featureName}" feature \u2014 its screens, forms, and flows. Screens belonging to other features are navigation context / prerequisites only; do NOT plan scenarios whose primary goal exercises another feature.`);
65673
65823
  }
65674
65824
  const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
65675
65825
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66054,10 +66204,12 @@ var require_mobile_generator = __commonJS({
66054
66204
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
66055
66205
  "use strict";
66056
66206
  Object.defineProperty(exports2, "__esModule", { value: true });
66207
+ exports2.collapseRetryCycles = collapseRetryCycles;
66057
66208
  exports2.runMobileGeneratePhase = runMobileGeneratePhase;
66058
66209
  var crypto_1 = require("crypto");
66059
66210
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
66060
66211
  var ai_provider_js_1 = require_ai_provider();
66212
+ var credential_tools_js_1 = require_credential_tools();
66061
66213
  var ai_queue_js_1 = require_ai_queue();
66062
66214
  var ai_retry_js_1 = require_ai_retry();
66063
66215
  var mobile_source_parser_js_1 = require_mobile_source_parser();
@@ -66179,6 +66331,24 @@ var require_mobile_generator = __commonJS({
66179
66331
  }
66180
66332
  }
66181
66333
  };
66334
+ var MOBILE_FILL_CREDENTIAL_TOOL = {
66335
+ type: "function",
66336
+ function: {
66337
+ name: "mobile_fill_credential",
66338
+ description: 'Fill a stored credential VALUE into a text field. ALWAYS use this instead of mobile_type for ANY email/password/token/auth field. Pass the field ref (eN) and the variable KEY (e.g. "TEST_USER_EMAIL"); the runner types the real value on the device and the generated test stores a {{KEY}} placeholder \u2014 you never see the secret. Available keys are listed in the Test Credentials section of the system prompt.',
66339
+ parameters: {
66340
+ type: "object",
66341
+ properties: {
66342
+ ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
66343
+ credentialKey: {
66344
+ type: "string",
66345
+ description: 'The credential variable name (e.g. "TEST_USER_EMAIL", "TEST_USER_PASSWORD"). Must be one of the keys listed in the Test Credentials section of the prompt.'
66346
+ }
66347
+ },
66348
+ required: ["ref", "credentialKey"]
66349
+ }
66350
+ }
66351
+ };
66182
66352
  var FINISH_GENERATE_TOOL = {
66183
66353
  type: "function",
66184
66354
  function: {
@@ -66200,6 +66370,7 @@ var require_mobile_generator = __commonJS({
66200
66370
  MOBILE_TAP_TOOL,
66201
66371
  MOBILE_TYPE_TOOL,
66202
66372
  MOBILE_CLEAR_TOOL,
66373
+ MOBILE_FILL_CREDENTIAL_TOOL,
66203
66374
  MOBILE_SWIPE_TOOL,
66204
66375
  MOBILE_BACK_TOOL,
66205
66376
  MOBILE_RELAUNCH_TOOL,
@@ -66278,6 +66449,24 @@ var require_mobile_generator = __commonJS({
66278
66449
  - Finish promptly once the outcome is proven: the instant the expected outcome is visible, assert it and call finish_generate. Do not keep exploring after the goal is proven.
66279
66450
 
66280
66451
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
66452
+ }
66453
+ function buildGenerateCredentialPromptBlock(secrets) {
66454
+ const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
66455
+ if (keys.length === 0)
66456
+ return "";
66457
+ const keyList = keys.map((k) => `- \`${k}\``).join("\n");
66458
+ return `
66459
+
66460
+ ## Test Credentials (values are HIDDEN from you)
66461
+ This app has stored login credentials under these keys:
66462
+ ${keyList}
66463
+
66464
+ When a scenario needs a signed-in session or fills a login/sign-up form, fill each credential field with \`mobile_fill_credential\`: pass the field ref and the KEY (e.g. \`{ ref: "e5", credentialKey: "TEST_USER_EMAIL" }\`). The runner types the real value on the device and the generated test stores a \`{{KEY}}\` placeholder that replay resolves \u2014 you never see, log, or echo the secret.
66465
+
66466
+ SECURITY RULES:
66467
+ - NEVER call \`mobile_type\` with a real email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
66468
+ - For validation scenarios (wrong password, empty submit) you MAY \`mobile_type\` synthetic invalid values like "wrong@example.com" / "badpassword" \u2014 those are not real credentials and are allowed.
66469
+ - NEVER put credential values in test names, descriptions, or assert texts.`;
66281
66470
  }
66282
66471
  function buildScenarioKickoff(scenario) {
66283
66472
  const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
@@ -66348,7 +66537,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66348
66537
  }
66349
66538
  }
66350
66539
  async function runScenarioLoop(args) {
66351
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
66540
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
66352
66541
  const actions = [];
66353
66542
  let assertCount = 0;
66354
66543
  let finish = null;
@@ -66465,7 +66654,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66465
66654
  } catch (err) {
66466
66655
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66467
66656
  }
66468
- const systemPrompt = buildSystemPrompt(platform3, targetAppId);
66657
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
66469
66658
  const kickoff = buildScenarioKickoff(scenario);
66470
66659
  const recentExchanges = [];
66471
66660
  let iteration = 0;
@@ -66578,6 +66767,7 @@ ${statePacket}` },
66578
66767
  latest: getLatest,
66579
66768
  absorbObservation,
66580
66769
  resolveRef,
66770
+ credentials,
66581
66771
  screenName: () => lastScreenName || "UnknownScreen",
66582
66772
  recordAction: (action) => {
66583
66773
  actions.push(finalizeAction(action));
@@ -66599,7 +66789,7 @@ ${statePacket}` },
66599
66789
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
66600
66790
  }
66601
66791
  async function dispatchGenerateTool(args) {
66602
- const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
66792
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
66603
66793
  const unknownRef = (ref) => ({
66604
66794
  ok: false,
66605
66795
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -66665,6 +66855,14 @@ ${statePacket}` },
66665
66855
  if (!element)
66666
66856
  return unknownRef(toolArgs.ref);
66667
66857
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66858
+ for (const [key, secret] of Object.entries(credentials)) {
66859
+ if (secret && secret.length >= 4 && value.includes(secret)) {
66860
+ return {
66861
+ ok: false,
66862
+ error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
66863
+ };
66864
+ }
66865
+ }
66668
66866
  const beforeScreenName = screenName();
66669
66867
  const result = await driver.type(value, element.bounds);
66670
66868
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
@@ -66678,6 +66876,37 @@ ${statePacket}` },
66678
66876
  }
66679
66877
  return actionPayload(result, absorbed);
66680
66878
  }
66879
+ // KEY-based credential fill: type the real value on-device, persist only a
66880
+ // {{KEY}} placeholder in the recorded step (replay resolves it against the
66881
+ // run's current test variables). The tool result echoes the KEY, never the
66882
+ // value, so the secret cannot enter the transcript.
66883
+ case "mobile_fill_credential": {
66884
+ const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
66885
+ if (!key) {
66886
+ return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
66887
+ }
66888
+ const secret = credentials[key];
66889
+ if (!secret) {
66890
+ const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
66891
+ return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
66892
+ }
66893
+ const element = resolveRef(toolArgs.ref);
66894
+ if (!element)
66895
+ return unknownRef(toolArgs.ref);
66896
+ const beforeScreenName = screenName();
66897
+ const result = await driver.type(secret, element.bounds);
66898
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
66899
+ if (shouldRecordAction(result, absorbed)) {
66900
+ recordAction({
66901
+ type: "TYPE",
66902
+ value: (0, shared_1.mobileCredentialPlaceholder)(key),
66903
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
66904
+ metadata: metadataForElement(element, beforeScreenName, platform3)
66905
+ });
66906
+ log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
66907
+ }
66908
+ return { ...actionPayload(result, absorbed), filledCredentialKey: key };
66909
+ }
66681
66910
  case "mobile_clear": {
66682
66911
  const element = resolveRef(toolArgs.ref);
66683
66912
  if (!element)
@@ -66826,18 +67055,40 @@ ${statePacket}` },
66826
67055
  return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
66827
67056
  }
66828
67057
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
66829
- var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
66830
- "android:id/content",
66831
- "android:id/decor",
66832
- "android:id/action_bar_root",
66833
- "android:id/statusBarBackground",
66834
- "android:id/navigationBarBackground"
67058
+ var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
67059
+ "content",
67060
+ "decor",
67061
+ "decor_content_parent",
67062
+ "action_bar_root",
67063
+ "statusBarBackground",
67064
+ "navigationBarBackground",
67065
+ // WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
67066
+ // expose their framework ROOT container's DOM id as the element id — present on
67067
+ // EVERY in-app screen, so the floor must not anchor to it (observed live on
67068
+ // ThoughtStream: `main-content` produced a screen-agnostic assertion that then
67069
+ // never matched via the text-contains fallback).
67070
+ "main-content",
67071
+ "root",
67072
+ "app",
67073
+ "app-root",
67074
+ "__next",
67075
+ "react-root",
67076
+ "application"
66835
67077
  ]);
66836
67078
  function isAlwaysPresentContainer(element) {
66837
67079
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
66838
67080
  return true;
66839
67081
  const resourceId = element.resourceId?.trim();
66840
- return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
67082
+ if (resourceId) {
67083
+ const slashIdx = resourceId.indexOf("/");
67084
+ const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
67085
+ if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
67086
+ return true;
67087
+ }
67088
+ const accessibilityId = element.accessibilityId?.trim();
67089
+ if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
67090
+ return true;
67091
+ return false;
66841
67092
  }
66842
67093
  function appendHybridFloor(actions, recording, platform3) {
66843
67094
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -66851,9 +67102,38 @@ ${statePacket}` },
66851
67102
  }));
66852
67103
  return true;
66853
67104
  }
67105
+ function actionCycleKey(action) {
67106
+ return `${action.type}|${action.selector}|${action.value ?? ""}`;
67107
+ }
67108
+ var MAX_RETRY_CYCLE_LENGTH = 8;
67109
+ function collapseRetryCycles(actions) {
67110
+ const out = [...actions];
67111
+ let changed = true;
67112
+ while (changed) {
67113
+ changed = false;
67114
+ const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
67115
+ for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
67116
+ for (let i = 0; i + 2 * window2 <= out.length; i++) {
67117
+ const first = out.slice(i, i + window2);
67118
+ if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
67119
+ continue;
67120
+ const second = out.slice(i + window2, i + 2 * window2);
67121
+ if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
67122
+ continue;
67123
+ out.splice(i, window2);
67124
+ changed = true;
67125
+ break;
67126
+ }
67127
+ }
67128
+ }
67129
+ return out;
67130
+ }
66854
67131
  function assembleTest(args) {
66855
67132
  const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
66856
- const actions = [...recording.actions];
67133
+ const actions = collapseRetryCycles(recording.actions);
67134
+ if (actions.length < recording.actions.length) {
67135
+ log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
67136
+ }
66857
67137
  const floorAppended = appendHybridFloor(actions, recording, platform3);
66858
67138
  if (floorAppended) {
66859
67139
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -66880,9 +67160,9 @@ ${statePacket}` },
66880
67160
  if (claimedVerified && !verified) {
66881
67161
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
66882
67162
  }
66883
- const platformTag = platform3 === "IOS" ? "@ios" : "@android";
67163
+ const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
66884
67164
  const reviewTag = verified ? "@verified" : "@needs-review";
66885
- const tags = ["@discovered", "@mobile", platformTag, reviewTag];
67165
+ const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
66886
67166
  const test = {
66887
67167
  name: compiled.name,
66888
67168
  description: recording.finish?.description ?? compiled.description,
@@ -66892,6 +67172,12 @@ ${statePacket}` },
66892
67172
  playwrightCode: "",
66893
67173
  verified,
66894
67174
  tags,
67175
+ // Per-test feature label → TestCase.suite. One deep run plans scenarios for
67176
+ // MULTIPLE feature groups (the planner reuses survey feature names), so the
67177
+ // run-level ctx.featureName fallback in /discovery-test would wrongly file
67178
+ // every test under the deep-dived feature. The scenario's own group is the
67179
+ // correct home; the runner forwards it as `suiteName`.
67180
+ ...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
66895
67181
  // Carry the planner scenario identity so incremental/resume can match an
66896
67182
  // already-generated scenario reliably (scenarioRef.name === the plan's
66897
67183
  // scenario name). Without this the resume skip-set falls back to the
@@ -66909,7 +67195,27 @@ ${statePacket}` },
66909
67195
  startFromLanding: scenario.startFromLanding,
66910
67196
  targetPages: [...scenario.targetPages]
66911
67197
  },
66912
- evidence: { observedLocators: [], observedTransitions: [] }
67198
+ evidence: { observedLocators: [], observedTransitions: [] },
67199
+ // Assertion-strength marker for the server promote-seam
67200
+ // (resolveStabilityTransition): an UNVERIFIED recording proves none of the
67201
+ // scenario's outcomes — only the hybrid floor's landing-screen visibility —
67202
+ // so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
67203
+ // human instead of letting the pass auto-promote it into the ACTIVE suite.
67204
+ // Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
67205
+ ...verified ? {} : {
67206
+ gateActivity: {
67207
+ outcomeGate: {
67208
+ requiredCount: scenario.expectedOutcomes.length,
67209
+ uncoveredCount: scenario.expectedOutcomes.length
67210
+ },
67211
+ strengthGate: {
67212
+ fired: false,
67213
+ severity: "block",
67214
+ blocked: true,
67215
+ reason: "Mobile scenario finished unverified \u2014 the agent never proved the scenario outcome live, so the recording may not demonstrate the goal (e.g. a happy path that never completed). Review before promotion."
67216
+ }
67217
+ }
67218
+ }
66913
67219
  }
66914
67220
  };
66915
67221
  return test;
@@ -66930,6 +67236,7 @@ ${statePacket}` },
66930
67236
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
66931
67237
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
66932
67238
  const targetAppId = ctx.target?.appId?.trim() || void 0;
67239
+ const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
66933
67240
  const skipSet = new Set(skipScenarioNames ?? []);
66934
67241
  const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
66935
67242
  if (skipSet.size > 0) {
@@ -66958,6 +67265,7 @@ ${statePacket}` },
66958
67265
  onAICall,
66959
67266
  onScreenshot,
66960
67267
  targetAppId,
67268
+ credentials: credentialSecrets,
66961
67269
  log: log2
66962
67270
  });
66963
67271
  consecutiveInfraFailures = 0;
@@ -313580,9 +313888,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
313580
313888
  });
313581
313889
  let featureInsight;
313582
313890
  if (mode === "deep" && ctx.featureName) {
313891
+ const targetName = ctx.featureName.trim().toLowerCase();
313892
+ const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
313893
+ const plannedDescription = plannedGroup?.description?.trim();
313583
313894
  featureInsight = {
313584
313895
  featureName: ctx.featureName,
313585
- description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313896
+ description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313586
313897
  };
313587
313898
  }
313588
313899
  const duration = (Date.now() - startTime) / 1e3;
@@ -328118,7 +328429,7 @@ var require_package4 = __commonJS({
328118
328429
  "package.json"(exports2, module2) {
328119
328430
  module2.exports = {
328120
328431
  name: "@validate.qa/runner",
328121
- version: "1.0.16",
328432
+ version: "1.0.18",
328122
328433
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328123
328434
  bin: {
328124
328435
  "validate-runner": "dist/cli.js"
@@ -329659,6 +329970,7 @@ async function startMobileNetworkCapture(options) {
329659
329970
 
329660
329971
  // src/services/appium-executor.ts
329661
329972
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
329973
+ var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
329662
329974
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
329663
329975
  var WD_ACTION_TIMEOUT_MS = 3e4;
329664
329976
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
@@ -329987,6 +330299,11 @@ function boundsFromStep(step) {
329987
330299
  }
329988
330300
  return null;
329989
330301
  }
330302
+ function boundsForRun(step, platform3, isCrossPlatform) {
330303
+ if (isCrossPlatform) return null;
330304
+ if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
330305
+ return boundsFromStep(step);
330306
+ }
329990
330307
  function locatorStrategyForValue(value) {
329991
330308
  const trimmed = value?.trim();
329992
330309
  if (!trimmed) {
@@ -330006,19 +330323,21 @@ function locatorStrategyForValue(value) {
330006
330323
  { using: "id", value: trimmed }
330007
330324
  ];
330008
330325
  }
330009
- function buildLocatorStrategies(target) {
330326
+ function buildLocatorStrategies(target, platform3) {
330010
330327
  if (!target) return [];
330011
330328
  const strategies = [];
330012
- const bundle = target.locatorBundle;
330329
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330013
330330
  if (bundle) {
330014
330331
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330015
330332
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330016
330333
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
330017
330334
  if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
330018
330335
  }
330019
- strategies.push(...locatorStrategyForValue(target.primary));
330020
- for (const fallback of target.fallbacks ?? []) {
330021
- strategies.push(...locatorStrategyForValue(fallback));
330336
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330337
+ strategies.push(...locatorStrategyForValue(target.primary));
330338
+ for (const fallback of target.fallbacks ?? []) {
330339
+ strategies.push(...locatorStrategyForValue(fallback));
330340
+ }
330022
330341
  }
330023
330342
  return strategies.filter(
330024
330343
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
@@ -330044,10 +330363,10 @@ function isIdentityStrategy(strategy) {
330044
330363
  if (strategy.using === "xpath") return strategy.value.includes("@");
330045
330364
  return false;
330046
330365
  }
330047
- function buildIdentityLocatorStrategies(target) {
330366
+ function buildIdentityLocatorStrategies(target, platform3) {
330048
330367
  if (!target) return [];
330049
330368
  const strategies = [];
330050
- const bundle = target.locatorBundle;
330369
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330051
330370
  if (bundle) {
330052
330371
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330053
330372
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
@@ -330057,9 +330376,11 @@ function buildIdentityLocatorStrategies(target) {
330057
330376
  }
330058
330377
  if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330059
330378
  }
330060
- for (const value of [target.primary, ...target.fallbacks ?? []]) {
330061
- for (const strategy of locatorStrategyForValue(value)) {
330062
- if (isIdentityStrategy(strategy)) strategies.push(strategy);
330379
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330380
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330381
+ for (const strategy of locatorStrategyForValue(value)) {
330382
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330383
+ }
330063
330384
  }
330064
330385
  }
330065
330386
  return dedupeStrategies(strategies);
@@ -330072,7 +330393,7 @@ function readStepTimeout(step) {
330072
330393
  return void 0;
330073
330394
  }
330074
330395
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330075
- const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
330396
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
330076
330397
  if (strategies.length === 0) {
330077
330398
  throw new Error(
330078
330399
  opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
@@ -330174,18 +330495,18 @@ function shouldAbortAfterStepFailure(action) {
330174
330495
  return false;
330175
330496
  }
330176
330497
  }
330177
- async function executeTap(sessionId, step) {
330178
- const bounds = boundsFromStep(step);
330498
+ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
330499
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330179
330500
  if (step.target) {
330180
330501
  try {
330181
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330502
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330182
330503
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
330183
330504
  method: "POST",
330184
330505
  body: "{}"
330185
330506
  });
330186
330507
  return;
330187
330508
  } catch (error2) {
330188
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330509
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330189
330510
  }
330190
330511
  }
330191
330512
  if (!bounds) {
@@ -330196,7 +330517,7 @@ async function executeTap(sessionId, step) {
330196
330517
  async function executeType(sessionId, step, context) {
330197
330518
  if (step.value === void 0 || step.value === null || step.value === "") return;
330198
330519
  const value = String(step.value);
330199
- const bounds = boundsFromStep(step);
330520
+ const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
330200
330521
  let elementId = null;
330201
330522
  let lastInputError = null;
330202
330523
  let tappedBounds = false;
@@ -330213,12 +330534,12 @@ async function executeType(sessionId, step, context) {
330213
330534
  }
330214
330535
  if (step.target) {
330215
330536
  try {
330216
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330537
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
330217
330538
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330218
330539
  if (!lastInputError) return;
330219
330540
  elementId = null;
330220
330541
  } catch (error2) {
330221
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330542
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
330222
330543
  lastInputError = error2;
330223
330544
  }
330224
330545
  }
@@ -330266,14 +330587,14 @@ async function executeType(sessionId, step, context) {
330266
330587
  }
330267
330588
  throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
330268
330589
  }
330269
- async function executeClear(sessionId, step) {
330270
- const bounds = boundsFromStep(step);
330590
+ async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
330591
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330271
330592
  let elementId = null;
330272
330593
  if (step.target) {
330273
330594
  try {
330274
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330595
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330275
330596
  } catch (error2) {
330276
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330597
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330277
330598
  }
330278
330599
  }
330279
330600
  if (!elementId && bounds) {
@@ -330342,20 +330663,20 @@ async function executeHome(sessionId, platform3) {
330342
330663
  body: JSON.stringify({ keycode: 3 })
330343
330664
  });
330344
330665
  }
330345
- async function executeWaitForElement(sessionId, step) {
330666
+ async function executeWaitForElement(sessionId, step, platform3) {
330346
330667
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330347
- await findElement(sessionId, step.target, timeout, { identityOnly: true });
330668
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
330348
330669
  }
330349
- async function executeAssertVisible(sessionId, step) {
330350
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330670
+ async function executeAssertVisible(sessionId, step, platform3) {
330671
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330351
330672
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330352
330673
  if (!result?.value) {
330353
330674
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
330354
330675
  }
330355
330676
  }
330356
- async function executeAssertText(sessionId, step) {
330677
+ async function executeAssertText(sessionId, step, platform3) {
330357
330678
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330358
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330679
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330359
330680
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330360
330681
  const actualText = typeof result?.value === "string" ? result.value : "";
330361
330682
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330386,6 +330707,10 @@ async function executeMobileTest(options) {
330386
330707
  const logLines = [];
330387
330708
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330388
330709
  const startTime = Date.now();
330710
+ const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
330711
+ if (missingVariableKeys.length > 0) {
330712
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
330713
+ }
330389
330714
  const stepResults = [];
330390
330715
  const screenshots = [];
330391
330716
  let apiCalls = [];
@@ -330521,12 +330846,14 @@ async function executeMobileTest(options) {
330521
330846
  }
330522
330847
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330523
330848
  let entryDrift = false;
330524
- const entryStep = options.steps.find(
330525
- (s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
330849
+ const runPlatform = options.target.platform;
330850
+ const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330851
+ const entryStep = runSteps.find(
330852
+ (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330526
330853
  );
330527
330854
  if (entryStep) {
330528
330855
  try {
330529
- await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
330856
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
330530
330857
  } catch (err) {
330531
330858
  if (isTransportError(err)) throw err;
330532
330859
  entryDrift = true;
@@ -330538,8 +330865,14 @@ async function executeMobileTest(options) {
330538
330865
  }
330539
330866
  }
330540
330867
  let environmental = null;
330541
- for (const step of options.steps) {
330868
+ for (const step of runSteps) {
330542
330869
  if (entryDrift) break;
330870
+ if (!stepAppliesToPlatform(step, runPlatform)) {
330871
+ log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
330872
+ stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
330873
+ screenshots.push("");
330874
+ continue;
330875
+ }
330543
330876
  const stepStart = Date.now();
330544
330877
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330545
330878
  let status = "passed";
@@ -330551,17 +330884,18 @@ async function executeMobileTest(options) {
330551
330884
  log2(" App launched (handled by session creation)");
330552
330885
  break;
330553
330886
  case "tap":
330554
- await executeTap(sessionId, step);
330887
+ await executeTap(sessionId, step, runPlatform, isCrossPlatform);
330555
330888
  break;
330556
330889
  case "type":
330557
330890
  await executeType(sessionId, step, {
330558
- platform: options.target.platform,
330891
+ platform: runPlatform,
330559
330892
  deviceId: selectedDevice.id,
330560
- log: log2
330893
+ log: log2,
330894
+ isCrossPlatform
330561
330895
  });
330562
330896
  break;
330563
330897
  case "clear":
330564
- await executeClear(sessionId, step);
330898
+ await executeClear(sessionId, step, runPlatform, isCrossPlatform);
330565
330899
  break;
330566
330900
  case "swipe":
330567
330901
  case "scroll":
@@ -330571,16 +330905,16 @@ async function executeMobileTest(options) {
330571
330905
  await executeBack(sessionId);
330572
330906
  break;
330573
330907
  case "home":
330574
- await executeHome(sessionId, options.target.platform);
330908
+ await executeHome(sessionId, runPlatform);
330575
330909
  break;
330576
330910
  case "waitForElement":
330577
- await executeWaitForElement(sessionId, step);
330911
+ await executeWaitForElement(sessionId, step, runPlatform);
330578
330912
  break;
330579
330913
  case "assertVisible":
330580
- await executeAssertVisible(sessionId, step);
330914
+ await executeAssertVisible(sessionId, step, runPlatform);
330581
330915
  break;
330582
330916
  case "assertText":
330583
- await executeAssertText(sessionId, step);
330917
+ await executeAssertText(sessionId, step, runPlatform);
330584
330918
  break;
330585
330919
  default: {
330586
330920
  const _exhaustive = step.action;
@@ -330617,8 +330951,14 @@ async function executeMobileTest(options) {
330617
330951
  break;
330618
330952
  }
330619
330953
  }
330620
- const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330621
- const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330954
+ const executedResults = stepResults.filter((r) => r.status !== "skipped");
330955
+ const verifyOrders = new Set(
330956
+ runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330957
+ );
330958
+ const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330959
+ const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
330960
+ const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330961
+ const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null) ?? (crossPlatformUnverified ? `Cross-platform run on ${runPlatform} verified no outcome \u2014 every assertion/wait step was skipped or unresolved. Author a ${runPlatform} assertion or resolve the divergence.` : null);
330622
330962
  await stopCapture();
330623
330963
  await stopRecording();
330624
330964
  return {
@@ -330752,6 +331092,188 @@ async function createMobileDiscoverySession(target, options) {
330752
331092
  }
330753
331093
  };
330754
331094
  }
331095
+ async function readElementAttribute(sessionId, elementId, name) {
331096
+ try {
331097
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
331098
+ const value = result?.value;
331099
+ if (typeof value !== "string") return void 0;
331100
+ const trimmed = value.trim();
331101
+ return trimmed.length > 0 ? trimmed : void 0;
331102
+ } catch (err) {
331103
+ if (isTransportError(err)) throw err;
331104
+ return void 0;
331105
+ }
331106
+ }
331107
+ function harvestFallbackXpath(platform3, attrs) {
331108
+ if (platform3 === "IOS") {
331109
+ if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
331110
+ const text = attrs.label ?? attrs.value;
331111
+ if (text) return identityTextXpath(text);
331112
+ return "//*";
331113
+ }
331114
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
331115
+ if (attrs.text) return identityTextXpath(attrs.text);
331116
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
331117
+ return "//*";
331118
+ }
331119
+ async function readNativeElementBundle(sessionId, elementId, platform3) {
331120
+ const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
331121
+ if (platform3 === "IOS") {
331122
+ const name = await readElementAttribute(sessionId, elementId, "name");
331123
+ const label = await readElementAttribute(sessionId, elementId, "label");
331124
+ const value = await readElementAttribute(sessionId, elementId, "value");
331125
+ const text2 = label ?? value;
331126
+ if (!name && !text2) return null;
331127
+ const bundle2 = {
331128
+ xpath: harvestFallbackXpath("IOS", { name, label, value }),
331129
+ className
331130
+ };
331131
+ if (name) bundle2.accessibilityId = name;
331132
+ if (text2) bundle2.text = text2;
331133
+ return bundle2;
331134
+ }
331135
+ const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
331136
+ const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
331137
+ const text = await readElementAttribute(sessionId, elementId, "text");
331138
+ if (!resourceId && !contentDesc && !text) return null;
331139
+ const bundle = {
331140
+ xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
331141
+ className
331142
+ };
331143
+ if (contentDesc) bundle.accessibilityId = contentDesc;
331144
+ if (resourceId) bundle.resourceId = resourceId;
331145
+ if (text) bundle.text = text;
331146
+ if (contentDesc) bundle.contentDesc = contentDesc;
331147
+ return bundle;
331148
+ }
331149
+ function isStateAdvancingAction(action) {
331150
+ return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
331151
+ }
331152
+ function stepNeedsLocator(action) {
331153
+ return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
331154
+ }
331155
+ async function driveHarvestStep(sessionId, step, elementId, platform3) {
331156
+ switch (step.action) {
331157
+ case "tap":
331158
+ if (!elementId) return false;
331159
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
331160
+ return true;
331161
+ case "type": {
331162
+ if (step.value === void 0 || step.value === null || step.value === "") return true;
331163
+ if (!elementId) return false;
331164
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
331165
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331166
+ const err = await trySendElementValue(sessionId, elementId, String(step.value));
331167
+ return !err;
331168
+ }
331169
+ case "clear":
331170
+ if (!elementId) return false;
331171
+ await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
331172
+ return true;
331173
+ case "swipe":
331174
+ case "scroll":
331175
+ await executeSwipe(sessionId, step);
331176
+ return true;
331177
+ case "back":
331178
+ await executeBack(sessionId);
331179
+ return true;
331180
+ case "home":
331181
+ await executeHome(sessionId, platform3);
331182
+ return true;
331183
+ case "launchApp":
331184
+ case "waitForElement":
331185
+ case "assertVisible":
331186
+ case "assertText":
331187
+ return true;
331188
+ }
331189
+ }
331190
+ async function harvestPlatformLocators(options) {
331191
+ const platform3 = options.target.platform;
331192
+ const logLines = [];
331193
+ const log2 = (msg) => {
331194
+ logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
331195
+ };
331196
+ const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
331197
+ if (missingVariableKeys.length > 0) {
331198
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
331199
+ }
331200
+ const bundles = {};
331201
+ const divergentOrders = [];
331202
+ let reachedOrder = null;
331203
+ let environmentalError;
331204
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
331205
+ const session = await createMobileDiscoverySession({
331206
+ appId: options.target.appId,
331207
+ platform: platform3,
331208
+ platformVersion: options.target.platformVersion,
331209
+ recordedDeviceId: options.target.recordedDeviceId,
331210
+ assignedDeviceId: options.target.assignedDeviceId,
331211
+ launchMode: "LAUNCH_APP"
331212
+ });
331213
+ options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
331214
+ try {
331215
+ await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
331216
+ if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
331217
+ try {
331218
+ await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
331219
+ } catch (err) {
331220
+ log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
331221
+ }
331222
+ }
331223
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331224
+ for (const step of harvestSteps) {
331225
+ if (step.action === "launchApp") continue;
331226
+ if (!stepAppliesToPlatform(step, platform3)) continue;
331227
+ let elementId = null;
331228
+ const identityStrategies = buildIdentityLocatorStrategies(step.target);
331229
+ if (identityStrategies.length > 0 && step.target) {
331230
+ try {
331231
+ elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
331232
+ } catch (err) {
331233
+ if (isTransportError(err)) throw err;
331234
+ elementId = null;
331235
+ }
331236
+ }
331237
+ if (elementId) {
331238
+ const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
331239
+ if (bundle) {
331240
+ bundles[step.order] = bundle;
331241
+ log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
331242
+ } else {
331243
+ divergentOrders.push(step.order);
331244
+ log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
331245
+ }
331246
+ } else if (stepNeedsLocator(step.action)) {
331247
+ divergentOrders.push(step.order);
331248
+ log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
331249
+ }
331250
+ const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
331251
+ if (!advanced && isStateAdvancingAction(step.action)) {
331252
+ log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
331253
+ break;
331254
+ }
331255
+ reachedOrder = step.order;
331256
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
331257
+ }
331258
+ } catch (err) {
331259
+ environmentalError = err instanceof Error ? err.message : String(err);
331260
+ log2(` \u2717 Harvest aborted: ${environmentalError}`);
331261
+ } finally {
331262
+ options.onSessionEnding?.(session.deviceId);
331263
+ await session.stop().catch(() => {
331264
+ });
331265
+ }
331266
+ return {
331267
+ platform: platform3,
331268
+ bundles,
331269
+ divergentOrders,
331270
+ reachedOrder,
331271
+ deviceId: session.deviceId,
331272
+ deviceName: session.deviceName,
331273
+ logs: logLines.join("\n"),
331274
+ ...environmentalError ? { environmentalError } : {}
331275
+ };
331276
+ }
330755
331277
 
330756
331278
  // src/mobile-device-queue.ts
330757
331279
  function isNativeMobileDiscoveryContext(ctx) {
@@ -330943,12 +331465,46 @@ async function tapAt(sessionPath, bounds) {
330943
331465
  throw new Error("TAP requires usable bounds (finite x/y)");
330944
331466
  }
330945
331467
  }
330946
- async function typeText(sessionPath, value, bounds) {
331468
+ async function getElementRect(sessionPath, elementId) {
331469
+ try {
331470
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331471
+ if (!value || typeof value !== "object") return null;
331472
+ const r = value;
331473
+ if (typeof r.x !== "number" || typeof r.y !== "number" || typeof r.width !== "number" || typeof r.height !== "number" || !Number.isFinite(r.x) || !Number.isFinite(r.y)) {
331474
+ return null;
331475
+ }
331476
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
331477
+ } catch {
331478
+ return null;
331479
+ }
331480
+ }
331481
+ function rectMatchesIntendedBounds(rect, bounds) {
331482
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
331483
+ const rectCenterX = rect.x + rect.width / 2;
331484
+ const rectCenterY = rect.y + rect.height / 2;
331485
+ const boundsCenterX = bounds.x + bounds.width / 2;
331486
+ const boundsCenterY = bounds.y + bounds.height / 2;
331487
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331488
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331489
+ return rectCenterInBounds || boundsCenterInRect;
331490
+ }
331491
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
331492
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
330947
331493
  let elementId = await getActiveElementId2(sessionPath);
331494
+ if (elementId && box && box.width > 0 && box.height > 0) {
331495
+ const rect = await getElementRect(sessionPath, elementId);
331496
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331497
+ elementId = null;
331498
+ }
331499
+ }
330948
331500
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
330949
331501
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330950
331502
  elementId = await getActiveElementId2(sessionPath);
330951
331503
  }
331504
+ return elementId;
331505
+ }
331506
+ async function typeText(sessionPath, value, bounds) {
331507
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330952
331508
  if (!elementId) {
330953
331509
  throw new Error("No focused element available for TYPE. Tap an input field first.");
330954
331510
  }
@@ -330958,11 +331514,7 @@ async function typeText(sessionPath, value, bounds) {
330958
331514
  });
330959
331515
  }
330960
331516
  async function clearField(sessionPath, bounds) {
330961
- let elementId = await getActiveElementId2(sessionPath);
330962
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
330963
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330964
- elementId = await getActiveElementId2(sessionPath);
330965
- }
331517
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330966
331518
  if (!elementId) {
330967
331519
  throw new Error("No focused element available for CLEAR. Tap an input field first.");
330968
331520
  }
@@ -332712,6 +333264,7 @@ function scrubAuditMessages(messages, testVariables) {
332712
333264
  const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
332713
333265
  if (secretValues.length === 0) return compacted;
332714
333266
  let json = JSON.stringify(compacted);
333267
+ if (typeof json !== "string") return compacted;
332715
333268
  for (const val of secretValues) {
332716
333269
  const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
332717
333270
  json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
@@ -334009,6 +334562,104 @@ ${finalVerifyResult.logs ?? ""}`;
334009
334562
  log.fail("ERROR - No discovery context");
334010
334563
  return;
334011
334564
  }
334565
+ const locatorCtx = ctx;
334566
+ if (locatorCtx.mode === "locator-discovery") {
334567
+ const harvestTarget = locatorCtx.target;
334568
+ const targetTestCaseId = locatorCtx.targetTestCaseId;
334569
+ const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
334570
+ if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
334571
+ await postResult(opts.serverUrl, headers, run2.runId, {
334572
+ status: "ERROR",
334573
+ error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
334574
+ });
334575
+ log.fail("ERROR - malformed locator-discovery context");
334576
+ return;
334577
+ }
334578
+ if (getBridgeState().hasActiveSession) {
334579
+ await postResult(opts.serverUrl, headers, run2.runId, {
334580
+ status: "ERROR",
334581
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
334582
+ });
334583
+ log.fail("ERROR - device busy (recording) for locator discovery");
334584
+ return;
334585
+ }
334586
+ const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334587
+ liveBrowserHandle.patch({
334588
+ mode: "discovery",
334589
+ testName: "Cross-platform locator discovery",
334590
+ note: `${platform3} locator discovery`,
334591
+ surface: "mobile",
334592
+ mobilePlatform: platform3,
334593
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334594
+ ...leasedName ? { deviceName: leasedName } : {}
334595
+ });
334596
+ requestLiveBrowserHeartbeat(true);
334597
+ log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
334598
+ setExecutorBusy(true);
334599
+ try {
334600
+ const harvest = await harvestPlatformLocators({
334601
+ steps: locatorCtx.sourceSteps ?? [],
334602
+ target: {
334603
+ appId: harvestTarget.appId,
334604
+ platform: platform3,
334605
+ platformVersion: harvestTarget.platformVersion,
334606
+ recordedDeviceId: harvestTarget.recordedDeviceId,
334607
+ assignedDeviceId,
334608
+ launchMode: harvestTarget.launchMode,
334609
+ deeplink: harvestTarget.deeplink
334610
+ },
334611
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334612
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334613
+ const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
334614
+ liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
334615
+ requestLiveBrowserHeartbeat(true);
334616
+ },
334617
+ onSessionEnding: (deviceId) => {
334618
+ detachMirrorSession(deviceId);
334619
+ },
334620
+ // Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
334621
+ testVariables: run2.testVariables
334622
+ });
334623
+ let queuedRunId = null;
334624
+ let resultPosted = false;
334625
+ try {
334626
+ const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
334627
+ method: "POST",
334628
+ headers: { ...headers, "Content-Type": "application/json" },
334629
+ body: JSON.stringify({
334630
+ bundles: harvest.bundles,
334631
+ divergentOrders: harvest.divergentOrders,
334632
+ reachedOrder: harvest.reachedOrder,
334633
+ logs: harvest.logs,
334634
+ ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
334635
+ }),
334636
+ signal: AbortSignal.timeout(3e4)
334637
+ });
334638
+ if (res.ok) {
334639
+ resultPosted = true;
334640
+ const data = await res.json().catch(() => null);
334641
+ queuedRunId = data?.queuedRunId ?? null;
334642
+ } else {
334643
+ log.warn(`[locator-discovery] result POST returned ${res.status}`);
334644
+ }
334645
+ } catch (postErr) {
334646
+ log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
334647
+ }
334648
+ const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
334649
+ await postResult(opts.serverUrl, headers, run2.runId, {
334650
+ status: passed ? "PASSED" : "ERROR",
334651
+ 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,
334652
+ logs: harvest.logs,
334653
+ duration: Date.now() - startTime
334654
+ });
334655
+ log[passed ? "ok" : "fail"](
334656
+ `Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
334657
+ );
334658
+ } finally {
334659
+ setExecutorBusy(false);
334660
+ }
334661
+ return;
334662
+ }
334012
334663
  log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
334013
334664
  const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
334014
334665
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
@@ -334722,7 +335373,11 @@ ${finalVerifyResult.logs ?? ""}`;
334722
335373
  recordedDeviceId: target.recordedDeviceId,
334723
335374
  assignedDeviceId,
334724
335375
  launchMode: target.launchMode,
334725
- deeplink: target.deeplink
335376
+ deeplink: target.deeplink,
335377
+ // Authoring platform (test's session medium). When it differs from
335378
+ // target.platform this is a cross-platform run: no blind coordinate
335379
+ // taps on foreign geometry, and the run must verify an outcome.
335380
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
334726
335381
  },
334727
335382
  // Stream this run to its OWN per-device dashboard tile (keyed by the
334728
335383
  // leased device id) so N parallel Appium runs each show a live mirror.
@@ -334732,7 +335387,11 @@ ${finalVerifyResult.logs ?? ""}`;
334732
335387
  },
334733
335388
  onSessionEnding: (deviceId) => {
334734
335389
  detachMirrorSession(deviceId);
334735
- }
335390
+ },
335391
+ // Resolve {{KEY}} credential placeholders (recorded by the mobile
335392
+ // discovery generator instead of secret values) against this run's
335393
+ // CURRENT test variables.
335394
+ testVariables: run2.testVariables
334736
335395
  });
334737
335396
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
334738
335397
  ${result2.logs}`);