@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.mjs CHANGED
@@ -1028,6 +1028,10 @@ var require_mobile_source_parser = __commonJS({
1028
1028
  }
1029
1029
  return fnv1a(parts.join("\n"));
1030
1030
  }
1031
+ var EDITABLE_CLASS_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
1032
+ function isEditableElement(el) {
1033
+ return EDITABLE_CLASS_RE.test(el.role.trim());
1034
+ }
1031
1035
  function buildLocatorBundle(el, platform3) {
1032
1036
  const bundle = {
1033
1037
  xpath: el.xpath,
@@ -1037,7 +1041,7 @@ var require_mobile_source_parser = __commonJS({
1037
1041
  bundle.accessibilityId = el.accessibilityId;
1038
1042
  if (platform3 === "ANDROID" && el.resourceId)
1039
1043
  bundle.resourceId = el.resourceId;
1040
- if (el.text)
1044
+ if (el.text && !isEditableElement(el))
1041
1045
  bundle.text = el.text;
1042
1046
  if (platform3 === "ANDROID" && el.accessibilityId)
1043
1047
  bundle.contentDesc = el.accessibilityId;
@@ -1048,7 +1052,7 @@ var require_mobile_source_parser = __commonJS({
1048
1052
  return el.accessibilityId;
1049
1053
  if (el.resourceId)
1050
1054
  return el.resourceId;
1051
- if (el.text)
1055
+ if (el.text && !isEditableElement(el))
1052
1056
  return el.text;
1053
1057
  return el.xpath;
1054
1058
  }
@@ -1368,6 +1372,31 @@ function toInlineCommentText(value) {
1368
1372
  function isPlainObject(value) {
1369
1373
  return typeof value === "object" && value !== null && !Array.isArray(value);
1370
1374
  }
1375
+ function isEditableMobileClassName(className) {
1376
+ if (!className)
1377
+ return false;
1378
+ return /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/.test(className.trim());
1379
+ }
1380
+ function mobileCredentialPlaceholder(key) {
1381
+ return `{{${key}}}`;
1382
+ }
1383
+ function resolveMobileStepValuePlaceholders(steps, variables) {
1384
+ const missing = /* @__PURE__ */ new Set();
1385
+ const vars = variables ?? {};
1386
+ const resolved = steps.map((step) => {
1387
+ if (typeof step.value !== "string" || !step.value.includes("{{"))
1388
+ return step;
1389
+ const value = step.value.replace(MOBILE_VALUE_PLACEHOLDER_RE, (token, key) => {
1390
+ const substitute = vars[key];
1391
+ if (typeof substitute === "string" && substitute.length > 0)
1392
+ return substitute;
1393
+ missing.add(key);
1394
+ return token;
1395
+ });
1396
+ return value === step.value ? step : { ...step, value };
1397
+ });
1398
+ return { steps: resolved, missingKeys: [...missing] };
1399
+ }
1371
1400
  function readMobileMetadata(value) {
1372
1401
  if (!isPlainObject(value)) {
1373
1402
  return { screenName: "UnknownScreen" };
@@ -1409,7 +1438,11 @@ function buildStepTarget(metadata, selector) {
1409
1438
  const primary = nonEmpty(locatorBundle.accessibilityId) ?? nonEmpty(locatorBundle.resourceId) ?? trimmedSelector ?? nonEmpty(locatorBundle.xpath) ?? locatorBundle.xpath;
1410
1439
  const fallbackCandidates = [
1411
1440
  locatorBundle.resourceId,
1412
- locatorBundle.text,
1441
+ // For editable fields the text attribute IS the currently-typed value — a
1442
+ // self-invalidating locator that only matches while the recorded text is
1443
+ // still in the field (and it can leak typed input into locators). Static
1444
+ // elements keep their text fallback.
1445
+ isEditableMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
1413
1446
  locatorBundle.contentDesc,
1414
1447
  locatorBundle.xpath,
1415
1448
  selector
@@ -1840,7 +1873,7 @@ function isRunnableStep(step) {
1840
1873
  return false;
1841
1874
  }
1842
1875
  }
1843
- var STEP_ACTION_BY_ACTION_TYPE;
1876
+ var STEP_ACTION_BY_ACTION_TYPE, MOBILE_VALUE_PLACEHOLDER_RE;
1844
1877
  var init_mobile_test_generation = __esm({
1845
1878
  "../shared/dist/mobile-test-generation.js"() {
1846
1879
  "use strict";
@@ -1858,6 +1891,103 @@ var init_mobile_test_generation = __esm({
1858
1891
  ASSERT_VISIBLE: "assertVisible",
1859
1892
  ASSERT_TEXT: "assertText"
1860
1893
  };
1894
+ MOBILE_VALUE_PLACEHOLDER_RE = /\{\{([A-Za-z0-9_]+)\}\}/g;
1895
+ }
1896
+ });
1897
+
1898
+ // ../shared/dist/mobile-cross-platform.js
1899
+ function isMobilePlatform(value) {
1900
+ return value === "IOS" || value === "ANDROID";
1901
+ }
1902
+ function mediumForMobilePlatform(platform3) {
1903
+ return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
1904
+ }
1905
+ function mobilePlatformFromMedium(medium) {
1906
+ if (medium === "IOS_NATIVE")
1907
+ return "IOS";
1908
+ if (medium === "ANDROID_NATIVE")
1909
+ return "ANDROID";
1910
+ return null;
1911
+ }
1912
+ function otherMobilePlatform(platform3) {
1913
+ return platform3 === "IOS" ? "ANDROID" : "IOS";
1914
+ }
1915
+ function platformTag(platform3) {
1916
+ return platform3 === "IOS" ? "@ios" : "@android";
1917
+ }
1918
+ function resolvePlatformLocatorBundle(target, platform3) {
1919
+ if (!target)
1920
+ return void 0;
1921
+ if (platform3) {
1922
+ const overlay = target.platformLocators?.[platform3];
1923
+ if (overlay)
1924
+ return overlay;
1925
+ }
1926
+ return target.locatorBundle;
1927
+ }
1928
+ function hasPlatformLocator(target, platform3) {
1929
+ return Boolean(target?.platformLocators?.[platform3]);
1930
+ }
1931
+ function hasCrossPlatformIdentity(bundle) {
1932
+ if (!bundle)
1933
+ return false;
1934
+ return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
1935
+ }
1936
+ function stepIsDivergentForPlatform(step, platform3) {
1937
+ return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
1938
+ }
1939
+ function stepAppliesToPlatform(step, platform3) {
1940
+ const platforms = step.platforms;
1941
+ if (!platforms || platforms.length === 0)
1942
+ return true;
1943
+ if (!platform3)
1944
+ return true;
1945
+ return platforms.includes(platform3);
1946
+ }
1947
+ function stepsMissingPlatformLocators(steps, platform3) {
1948
+ return steps.filter((step) => {
1949
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1950
+ return false;
1951
+ if (!stepAppliesToPlatform(step, platform3))
1952
+ return false;
1953
+ if (hasPlatformLocator(step.target, platform3))
1954
+ return false;
1955
+ if (stepIsDivergentForPlatform(step, platform3))
1956
+ return false;
1957
+ return hasCrossPlatformIdentity(step.target?.locatorBundle);
1958
+ });
1959
+ }
1960
+ function stepsNeedingReviewForPlatform(steps, platform3) {
1961
+ return steps.filter((step) => {
1962
+ if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
1963
+ return false;
1964
+ if (!stepAppliesToPlatform(step, platform3))
1965
+ return false;
1966
+ if (hasPlatformLocator(step.target, platform3))
1967
+ return false;
1968
+ if (stepIsDivergentForPlatform(step, platform3))
1969
+ return true;
1970
+ return !hasCrossPlatformIdentity(step.target?.locatorBundle);
1971
+ });
1972
+ }
1973
+ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
1974
+ if (runPlatform === authoringPlatform)
1975
+ return true;
1976
+ return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
1977
+ }
1978
+ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
1979
+ var init_mobile_cross_platform = __esm({
1980
+ "../shared/dist/mobile-cross-platform.js"() {
1981
+ "use strict";
1982
+ MOBILE_PLATFORMS = ["IOS", "ANDROID"];
1983
+ LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
1984
+ "tap",
1985
+ "type",
1986
+ "clear",
1987
+ "waitForElement",
1988
+ "assertVisible",
1989
+ "assertText"
1990
+ ]);
1861
1991
  }
1862
1992
  });
1863
1993
 
@@ -3539,6 +3669,7 @@ __export(dist_exports, {
3539
3669
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
3540
3670
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
3541
3671
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
3672
+ MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
3542
3673
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
3543
3674
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
3544
3675
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
@@ -3592,6 +3723,8 @@ __export(dist_exports, {
3592
3723
  getPlanDefinition: () => getPlanDefinition,
3593
3724
  getPlanLabel: () => getPlanLabel,
3594
3725
  getTrialDaysRemaining: () => getTrialDaysRemaining,
3726
+ hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
3727
+ hasPlatformLocator: () => hasPlatformLocator,
3595
3728
  hostOfOrigin: () => hostOfOrigin,
3596
3729
  isAddonType: () => isAddonType,
3597
3730
  isApiTest: () => isApiTest,
@@ -3600,24 +3733,38 @@ __export(dist_exports, {
3600
3733
  isCountedFailure: () => isCountedFailure,
3601
3734
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3602
3735
  isDbPricingPlan: () => isDbPricingPlan,
3736
+ isEditableMobileClassName: () => isEditableMobileClassName,
3603
3737
  isEnvironmentalError: () => isEnvironmentalError,
3738
+ isMobilePlatform: () => isMobilePlatform,
3604
3739
  isPricingPlan: () => isPricingPlan,
3605
3740
  isRunnableStep: () => isRunnableStep,
3606
3741
  isSafeDeepLink: () => isSafeDeepLink,
3607
3742
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
3743
+ isTestReadyForPlatform: () => isTestReadyForPlatform,
3608
3744
  isTrialExpired: () => isTrialExpired,
3609
3745
  isUIVariant: () => isUIVariant,
3610
3746
  isValidMobileAppId: () => isValidMobileAppId,
3747
+ mediumForMobilePlatform: () => mediumForMobilePlatform,
3748
+ mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
3749
+ mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3611
3750
  normalizeControlName: () => normalizeControlName,
3751
+ otherMobilePlatform: () => otherMobilePlatform,
3612
3752
  parenDepthDelta: () => parenDepthDelta,
3613
3753
  planHasBillingPortal: () => planHasBillingPortal,
3614
3754
  planRequiresCheckout: () => planRequiresCheckout,
3755
+ platformTag: () => platformTag,
3615
3756
  registrableDomain: () => registrableDomain,
3616
3757
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3758
+ resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
3759
+ resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3617
3760
  safeOrigin: () => safeOrigin,
3618
3761
  sanitizeTestVariables: () => sanitizeTestVariables,
3619
3762
  selectorCandidatesForStep: () => selectorCandidatesForStep,
3620
3763
  selectorForValue: () => selectorForValue,
3764
+ stepAppliesToPlatform: () => stepAppliesToPlatform,
3765
+ stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
3766
+ stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
3767
+ stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
3621
3768
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
3622
3769
  stripLoginScaffolding: () => stripLoginScaffolding,
3623
3770
  summarizeHealth: () => summarizeHealth,
@@ -3631,6 +3778,7 @@ var init_dist = __esm({
3631
3778
  init_types();
3632
3779
  init_constants();
3633
3780
  init_mobile_test_generation();
3781
+ init_mobile_cross_platform();
3634
3782
  init_mobile_deeplink();
3635
3783
  init_plans();
3636
3784
  init_audit_pricing();
@@ -65467,6 +65615,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65467
65615
  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).
65468
65616
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65469
65617
  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.
65618
+ 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.
65470
65619
 
65471
65620
  ## Output JSON Schema
65472
65621
  {
@@ -65674,7 +65823,8 @@ ${context.appBrief.slice(0, 500)}`);
65674
65823
  sections.push(`## App Under Test
65675
65824
  ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
65676
65825
  if (context.featureName) {
65677
- sections.push(`## Feature: ${context.featureName}`);
65826
+ sections.push(`## Feature: ${context.featureName}
65827
+ 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.`);
65678
65828
  }
65679
65829
  const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
65680
65830
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66059,10 +66209,12 @@ var require_mobile_generator = __commonJS({
66059
66209
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
66060
66210
  "use strict";
66061
66211
  Object.defineProperty(exports, "__esModule", { value: true });
66212
+ exports.collapseRetryCycles = collapseRetryCycles;
66062
66213
  exports.runMobileGeneratePhase = runMobileGeneratePhase;
66063
66214
  var crypto_1 = __require("crypto");
66064
66215
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
66065
66216
  var ai_provider_js_1 = require_ai_provider();
66217
+ var credential_tools_js_1 = require_credential_tools();
66066
66218
  var ai_queue_js_1 = require_ai_queue();
66067
66219
  var ai_retry_js_1 = require_ai_retry();
66068
66220
  var mobile_source_parser_js_1 = require_mobile_source_parser();
@@ -66184,6 +66336,24 @@ var require_mobile_generator = __commonJS({
66184
66336
  }
66185
66337
  }
66186
66338
  };
66339
+ var MOBILE_FILL_CREDENTIAL_TOOL = {
66340
+ type: "function",
66341
+ function: {
66342
+ name: "mobile_fill_credential",
66343
+ 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.',
66344
+ parameters: {
66345
+ type: "object",
66346
+ properties: {
66347
+ ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
66348
+ credentialKey: {
66349
+ type: "string",
66350
+ 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.'
66351
+ }
66352
+ },
66353
+ required: ["ref", "credentialKey"]
66354
+ }
66355
+ }
66356
+ };
66187
66357
  var FINISH_GENERATE_TOOL = {
66188
66358
  type: "function",
66189
66359
  function: {
@@ -66205,6 +66375,7 @@ var require_mobile_generator = __commonJS({
66205
66375
  MOBILE_TAP_TOOL,
66206
66376
  MOBILE_TYPE_TOOL,
66207
66377
  MOBILE_CLEAR_TOOL,
66378
+ MOBILE_FILL_CREDENTIAL_TOOL,
66208
66379
  MOBILE_SWIPE_TOOL,
66209
66380
  MOBILE_BACK_TOOL,
66210
66381
  MOBILE_RELAUNCH_TOOL,
@@ -66283,6 +66454,24 @@ var require_mobile_generator = __commonJS({
66283
66454
  - 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.
66284
66455
 
66285
66456
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
66457
+ }
66458
+ function buildGenerateCredentialPromptBlock(secrets) {
66459
+ const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
66460
+ if (keys.length === 0)
66461
+ return "";
66462
+ const keyList = keys.map((k) => `- \`${k}\``).join("\n");
66463
+ return `
66464
+
66465
+ ## Test Credentials (values are HIDDEN from you)
66466
+ This app has stored login credentials under these keys:
66467
+ ${keyList}
66468
+
66469
+ 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.
66470
+
66471
+ SECURITY RULES:
66472
+ - 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.
66473
+ - 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.
66474
+ - NEVER put credential values in test names, descriptions, or assert texts.`;
66286
66475
  }
66287
66476
  function buildScenarioKickoff(scenario) {
66288
66477
  const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
@@ -66353,7 +66542,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66353
66542
  }
66354
66543
  }
66355
66544
  async function runScenarioLoop(args) {
66356
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
66545
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
66357
66546
  const actions = [];
66358
66547
  let assertCount = 0;
66359
66548
  let finish = null;
@@ -66470,7 +66659,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66470
66659
  } catch (err) {
66471
66660
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66472
66661
  }
66473
- const systemPrompt = buildSystemPrompt(platform3, targetAppId);
66662
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
66474
66663
  const kickoff = buildScenarioKickoff(scenario);
66475
66664
  const recentExchanges = [];
66476
66665
  let iteration = 0;
@@ -66583,6 +66772,7 @@ ${statePacket}` },
66583
66772
  latest: getLatest,
66584
66773
  absorbObservation,
66585
66774
  resolveRef,
66775
+ credentials,
66586
66776
  screenName: () => lastScreenName || "UnknownScreen",
66587
66777
  recordAction: (action) => {
66588
66778
  actions.push(finalizeAction(action));
@@ -66604,7 +66794,7 @@ ${statePacket}` },
66604
66794
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
66605
66795
  }
66606
66796
  async function dispatchGenerateTool(args) {
66607
- const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
66797
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
66608
66798
  const unknownRef = (ref) => ({
66609
66799
  ok: false,
66610
66800
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -66670,6 +66860,14 @@ ${statePacket}` },
66670
66860
  if (!element)
66671
66861
  return unknownRef(toolArgs.ref);
66672
66862
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66863
+ for (const [key, secret] of Object.entries(credentials)) {
66864
+ if (secret && secret.length >= 4 && value.includes(secret)) {
66865
+ return {
66866
+ ok: false,
66867
+ error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
66868
+ };
66869
+ }
66870
+ }
66673
66871
  const beforeScreenName = screenName();
66674
66872
  const result = await driver.type(value, element.bounds);
66675
66873
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
@@ -66683,6 +66881,37 @@ ${statePacket}` },
66683
66881
  }
66684
66882
  return actionPayload(result, absorbed);
66685
66883
  }
66884
+ // KEY-based credential fill: type the real value on-device, persist only a
66885
+ // {{KEY}} placeholder in the recorded step (replay resolves it against the
66886
+ // run's current test variables). The tool result echoes the KEY, never the
66887
+ // value, so the secret cannot enter the transcript.
66888
+ case "mobile_fill_credential": {
66889
+ const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
66890
+ if (!key) {
66891
+ return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
66892
+ }
66893
+ const secret = credentials[key];
66894
+ if (!secret) {
66895
+ const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
66896
+ return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
66897
+ }
66898
+ const element = resolveRef(toolArgs.ref);
66899
+ if (!element)
66900
+ return unknownRef(toolArgs.ref);
66901
+ const beforeScreenName = screenName();
66902
+ const result = await driver.type(secret, element.bounds);
66903
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
66904
+ if (shouldRecordAction(result, absorbed)) {
66905
+ recordAction({
66906
+ type: "TYPE",
66907
+ value: (0, shared_1.mobileCredentialPlaceholder)(key),
66908
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
66909
+ metadata: metadataForElement(element, beforeScreenName, platform3)
66910
+ });
66911
+ log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
66912
+ }
66913
+ return { ...actionPayload(result, absorbed), filledCredentialKey: key };
66914
+ }
66686
66915
  case "mobile_clear": {
66687
66916
  const element = resolveRef(toolArgs.ref);
66688
66917
  if (!element)
@@ -66831,18 +67060,40 @@ ${statePacket}` },
66831
67060
  return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
66832
67061
  }
66833
67062
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
66834
- var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
66835
- "android:id/content",
66836
- "android:id/decor",
66837
- "android:id/action_bar_root",
66838
- "android:id/statusBarBackground",
66839
- "android:id/navigationBarBackground"
67063
+ var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
67064
+ "content",
67065
+ "decor",
67066
+ "decor_content_parent",
67067
+ "action_bar_root",
67068
+ "statusBarBackground",
67069
+ "navigationBarBackground",
67070
+ // WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
67071
+ // expose their framework ROOT container's DOM id as the element id — present on
67072
+ // EVERY in-app screen, so the floor must not anchor to it (observed live on
67073
+ // ThoughtStream: `main-content` produced a screen-agnostic assertion that then
67074
+ // never matched via the text-contains fallback).
67075
+ "main-content",
67076
+ "root",
67077
+ "app",
67078
+ "app-root",
67079
+ "__next",
67080
+ "react-root",
67081
+ "application"
66840
67082
  ]);
66841
67083
  function isAlwaysPresentContainer(element) {
66842
67084
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
66843
67085
  return true;
66844
67086
  const resourceId = element.resourceId?.trim();
66845
- return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
67087
+ if (resourceId) {
67088
+ const slashIdx = resourceId.indexOf("/");
67089
+ const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
67090
+ if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
67091
+ return true;
67092
+ }
67093
+ const accessibilityId = element.accessibilityId?.trim();
67094
+ if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
67095
+ return true;
67096
+ return false;
66846
67097
  }
66847
67098
  function appendHybridFloor(actions, recording, platform3) {
66848
67099
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -66856,9 +67107,38 @@ ${statePacket}` },
66856
67107
  }));
66857
67108
  return true;
66858
67109
  }
67110
+ function actionCycleKey(action) {
67111
+ return `${action.type}|${action.selector}|${action.value ?? ""}`;
67112
+ }
67113
+ var MAX_RETRY_CYCLE_LENGTH = 8;
67114
+ function collapseRetryCycles(actions) {
67115
+ const out = [...actions];
67116
+ let changed = true;
67117
+ while (changed) {
67118
+ changed = false;
67119
+ const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
67120
+ for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
67121
+ for (let i = 0; i + 2 * window2 <= out.length; i++) {
67122
+ const first = out.slice(i, i + window2);
67123
+ if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
67124
+ continue;
67125
+ const second = out.slice(i + window2, i + 2 * window2);
67126
+ if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
67127
+ continue;
67128
+ out.splice(i, window2);
67129
+ changed = true;
67130
+ break;
67131
+ }
67132
+ }
67133
+ }
67134
+ return out;
67135
+ }
66859
67136
  function assembleTest(args) {
66860
67137
  const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
66861
- const actions = [...recording.actions];
67138
+ const actions = collapseRetryCycles(recording.actions);
67139
+ if (actions.length < recording.actions.length) {
67140
+ log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
67141
+ }
66862
67142
  const floorAppended = appendHybridFloor(actions, recording, platform3);
66863
67143
  if (floorAppended) {
66864
67144
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -66885,9 +67165,9 @@ ${statePacket}` },
66885
67165
  if (claimedVerified && !verified) {
66886
67166
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
66887
67167
  }
66888
- const platformTag = platform3 === "IOS" ? "@ios" : "@android";
67168
+ const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
66889
67169
  const reviewTag = verified ? "@verified" : "@needs-review";
66890
- const tags = ["@discovered", "@mobile", platformTag, reviewTag];
67170
+ const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
66891
67171
  const test = {
66892
67172
  name: compiled.name,
66893
67173
  description: recording.finish?.description ?? compiled.description,
@@ -66897,6 +67177,12 @@ ${statePacket}` },
66897
67177
  playwrightCode: "",
66898
67178
  verified,
66899
67179
  tags,
67180
+ // Per-test feature label → TestCase.suite. One deep run plans scenarios for
67181
+ // MULTIPLE feature groups (the planner reuses survey feature names), so the
67182
+ // run-level ctx.featureName fallback in /discovery-test would wrongly file
67183
+ // every test under the deep-dived feature. The scenario's own group is the
67184
+ // correct home; the runner forwards it as `suiteName`.
67185
+ ...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
66900
67186
  // Carry the planner scenario identity so incremental/resume can match an
66901
67187
  // already-generated scenario reliably (scenarioRef.name === the plan's
66902
67188
  // scenario name). Without this the resume skip-set falls back to the
@@ -66914,7 +67200,27 @@ ${statePacket}` },
66914
67200
  startFromLanding: scenario.startFromLanding,
66915
67201
  targetPages: [...scenario.targetPages]
66916
67202
  },
66917
- evidence: { observedLocators: [], observedTransitions: [] }
67203
+ evidence: { observedLocators: [], observedTransitions: [] },
67204
+ // Assertion-strength marker for the server promote-seam
67205
+ // (resolveStabilityTransition): an UNVERIFIED recording proves none of the
67206
+ // scenario's outcomes — only the hybrid floor's landing-screen visibility —
67207
+ // so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
67208
+ // human instead of letting the pass auto-promote it into the ACTIVE suite.
67209
+ // Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
67210
+ ...verified ? {} : {
67211
+ gateActivity: {
67212
+ outcomeGate: {
67213
+ requiredCount: scenario.expectedOutcomes.length,
67214
+ uncoveredCount: scenario.expectedOutcomes.length
67215
+ },
67216
+ strengthGate: {
67217
+ fired: false,
67218
+ severity: "block",
67219
+ blocked: true,
67220
+ 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."
67221
+ }
67222
+ }
67223
+ }
66918
67224
  }
66919
67225
  };
66920
67226
  return test;
@@ -66935,6 +67241,7 @@ ${statePacket}` },
66935
67241
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
66936
67242
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
66937
67243
  const targetAppId = ctx.target?.appId?.trim() || void 0;
67244
+ const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
66938
67245
  const skipSet = new Set(skipScenarioNames ?? []);
66939
67246
  const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
66940
67247
  if (skipSet.size > 0) {
@@ -66963,6 +67270,7 @@ ${statePacket}` },
66963
67270
  onAICall,
66964
67271
  onScreenshot,
66965
67272
  targetAppId,
67273
+ credentials: credentialSecrets,
66966
67274
  log: log2
66967
67275
  });
66968
67276
  consecutiveInfraFailures = 0;
@@ -313585,9 +313893,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
313585
313893
  });
313586
313894
  let featureInsight;
313587
313895
  if (mode === "deep" && ctx.featureName) {
313896
+ const targetName = ctx.featureName.trim().toLowerCase();
313897
+ const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
313898
+ const plannedDescription = plannedGroup?.description?.trim();
313588
313899
  featureInsight = {
313589
313900
  featureName: ctx.featureName,
313590
- description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313901
+ description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313591
313902
  };
313592
313903
  }
313593
313904
  const duration = (Date.now() - startTime) / 1e3;
@@ -328123,7 +328434,7 @@ var require_package4 = __commonJS({
328123
328434
  "package.json"(exports, module) {
328124
328435
  module.exports = {
328125
328436
  name: "@validate.qa/runner",
328126
- version: "1.0.16",
328437
+ version: "1.0.18",
328127
328438
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328128
328439
  bin: {
328129
328440
  "validate-runner": "dist/cli.js"
@@ -329667,6 +329978,7 @@ async function startMobileNetworkCapture(options) {
329667
329978
 
329668
329979
  // src/services/appium-executor.ts
329669
329980
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
329981
+ var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
329670
329982
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
329671
329983
  var WD_ACTION_TIMEOUT_MS = 3e4;
329672
329984
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
@@ -329995,6 +330307,11 @@ function boundsFromStep(step) {
329995
330307
  }
329996
330308
  return null;
329997
330309
  }
330310
+ function boundsForRun(step, platform3, isCrossPlatform) {
330311
+ if (isCrossPlatform) return null;
330312
+ if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
330313
+ return boundsFromStep(step);
330314
+ }
329998
330315
  function locatorStrategyForValue(value) {
329999
330316
  const trimmed = value?.trim();
330000
330317
  if (!trimmed) {
@@ -330014,19 +330331,21 @@ function locatorStrategyForValue(value) {
330014
330331
  { using: "id", value: trimmed }
330015
330332
  ];
330016
330333
  }
330017
- function buildLocatorStrategies(target) {
330334
+ function buildLocatorStrategies(target, platform3) {
330018
330335
  if (!target) return [];
330019
330336
  const strategies = [];
330020
- const bundle = target.locatorBundle;
330337
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330021
330338
  if (bundle) {
330022
330339
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330023
330340
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330024
330341
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
330025
330342
  if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
330026
330343
  }
330027
- strategies.push(...locatorStrategyForValue(target.primary));
330028
- for (const fallback of target.fallbacks ?? []) {
330029
- strategies.push(...locatorStrategyForValue(fallback));
330344
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330345
+ strategies.push(...locatorStrategyForValue(target.primary));
330346
+ for (const fallback of target.fallbacks ?? []) {
330347
+ strategies.push(...locatorStrategyForValue(fallback));
330348
+ }
330030
330349
  }
330031
330350
  return strategies.filter(
330032
330351
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
@@ -330052,10 +330371,10 @@ function isIdentityStrategy(strategy) {
330052
330371
  if (strategy.using === "xpath") return strategy.value.includes("@");
330053
330372
  return false;
330054
330373
  }
330055
- function buildIdentityLocatorStrategies(target) {
330374
+ function buildIdentityLocatorStrategies(target, platform3) {
330056
330375
  if (!target) return [];
330057
330376
  const strategies = [];
330058
- const bundle = target.locatorBundle;
330377
+ const bundle = resolvePlatformLocatorBundle(target, platform3);
330059
330378
  if (bundle) {
330060
330379
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330061
330380
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
@@ -330065,9 +330384,11 @@ function buildIdentityLocatorStrategies(target) {
330065
330384
  }
330066
330385
  if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330067
330386
  }
330068
- for (const value of [target.primary, ...target.fallbacks ?? []]) {
330069
- for (const strategy of locatorStrategyForValue(value)) {
330070
- if (isIdentityStrategy(strategy)) strategies.push(strategy);
330387
+ if (!(platform3 && hasPlatformLocator(target, platform3))) {
330388
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330389
+ for (const strategy of locatorStrategyForValue(value)) {
330390
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330391
+ }
330071
330392
  }
330072
330393
  }
330073
330394
  return dedupeStrategies(strategies);
@@ -330080,7 +330401,7 @@ function readStepTimeout(step) {
330080
330401
  return void 0;
330081
330402
  }
330082
330403
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330083
- const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
330404
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
330084
330405
  if (strategies.length === 0) {
330085
330406
  throw new Error(
330086
330407
  opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
@@ -330182,18 +330503,18 @@ function shouldAbortAfterStepFailure(action) {
330182
330503
  return false;
330183
330504
  }
330184
330505
  }
330185
- async function executeTap(sessionId, step) {
330186
- const bounds = boundsFromStep(step);
330506
+ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
330507
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330187
330508
  if (step.target) {
330188
330509
  try {
330189
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330510
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330190
330511
  await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
330191
330512
  method: "POST",
330192
330513
  body: "{}"
330193
330514
  });
330194
330515
  return;
330195
330516
  } catch (error2) {
330196
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330517
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330197
330518
  }
330198
330519
  }
330199
330520
  if (!bounds) {
@@ -330204,7 +330525,7 @@ async function executeTap(sessionId, step) {
330204
330525
  async function executeType(sessionId, step, context) {
330205
330526
  if (step.value === void 0 || step.value === null || step.value === "") return;
330206
330527
  const value = String(step.value);
330207
- const bounds = boundsFromStep(step);
330528
+ const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
330208
330529
  let elementId = null;
330209
330530
  let lastInputError = null;
330210
330531
  let tappedBounds = false;
@@ -330221,12 +330542,12 @@ async function executeType(sessionId, step, context) {
330221
330542
  }
330222
330543
  if (step.target) {
330223
330544
  try {
330224
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330545
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
330225
330546
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330226
330547
  if (!lastInputError) return;
330227
330548
  elementId = null;
330228
330549
  } catch (error2) {
330229
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330550
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
330230
330551
  lastInputError = error2;
330231
330552
  }
330232
330553
  }
@@ -330274,14 +330595,14 @@ async function executeType(sessionId, step, context) {
330274
330595
  }
330275
330596
  throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
330276
330597
  }
330277
- async function executeClear(sessionId, step) {
330278
- const bounds = boundsFromStep(step);
330598
+ async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
330599
+ const bounds = boundsForRun(step, platform3, isCrossPlatform);
330279
330600
  let elementId = null;
330280
330601
  if (step.target) {
330281
330602
  try {
330282
- elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330603
+ elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
330283
330604
  } catch (error2) {
330284
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330605
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
330285
330606
  }
330286
330607
  }
330287
330608
  if (!elementId && bounds) {
@@ -330350,20 +330671,20 @@ async function executeHome(sessionId, platform3) {
330350
330671
  body: JSON.stringify({ keycode: 3 })
330351
330672
  });
330352
330673
  }
330353
- async function executeWaitForElement(sessionId, step) {
330674
+ async function executeWaitForElement(sessionId, step, platform3) {
330354
330675
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330355
- await findElement(sessionId, step.target, timeout, { identityOnly: true });
330676
+ await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
330356
330677
  }
330357
- async function executeAssertVisible(sessionId, step) {
330358
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330678
+ async function executeAssertVisible(sessionId, step, platform3) {
330679
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330359
330680
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330360
330681
  if (!result?.value) {
330361
330682
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
330362
330683
  }
330363
330684
  }
330364
- async function executeAssertText(sessionId, step) {
330685
+ async function executeAssertText(sessionId, step, platform3) {
330365
330686
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330366
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330687
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
330367
330688
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330368
330689
  const actualText = typeof result?.value === "string" ? result.value : "";
330369
330690
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330394,6 +330715,10 @@ async function executeMobileTest(options) {
330394
330715
  const logLines = [];
330395
330716
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330396
330717
  const startTime = Date.now();
330718
+ const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
330719
+ if (missingVariableKeys.length > 0) {
330720
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
330721
+ }
330397
330722
  const stepResults = [];
330398
330723
  const screenshots = [];
330399
330724
  let apiCalls = [];
@@ -330529,12 +330854,14 @@ async function executeMobileTest(options) {
330529
330854
  }
330530
330855
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330531
330856
  let entryDrift = false;
330532
- const entryStep = options.steps.find(
330533
- (s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
330857
+ const runPlatform = options.target.platform;
330858
+ const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330859
+ const entryStep = runSteps.find(
330860
+ (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330534
330861
  );
330535
330862
  if (entryStep) {
330536
330863
  try {
330537
- await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
330864
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
330538
330865
  } catch (err) {
330539
330866
  if (isTransportError(err)) throw err;
330540
330867
  entryDrift = true;
@@ -330546,8 +330873,14 @@ async function executeMobileTest(options) {
330546
330873
  }
330547
330874
  }
330548
330875
  let environmental = null;
330549
- for (const step of options.steps) {
330876
+ for (const step of runSteps) {
330550
330877
  if (entryDrift) break;
330878
+ if (!stepAppliesToPlatform(step, runPlatform)) {
330879
+ log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
330880
+ stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
330881
+ screenshots.push("");
330882
+ continue;
330883
+ }
330551
330884
  const stepStart = Date.now();
330552
330885
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330553
330886
  let status = "passed";
@@ -330559,17 +330892,18 @@ async function executeMobileTest(options) {
330559
330892
  log2(" App launched (handled by session creation)");
330560
330893
  break;
330561
330894
  case "tap":
330562
- await executeTap(sessionId, step);
330895
+ await executeTap(sessionId, step, runPlatform, isCrossPlatform);
330563
330896
  break;
330564
330897
  case "type":
330565
330898
  await executeType(sessionId, step, {
330566
- platform: options.target.platform,
330899
+ platform: runPlatform,
330567
330900
  deviceId: selectedDevice.id,
330568
- log: log2
330901
+ log: log2,
330902
+ isCrossPlatform
330569
330903
  });
330570
330904
  break;
330571
330905
  case "clear":
330572
- await executeClear(sessionId, step);
330906
+ await executeClear(sessionId, step, runPlatform, isCrossPlatform);
330573
330907
  break;
330574
330908
  case "swipe":
330575
330909
  case "scroll":
@@ -330579,16 +330913,16 @@ async function executeMobileTest(options) {
330579
330913
  await executeBack(sessionId);
330580
330914
  break;
330581
330915
  case "home":
330582
- await executeHome(sessionId, options.target.platform);
330916
+ await executeHome(sessionId, runPlatform);
330583
330917
  break;
330584
330918
  case "waitForElement":
330585
- await executeWaitForElement(sessionId, step);
330919
+ await executeWaitForElement(sessionId, step, runPlatform);
330586
330920
  break;
330587
330921
  case "assertVisible":
330588
- await executeAssertVisible(sessionId, step);
330922
+ await executeAssertVisible(sessionId, step, runPlatform);
330589
330923
  break;
330590
330924
  case "assertText":
330591
- await executeAssertText(sessionId, step);
330925
+ await executeAssertText(sessionId, step, runPlatform);
330592
330926
  break;
330593
330927
  default: {
330594
330928
  const _exhaustive = step.action;
@@ -330625,8 +330959,14 @@ async function executeMobileTest(options) {
330625
330959
  break;
330626
330960
  }
330627
330961
  }
330628
- const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330629
- const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330962
+ const executedResults = stepResults.filter((r) => r.status !== "skipped");
330963
+ const verifyOrders = new Set(
330964
+ runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330965
+ );
330966
+ const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330967
+ const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
330968
+ const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330969
+ const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null) ?? (crossPlatformUnverified ? `Cross-platform run on ${runPlatform} verified no outcome \u2014 every assertion/wait step was skipped or unresolved. Author a ${runPlatform} assertion or resolve the divergence.` : null);
330630
330970
  await stopCapture();
330631
330971
  await stopRecording();
330632
330972
  return {
@@ -330760,6 +331100,188 @@ async function createMobileDiscoverySession(target, options) {
330760
331100
  }
330761
331101
  };
330762
331102
  }
331103
+ async function readElementAttribute(sessionId, elementId, name) {
331104
+ try {
331105
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
331106
+ const value = result?.value;
331107
+ if (typeof value !== "string") return void 0;
331108
+ const trimmed = value.trim();
331109
+ return trimmed.length > 0 ? trimmed : void 0;
331110
+ } catch (err) {
331111
+ if (isTransportError(err)) throw err;
331112
+ return void 0;
331113
+ }
331114
+ }
331115
+ function harvestFallbackXpath(platform3, attrs) {
331116
+ if (platform3 === "IOS") {
331117
+ if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
331118
+ const text = attrs.label ?? attrs.value;
331119
+ if (text) return identityTextXpath(text);
331120
+ return "//*";
331121
+ }
331122
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
331123
+ if (attrs.text) return identityTextXpath(attrs.text);
331124
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
331125
+ return "//*";
331126
+ }
331127
+ async function readNativeElementBundle(sessionId, elementId, platform3) {
331128
+ const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
331129
+ if (platform3 === "IOS") {
331130
+ const name = await readElementAttribute(sessionId, elementId, "name");
331131
+ const label = await readElementAttribute(sessionId, elementId, "label");
331132
+ const value = await readElementAttribute(sessionId, elementId, "value");
331133
+ const text2 = label ?? value;
331134
+ if (!name && !text2) return null;
331135
+ const bundle2 = {
331136
+ xpath: harvestFallbackXpath("IOS", { name, label, value }),
331137
+ className
331138
+ };
331139
+ if (name) bundle2.accessibilityId = name;
331140
+ if (text2) bundle2.text = text2;
331141
+ return bundle2;
331142
+ }
331143
+ const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
331144
+ const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
331145
+ const text = await readElementAttribute(sessionId, elementId, "text");
331146
+ if (!resourceId && !contentDesc && !text) return null;
331147
+ const bundle = {
331148
+ xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
331149
+ className
331150
+ };
331151
+ if (contentDesc) bundle.accessibilityId = contentDesc;
331152
+ if (resourceId) bundle.resourceId = resourceId;
331153
+ if (text) bundle.text = text;
331154
+ if (contentDesc) bundle.contentDesc = contentDesc;
331155
+ return bundle;
331156
+ }
331157
+ function isStateAdvancingAction(action) {
331158
+ return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
331159
+ }
331160
+ function stepNeedsLocator(action) {
331161
+ return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
331162
+ }
331163
+ async function driveHarvestStep(sessionId, step, elementId, platform3) {
331164
+ switch (step.action) {
331165
+ case "tap":
331166
+ if (!elementId) return false;
331167
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
331168
+ return true;
331169
+ case "type": {
331170
+ if (step.value === void 0 || step.value === null || step.value === "") return true;
331171
+ if (!elementId) return false;
331172
+ await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
331173
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331174
+ const err = await trySendElementValue(sessionId, elementId, String(step.value));
331175
+ return !err;
331176
+ }
331177
+ case "clear":
331178
+ if (!elementId) return false;
331179
+ await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
331180
+ return true;
331181
+ case "swipe":
331182
+ case "scroll":
331183
+ await executeSwipe(sessionId, step);
331184
+ return true;
331185
+ case "back":
331186
+ await executeBack(sessionId);
331187
+ return true;
331188
+ case "home":
331189
+ await executeHome(sessionId, platform3);
331190
+ return true;
331191
+ case "launchApp":
331192
+ case "waitForElement":
331193
+ case "assertVisible":
331194
+ case "assertText":
331195
+ return true;
331196
+ }
331197
+ }
331198
+ async function harvestPlatformLocators(options) {
331199
+ const platform3 = options.target.platform;
331200
+ const logLines = [];
331201
+ const log2 = (msg) => {
331202
+ logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
331203
+ };
331204
+ const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
331205
+ if (missingVariableKeys.length > 0) {
331206
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
331207
+ }
331208
+ const bundles = {};
331209
+ const divergentOrders = [];
331210
+ let reachedOrder = null;
331211
+ let environmentalError;
331212
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
331213
+ const session = await createMobileDiscoverySession({
331214
+ appId: options.target.appId,
331215
+ platform: platform3,
331216
+ platformVersion: options.target.platformVersion,
331217
+ recordedDeviceId: options.target.recordedDeviceId,
331218
+ assignedDeviceId: options.target.assignedDeviceId,
331219
+ launchMode: "LAUNCH_APP"
331220
+ });
331221
+ options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
331222
+ try {
331223
+ await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
331224
+ if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
331225
+ try {
331226
+ await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
331227
+ } catch (err) {
331228
+ log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
331229
+ }
331230
+ }
331231
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331232
+ for (const step of harvestSteps) {
331233
+ if (step.action === "launchApp") continue;
331234
+ if (!stepAppliesToPlatform(step, platform3)) continue;
331235
+ let elementId = null;
331236
+ const identityStrategies = buildIdentityLocatorStrategies(step.target);
331237
+ if (identityStrategies.length > 0 && step.target) {
331238
+ try {
331239
+ elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
331240
+ } catch (err) {
331241
+ if (isTransportError(err)) throw err;
331242
+ elementId = null;
331243
+ }
331244
+ }
331245
+ if (elementId) {
331246
+ const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
331247
+ if (bundle) {
331248
+ bundles[step.order] = bundle;
331249
+ log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
331250
+ } else {
331251
+ divergentOrders.push(step.order);
331252
+ log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
331253
+ }
331254
+ } else if (stepNeedsLocator(step.action)) {
331255
+ divergentOrders.push(step.order);
331256
+ log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
331257
+ }
331258
+ const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
331259
+ if (!advanced && isStateAdvancingAction(step.action)) {
331260
+ log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
331261
+ break;
331262
+ }
331263
+ reachedOrder = step.order;
331264
+ await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
331265
+ }
331266
+ } catch (err) {
331267
+ environmentalError = err instanceof Error ? err.message : String(err);
331268
+ log2(` \u2717 Harvest aborted: ${environmentalError}`);
331269
+ } finally {
331270
+ options.onSessionEnding?.(session.deviceId);
331271
+ await session.stop().catch(() => {
331272
+ });
331273
+ }
331274
+ return {
331275
+ platform: platform3,
331276
+ bundles,
331277
+ divergentOrders,
331278
+ reachedOrder,
331279
+ deviceId: session.deviceId,
331280
+ deviceName: session.deviceName,
331281
+ logs: logLines.join("\n"),
331282
+ ...environmentalError ? { environmentalError } : {}
331283
+ };
331284
+ }
330763
331285
 
330764
331286
  // src/mobile-device-queue.ts
330765
331287
  function isNativeMobileDiscoveryContext(ctx) {
@@ -330951,12 +331473,46 @@ async function tapAt(sessionPath, bounds) {
330951
331473
  throw new Error("TAP requires usable bounds (finite x/y)");
330952
331474
  }
330953
331475
  }
330954
- async function typeText(sessionPath, value, bounds) {
331476
+ async function getElementRect(sessionPath, elementId) {
331477
+ try {
331478
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331479
+ if (!value || typeof value !== "object") return null;
331480
+ const r = value;
331481
+ 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)) {
331482
+ return null;
331483
+ }
331484
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
331485
+ } catch {
331486
+ return null;
331487
+ }
331488
+ }
331489
+ function rectMatchesIntendedBounds(rect, bounds) {
331490
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
331491
+ const rectCenterX = rect.x + rect.width / 2;
331492
+ const rectCenterY = rect.y + rect.height / 2;
331493
+ const boundsCenterX = bounds.x + bounds.width / 2;
331494
+ const boundsCenterY = bounds.y + bounds.height / 2;
331495
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331496
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331497
+ return rectCenterInBounds || boundsCenterInRect;
331498
+ }
331499
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
331500
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
330955
331501
  let elementId = await getActiveElementId2(sessionPath);
331502
+ if (elementId && box && box.width > 0 && box.height > 0) {
331503
+ const rect = await getElementRect(sessionPath, elementId);
331504
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331505
+ elementId = null;
331506
+ }
331507
+ }
330956
331508
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
330957
331509
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330958
331510
  elementId = await getActiveElementId2(sessionPath);
330959
331511
  }
331512
+ return elementId;
331513
+ }
331514
+ async function typeText(sessionPath, value, bounds) {
331515
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330960
331516
  if (!elementId) {
330961
331517
  throw new Error("No focused element available for TYPE. Tap an input field first.");
330962
331518
  }
@@ -330966,11 +331522,7 @@ async function typeText(sessionPath, value, bounds) {
330966
331522
  });
330967
331523
  }
330968
331524
  async function clearField(sessionPath, bounds) {
330969
- let elementId = await getActiveElementId2(sessionPath);
330970
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
330971
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330972
- elementId = await getActiveElementId2(sessionPath);
330973
- }
331525
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330974
331526
  if (!elementId) {
330975
331527
  throw new Error("No focused element available for CLEAR. Tap an input field first.");
330976
331528
  }
@@ -332720,6 +333272,7 @@ function scrubAuditMessages(messages, testVariables) {
332720
333272
  const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
332721
333273
  if (secretValues.length === 0) return compacted;
332722
333274
  let json = JSON.stringify(compacted);
333275
+ if (typeof json !== "string") return compacted;
332723
333276
  for (const val of secretValues) {
332724
333277
  const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
332725
333278
  json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
@@ -334017,6 +334570,104 @@ ${finalVerifyResult.logs ?? ""}`;
334017
334570
  log.fail("ERROR - No discovery context");
334018
334571
  return;
334019
334572
  }
334573
+ const locatorCtx = ctx;
334574
+ if (locatorCtx.mode === "locator-discovery") {
334575
+ const harvestTarget = locatorCtx.target;
334576
+ const targetTestCaseId = locatorCtx.targetTestCaseId;
334577
+ const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
334578
+ if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
334579
+ await postResult(opts.serverUrl, headers, run2.runId, {
334580
+ status: "ERROR",
334581
+ error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
334582
+ });
334583
+ log.fail("ERROR - malformed locator-discovery context");
334584
+ return;
334585
+ }
334586
+ if (getBridgeState().hasActiveSession) {
334587
+ await postResult(opts.serverUrl, headers, run2.runId, {
334588
+ status: "ERROR",
334589
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
334590
+ });
334591
+ log.fail("ERROR - device busy (recording) for locator discovery");
334592
+ return;
334593
+ }
334594
+ const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334595
+ liveBrowserHandle.patch({
334596
+ mode: "discovery",
334597
+ testName: "Cross-platform locator discovery",
334598
+ note: `${platform3} locator discovery`,
334599
+ surface: "mobile",
334600
+ mobilePlatform: platform3,
334601
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334602
+ ...leasedName ? { deviceName: leasedName } : {}
334603
+ });
334604
+ requestLiveBrowserHeartbeat(true);
334605
+ log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
334606
+ setExecutorBusy(true);
334607
+ try {
334608
+ const harvest = await harvestPlatformLocators({
334609
+ steps: locatorCtx.sourceSteps ?? [],
334610
+ target: {
334611
+ appId: harvestTarget.appId,
334612
+ platform: platform3,
334613
+ platformVersion: harvestTarget.platformVersion,
334614
+ recordedDeviceId: harvestTarget.recordedDeviceId,
334615
+ assignedDeviceId,
334616
+ launchMode: harvestTarget.launchMode,
334617
+ deeplink: harvestTarget.deeplink
334618
+ },
334619
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334620
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334621
+ const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
334622
+ liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
334623
+ requestLiveBrowserHeartbeat(true);
334624
+ },
334625
+ onSessionEnding: (deviceId) => {
334626
+ detachMirrorSession(deviceId);
334627
+ },
334628
+ // Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
334629
+ testVariables: run2.testVariables
334630
+ });
334631
+ let queuedRunId = null;
334632
+ let resultPosted = false;
334633
+ try {
334634
+ const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
334635
+ method: "POST",
334636
+ headers: { ...headers, "Content-Type": "application/json" },
334637
+ body: JSON.stringify({
334638
+ bundles: harvest.bundles,
334639
+ divergentOrders: harvest.divergentOrders,
334640
+ reachedOrder: harvest.reachedOrder,
334641
+ logs: harvest.logs,
334642
+ ...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
334643
+ }),
334644
+ signal: AbortSignal.timeout(3e4)
334645
+ });
334646
+ if (res.ok) {
334647
+ resultPosted = true;
334648
+ const data = await res.json().catch(() => null);
334649
+ queuedRunId = data?.queuedRunId ?? null;
334650
+ } else {
334651
+ log.warn(`[locator-discovery] result POST returned ${res.status}`);
334652
+ }
334653
+ } catch (postErr) {
334654
+ log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
334655
+ }
334656
+ const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
334657
+ await postResult(opts.serverUrl, headers, run2.runId, {
334658
+ status: passed ? "PASSED" : "ERROR",
334659
+ 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,
334660
+ logs: harvest.logs,
334661
+ duration: Date.now() - startTime
334662
+ });
334663
+ log[passed ? "ok" : "fail"](
334664
+ `Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
334665
+ );
334666
+ } finally {
334667
+ setExecutorBusy(false);
334668
+ }
334669
+ return;
334670
+ }
334020
334671
  log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
334021
334672
  const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
334022
334673
  `${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
@@ -334730,7 +335381,11 @@ ${finalVerifyResult.logs ?? ""}`;
334730
335381
  recordedDeviceId: target.recordedDeviceId,
334731
335382
  assignedDeviceId,
334732
335383
  launchMode: target.launchMode,
334733
- deeplink: target.deeplink
335384
+ deeplink: target.deeplink,
335385
+ // Authoring platform (test's session medium). When it differs from
335386
+ // target.platform this is a cross-platform run: no blind coordinate
335387
+ // taps on foreign geometry, and the run must verify an outcome.
335388
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
334734
335389
  },
334735
335390
  // Stream this run to its OWN per-device dashboard tile (keyed by the
334736
335391
  // leased device id) so N parallel Appium runs each show a live mirror.
@@ -334740,7 +335395,11 @@ ${finalVerifyResult.logs ?? ""}`;
334740
335395
  },
334741
335396
  onSessionEnding: (deviceId) => {
334742
335397
  detachMirrorSession(deviceId);
334743
- }
335398
+ },
335399
+ // Resolve {{KEY}} credential placeholders (recorded by the mobile
335400
+ // discovery generator instead of secret values) against this run's
335401
+ // CURRENT test variables.
335402
+ testVariables: run2.testVariables
334744
335403
  });
334745
335404
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
334746
335405
  ${result2.logs}`);