@validate.qa/runner 1.0.17 → 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 +277 -32
  2. package/dist/cli.mjs +277 -32
  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,7 @@ 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;
1856
1890
  }
1857
1891
  });
1858
1892
 
@@ -3694,6 +3728,7 @@ __export(dist_exports, {
3694
3728
  isCountedFailure: () => isCountedFailure,
3695
3729
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3696
3730
  isDbPricingPlan: () => isDbPricingPlan,
3731
+ isEditableMobileClassName: () => isEditableMobileClassName,
3697
3732
  isEnvironmentalError: () => isEnvironmentalError,
3698
3733
  isMobilePlatform: () => isMobilePlatform,
3699
3734
  isPricingPlan: () => isPricingPlan,
@@ -3705,6 +3740,7 @@ __export(dist_exports, {
3705
3740
  isUIVariant: () => isUIVariant,
3706
3741
  isValidMobileAppId: () => isValidMobileAppId,
3707
3742
  mediumForMobilePlatform: () => mediumForMobilePlatform,
3743
+ mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
3708
3744
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3709
3745
  normalizeControlName: () => normalizeControlName,
3710
3746
  otherMobilePlatform: () => otherMobilePlatform,
@@ -3714,6 +3750,7 @@ __export(dist_exports, {
3714
3750
  platformTag: () => platformTag,
3715
3751
  registrableDomain: () => registrableDomain,
3716
3752
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3753
+ resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
3717
3754
  resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3718
3755
  safeOrigin: () => safeOrigin,
3719
3756
  sanitizeTestVariables: () => sanitizeTestVariables,
@@ -65573,6 +65610,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65573
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).
65574
65611
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65575
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.
65576
65614
 
65577
65615
  ## Output JSON Schema
65578
65616
  {
@@ -65780,7 +65818,8 @@ ${context.appBrief.slice(0, 500)}`);
65780
65818
  sections.push(`## App Under Test
65781
65819
  ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
65782
65820
  if (context.featureName) {
65783
- 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.`);
65784
65823
  }
65785
65824
  const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
65786
65825
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66165,10 +66204,12 @@ var require_mobile_generator = __commonJS({
66165
66204
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
66166
66205
  "use strict";
66167
66206
  Object.defineProperty(exports2, "__esModule", { value: true });
66207
+ exports2.collapseRetryCycles = collapseRetryCycles;
66168
66208
  exports2.runMobileGeneratePhase = runMobileGeneratePhase;
66169
66209
  var crypto_1 = require("crypto");
66170
66210
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
66171
66211
  var ai_provider_js_1 = require_ai_provider();
66212
+ var credential_tools_js_1 = require_credential_tools();
66172
66213
  var ai_queue_js_1 = require_ai_queue();
66173
66214
  var ai_retry_js_1 = require_ai_retry();
66174
66215
  var mobile_source_parser_js_1 = require_mobile_source_parser();
@@ -66290,6 +66331,24 @@ var require_mobile_generator = __commonJS({
66290
66331
  }
66291
66332
  }
66292
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
+ };
66293
66352
  var FINISH_GENERATE_TOOL = {
66294
66353
  type: "function",
66295
66354
  function: {
@@ -66311,6 +66370,7 @@ var require_mobile_generator = __commonJS({
66311
66370
  MOBILE_TAP_TOOL,
66312
66371
  MOBILE_TYPE_TOOL,
66313
66372
  MOBILE_CLEAR_TOOL,
66373
+ MOBILE_FILL_CREDENTIAL_TOOL,
66314
66374
  MOBILE_SWIPE_TOOL,
66315
66375
  MOBILE_BACK_TOOL,
66316
66376
  MOBILE_RELAUNCH_TOOL,
@@ -66389,6 +66449,24 @@ var require_mobile_generator = __commonJS({
66389
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.
66390
66450
 
66391
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.`;
66392
66470
  }
66393
66471
  function buildScenarioKickoff(scenario) {
66394
66472
  const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
@@ -66459,7 +66537,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66459
66537
  }
66460
66538
  }
66461
66539
  async function runScenarioLoop(args) {
66462
- 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;
66463
66541
  const actions = [];
66464
66542
  let assertCount = 0;
66465
66543
  let finish = null;
@@ -66576,7 +66654,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66576
66654
  } catch (err) {
66577
66655
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66578
66656
  }
66579
- const systemPrompt = buildSystemPrompt(platform3, targetAppId);
66657
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
66580
66658
  const kickoff = buildScenarioKickoff(scenario);
66581
66659
  const recentExchanges = [];
66582
66660
  let iteration = 0;
@@ -66689,6 +66767,7 @@ ${statePacket}` },
66689
66767
  latest: getLatest,
66690
66768
  absorbObservation,
66691
66769
  resolveRef,
66770
+ credentials,
66692
66771
  screenName: () => lastScreenName || "UnknownScreen",
66693
66772
  recordAction: (action) => {
66694
66773
  actions.push(finalizeAction(action));
@@ -66710,7 +66789,7 @@ ${statePacket}` },
66710
66789
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
66711
66790
  }
66712
66791
  async function dispatchGenerateTool(args) {
66713
- 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;
66714
66793
  const unknownRef = (ref) => ({
66715
66794
  ok: false,
66716
66795
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -66776,6 +66855,14 @@ ${statePacket}` },
66776
66855
  if (!element)
66777
66856
  return unknownRef(toolArgs.ref);
66778
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
+ }
66779
66866
  const beforeScreenName = screenName();
66780
66867
  const result = await driver.type(value, element.bounds);
66781
66868
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
@@ -66789,6 +66876,37 @@ ${statePacket}` },
66789
66876
  }
66790
66877
  return actionPayload(result, absorbed);
66791
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
+ }
66792
66910
  case "mobile_clear": {
66793
66911
  const element = resolveRef(toolArgs.ref);
66794
66912
  if (!element)
@@ -66937,18 +67055,40 @@ ${statePacket}` },
66937
67055
  return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
66938
67056
  }
66939
67057
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
66940
- var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
66941
- "android:id/content",
66942
- "android:id/decor",
66943
- "android:id/action_bar_root",
66944
- "android:id/statusBarBackground",
66945
- "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"
66946
67077
  ]);
66947
67078
  function isAlwaysPresentContainer(element) {
66948
67079
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
66949
67080
  return true;
66950
67081
  const resourceId = element.resourceId?.trim();
66951
- 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;
66952
67092
  }
66953
67093
  function appendHybridFloor(actions, recording, platform3) {
66954
67094
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -66962,9 +67102,38 @@ ${statePacket}` },
66962
67102
  }));
66963
67103
  return true;
66964
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
+ }
66965
67131
  function assembleTest(args) {
66966
67132
  const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
66967
- 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
+ }
66968
67137
  const floorAppended = appendHybridFloor(actions, recording, platform3);
66969
67138
  if (floorAppended) {
66970
67139
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -67003,6 +67172,12 @@ ${statePacket}` },
67003
67172
  playwrightCode: "",
67004
67173
  verified,
67005
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() } : {},
67006
67181
  // Carry the planner scenario identity so incremental/resume can match an
67007
67182
  // already-generated scenario reliably (scenarioRef.name === the plan's
67008
67183
  // scenario name). Without this the resume skip-set falls back to the
@@ -67020,7 +67195,27 @@ ${statePacket}` },
67020
67195
  startFromLanding: scenario.startFromLanding,
67021
67196
  targetPages: [...scenario.targetPages]
67022
67197
  },
67023
- 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
+ }
67024
67219
  }
67025
67220
  };
67026
67221
  return test;
@@ -67041,6 +67236,7 @@ ${statePacket}` },
67041
67236
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
67042
67237
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
67043
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);
67044
67240
  const skipSet = new Set(skipScenarioNames ?? []);
67045
67241
  const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
67046
67242
  if (skipSet.size > 0) {
@@ -67069,6 +67265,7 @@ ${statePacket}` },
67069
67265
  onAICall,
67070
67266
  onScreenshot,
67071
67267
  targetAppId,
67268
+ credentials: credentialSecrets,
67072
67269
  log: log2
67073
67270
  });
67074
67271
  consecutiveInfraFailures = 0;
@@ -313691,9 +313888,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
313691
313888
  });
313692
313889
  let featureInsight;
313693
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();
313694
313894
  featureInsight = {
313695
313895
  featureName: ctx.featureName,
313696
- 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.`
313697
313897
  };
313698
313898
  }
313699
313899
  const duration = (Date.now() - startTime) / 1e3;
@@ -328229,7 +328429,7 @@ var require_package4 = __commonJS({
328229
328429
  "package.json"(exports2, module2) {
328230
328430
  module2.exports = {
328231
328431
  name: "@validate.qa/runner",
328232
- version: "1.0.17",
328432
+ version: "1.0.18",
328233
328433
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328234
328434
  bin: {
328235
328435
  "validate-runner": "dist/cli.js"
@@ -330507,6 +330707,10 @@ async function executeMobileTest(options) {
330507
330707
  const logLines = [];
330508
330708
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330509
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
+ }
330510
330714
  const stepResults = [];
330511
330715
  const screenshots = [];
330512
330716
  let apiCalls = [];
@@ -330644,7 +330848,7 @@ async function executeMobileTest(options) {
330644
330848
  let entryDrift = false;
330645
330849
  const runPlatform = options.target.platform;
330646
330850
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330647
- const entryStep = options.steps.find(
330851
+ const entryStep = runSteps.find(
330648
330852
  (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330649
330853
  );
330650
330854
  if (entryStep) {
@@ -330661,7 +330865,7 @@ async function executeMobileTest(options) {
330661
330865
  }
330662
330866
  }
330663
330867
  let environmental = null;
330664
- for (const step of options.steps) {
330868
+ for (const step of runSteps) {
330665
330869
  if (entryDrift) break;
330666
330870
  if (!stepAppliesToPlatform(step, runPlatform)) {
330667
330871
  log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
@@ -330749,7 +330953,7 @@ async function executeMobileTest(options) {
330749
330953
  }
330750
330954
  const executedResults = stepResults.filter((r) => r.status !== "skipped");
330751
330955
  const verifyOrders = new Set(
330752
- options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330956
+ runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330753
330957
  );
330754
330958
  const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330755
330959
  const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
@@ -330989,11 +331193,15 @@ async function harvestPlatformLocators(options) {
330989
331193
  const log2 = (msg) => {
330990
331194
  logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330991
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
+ }
330992
331200
  const bundles = {};
330993
331201
  const divergentOrders = [];
330994
331202
  let reachedOrder = null;
330995
331203
  let environmentalError;
330996
- log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
331204
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
330997
331205
  const session = await createMobileDiscoverySession({
330998
331206
  appId: options.target.appId,
330999
331207
  platform: platform3,
@@ -331013,7 +331221,7 @@ async function harvestPlatformLocators(options) {
331013
331221
  }
331014
331222
  }
331015
331223
  await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331016
- for (const step of options.steps) {
331224
+ for (const step of harvestSteps) {
331017
331225
  if (step.action === "launchApp") continue;
331018
331226
  if (!stepAppliesToPlatform(step, platform3)) continue;
331019
331227
  let elementId = null;
@@ -331257,12 +331465,46 @@ async function tapAt(sessionPath, bounds) {
331257
331465
  throw new Error("TAP requires usable bounds (finite x/y)");
331258
331466
  }
331259
331467
  }
331260
- 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;
331261
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
+ }
331262
331500
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331263
331501
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331264
331502
  elementId = await getActiveElementId2(sessionPath);
331265
331503
  }
331504
+ return elementId;
331505
+ }
331506
+ async function typeText(sessionPath, value, bounds) {
331507
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331266
331508
  if (!elementId) {
331267
331509
  throw new Error("No focused element available for TYPE. Tap an input field first.");
331268
331510
  }
@@ -331272,11 +331514,7 @@ async function typeText(sessionPath, value, bounds) {
331272
331514
  });
331273
331515
  }
331274
331516
  async function clearField(sessionPath, bounds) {
331275
- let elementId = await getActiveElementId2(sessionPath);
331276
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331277
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331278
- elementId = await getActiveElementId2(sessionPath);
331279
- }
331517
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331280
331518
  if (!elementId) {
331281
331519
  throw new Error("No focused element available for CLEAR. Tap an input field first.");
331282
331520
  }
@@ -333026,6 +333264,7 @@ function scrubAuditMessages(messages, testVariables) {
333026
333264
  const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
333027
333265
  if (secretValues.length === 0) return compacted;
333028
333266
  let json = JSON.stringify(compacted);
333267
+ if (typeof json !== "string") return compacted;
333029
333268
  for (const val of secretValues) {
333030
333269
  const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
333031
333270
  json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
@@ -334377,7 +334616,9 @@ ${finalVerifyResult.logs ?? ""}`;
334377
334616
  },
334378
334617
  onSessionEnding: (deviceId) => {
334379
334618
  detachMirrorSession(deviceId);
334380
- }
334619
+ },
334620
+ // Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
334621
+ testVariables: run2.testVariables
334381
334622
  });
334382
334623
  let queuedRunId = null;
334383
334624
  let resultPosted = false;
@@ -335146,7 +335387,11 @@ ${finalVerifyResult.logs ?? ""}`;
335146
335387
  },
335147
335388
  onSessionEnding: (deviceId) => {
335148
335389
  detachMirrorSession(deviceId);
335149
- }
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
335150
335395
  });
335151
335396
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
335152
335397
  ${result2.logs}`);
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,7 @@ 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;
1861
1895
  }
1862
1896
  });
1863
1897
 
@@ -3699,6 +3733,7 @@ __export(dist_exports, {
3699
3733
  isCountedFailure: () => isCountedFailure,
3700
3734
  isCreditPackAmountCents: () => isCreditPackAmountCents,
3701
3735
  isDbPricingPlan: () => isDbPricingPlan,
3736
+ isEditableMobileClassName: () => isEditableMobileClassName,
3702
3737
  isEnvironmentalError: () => isEnvironmentalError,
3703
3738
  isMobilePlatform: () => isMobilePlatform,
3704
3739
  isPricingPlan: () => isPricingPlan,
@@ -3710,6 +3745,7 @@ __export(dist_exports, {
3710
3745
  isUIVariant: () => isUIVariant,
3711
3746
  isValidMobileAppId: () => isValidMobileAppId,
3712
3747
  mediumForMobilePlatform: () => mediumForMobilePlatform,
3748
+ mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
3713
3749
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
3714
3750
  normalizeControlName: () => normalizeControlName,
3715
3751
  otherMobilePlatform: () => otherMobilePlatform,
@@ -3719,6 +3755,7 @@ __export(dist_exports, {
3719
3755
  platformTag: () => platformTag,
3720
3756
  registrableDomain: () => registrableDomain,
3721
3757
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
3758
+ resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
3722
3759
  resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
3723
3760
  safeOrigin: () => safeOrigin,
3724
3761
  sanitizeTestVariables: () => sanitizeTestVariables,
@@ -65578,6 +65615,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65578
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).
65579
65616
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65580
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.
65581
65619
 
65582
65620
  ## Output JSON Schema
65583
65621
  {
@@ -65785,7 +65823,8 @@ ${context.appBrief.slice(0, 500)}`);
65785
65823
  sections.push(`## App Under Test
65786
65824
  ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
65787
65825
  if (context.featureName) {
65788
- 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.`);
65789
65828
  }
65790
65829
  const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
65791
65830
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66170,10 +66209,12 @@ var require_mobile_generator = __commonJS({
66170
66209
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
66171
66210
  "use strict";
66172
66211
  Object.defineProperty(exports, "__esModule", { value: true });
66212
+ exports.collapseRetryCycles = collapseRetryCycles;
66173
66213
  exports.runMobileGeneratePhase = runMobileGeneratePhase;
66174
66214
  var crypto_1 = __require("crypto");
66175
66215
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
66176
66216
  var ai_provider_js_1 = require_ai_provider();
66217
+ var credential_tools_js_1 = require_credential_tools();
66177
66218
  var ai_queue_js_1 = require_ai_queue();
66178
66219
  var ai_retry_js_1 = require_ai_retry();
66179
66220
  var mobile_source_parser_js_1 = require_mobile_source_parser();
@@ -66295,6 +66336,24 @@ var require_mobile_generator = __commonJS({
66295
66336
  }
66296
66337
  }
66297
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
+ };
66298
66357
  var FINISH_GENERATE_TOOL = {
66299
66358
  type: "function",
66300
66359
  function: {
@@ -66316,6 +66375,7 @@ var require_mobile_generator = __commonJS({
66316
66375
  MOBILE_TAP_TOOL,
66317
66376
  MOBILE_TYPE_TOOL,
66318
66377
  MOBILE_CLEAR_TOOL,
66378
+ MOBILE_FILL_CREDENTIAL_TOOL,
66319
66379
  MOBILE_SWIPE_TOOL,
66320
66380
  MOBILE_BACK_TOOL,
66321
66381
  MOBILE_RELAUNCH_TOOL,
@@ -66394,6 +66454,24 @@ var require_mobile_generator = __commonJS({
66394
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.
66395
66455
 
66396
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.`;
66397
66475
  }
66398
66476
  function buildScenarioKickoff(scenario) {
66399
66477
  const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
@@ -66464,7 +66542,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66464
66542
  }
66465
66543
  }
66466
66544
  async function runScenarioLoop(args) {
66467
- 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;
66468
66546
  const actions = [];
66469
66547
  let assertCount = 0;
66470
66548
  let finish = null;
@@ -66581,7 +66659,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66581
66659
  } catch (err) {
66582
66660
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66583
66661
  }
66584
- const systemPrompt = buildSystemPrompt(platform3, targetAppId);
66662
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
66585
66663
  const kickoff = buildScenarioKickoff(scenario);
66586
66664
  const recentExchanges = [];
66587
66665
  let iteration = 0;
@@ -66694,6 +66772,7 @@ ${statePacket}` },
66694
66772
  latest: getLatest,
66695
66773
  absorbObservation,
66696
66774
  resolveRef,
66775
+ credentials,
66697
66776
  screenName: () => lastScreenName || "UnknownScreen",
66698
66777
  recordAction: (action) => {
66699
66778
  actions.push(finalizeAction(action));
@@ -66715,7 +66794,7 @@ ${statePacket}` },
66715
66794
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
66716
66795
  }
66717
66796
  async function dispatchGenerateTool(args) {
66718
- 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;
66719
66798
  const unknownRef = (ref) => ({
66720
66799
  ok: false,
66721
66800
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -66781,6 +66860,14 @@ ${statePacket}` },
66781
66860
  if (!element)
66782
66861
  return unknownRef(toolArgs.ref);
66783
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
+ }
66784
66871
  const beforeScreenName = screenName();
66785
66872
  const result = await driver.type(value, element.bounds);
66786
66873
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
@@ -66794,6 +66881,37 @@ ${statePacket}` },
66794
66881
  }
66795
66882
  return actionPayload(result, absorbed);
66796
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
+ }
66797
66915
  case "mobile_clear": {
66798
66916
  const element = resolveRef(toolArgs.ref);
66799
66917
  if (!element)
@@ -66942,18 +67060,40 @@ ${statePacket}` },
66942
67060
  return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
66943
67061
  }
66944
67062
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
66945
- var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
66946
- "android:id/content",
66947
- "android:id/decor",
66948
- "android:id/action_bar_root",
66949
- "android:id/statusBarBackground",
66950
- "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"
66951
67082
  ]);
66952
67083
  function isAlwaysPresentContainer(element) {
66953
67084
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
66954
67085
  return true;
66955
67086
  const resourceId = element.resourceId?.trim();
66956
- 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;
66957
67097
  }
66958
67098
  function appendHybridFloor(actions, recording, platform3) {
66959
67099
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -66967,9 +67107,38 @@ ${statePacket}` },
66967
67107
  }));
66968
67108
  return true;
66969
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
+ }
66970
67136
  function assembleTest(args) {
66971
67137
  const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
66972
- 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
+ }
66973
67142
  const floorAppended = appendHybridFloor(actions, recording, platform3);
66974
67143
  if (floorAppended) {
66975
67144
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -67008,6 +67177,12 @@ ${statePacket}` },
67008
67177
  playwrightCode: "",
67009
67178
  verified,
67010
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() } : {},
67011
67186
  // Carry the planner scenario identity so incremental/resume can match an
67012
67187
  // already-generated scenario reliably (scenarioRef.name === the plan's
67013
67188
  // scenario name). Without this the resume skip-set falls back to the
@@ -67025,7 +67200,27 @@ ${statePacket}` },
67025
67200
  startFromLanding: scenario.startFromLanding,
67026
67201
  targetPages: [...scenario.targetPages]
67027
67202
  },
67028
- 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
+ }
67029
67224
  }
67030
67225
  };
67031
67226
  return test;
@@ -67046,6 +67241,7 @@ ${statePacket}` },
67046
67241
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
67047
67242
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
67048
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);
67049
67245
  const skipSet = new Set(skipScenarioNames ?? []);
67050
67246
  const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
67051
67247
  if (skipSet.size > 0) {
@@ -67074,6 +67270,7 @@ ${statePacket}` },
67074
67270
  onAICall,
67075
67271
  onScreenshot,
67076
67272
  targetAppId,
67273
+ credentials: credentialSecrets,
67077
67274
  log: log2
67078
67275
  });
67079
67276
  consecutiveInfraFailures = 0;
@@ -313696,9 +313893,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
313696
313893
  });
313697
313894
  let featureInsight;
313698
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();
313699
313899
  featureInsight = {
313700
313900
  featureName: ctx.featureName,
313701
- 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.`
313702
313902
  };
313703
313903
  }
313704
313904
  const duration = (Date.now() - startTime) / 1e3;
@@ -328234,7 +328434,7 @@ var require_package4 = __commonJS({
328234
328434
  "package.json"(exports, module) {
328235
328435
  module.exports = {
328236
328436
  name: "@validate.qa/runner",
328237
- version: "1.0.17",
328437
+ version: "1.0.18",
328238
328438
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328239
328439
  bin: {
328240
328440
  "validate-runner": "dist/cli.js"
@@ -330515,6 +330715,10 @@ async function executeMobileTest(options) {
330515
330715
  const logLines = [];
330516
330716
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330517
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
+ }
330518
330722
  const stepResults = [];
330519
330723
  const screenshots = [];
330520
330724
  let apiCalls = [];
@@ -330652,7 +330856,7 @@ async function executeMobileTest(options) {
330652
330856
  let entryDrift = false;
330653
330857
  const runPlatform = options.target.platform;
330654
330858
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330655
- const entryStep = options.steps.find(
330859
+ const entryStep = runSteps.find(
330656
330860
  (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330657
330861
  );
330658
330862
  if (entryStep) {
@@ -330669,7 +330873,7 @@ async function executeMobileTest(options) {
330669
330873
  }
330670
330874
  }
330671
330875
  let environmental = null;
330672
- for (const step of options.steps) {
330876
+ for (const step of runSteps) {
330673
330877
  if (entryDrift) break;
330674
330878
  if (!stepAppliesToPlatform(step, runPlatform)) {
330675
330879
  log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
@@ -330757,7 +330961,7 @@ async function executeMobileTest(options) {
330757
330961
  }
330758
330962
  const executedResults = stepResults.filter((r) => r.status !== "skipped");
330759
330963
  const verifyOrders = new Set(
330760
- options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330964
+ runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330761
330965
  );
330762
330966
  const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330763
330967
  const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
@@ -330997,11 +331201,15 @@ async function harvestPlatformLocators(options) {
330997
331201
  const log2 = (msg) => {
330998
331202
  logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330999
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
+ }
331000
331208
  const bundles = {};
331001
331209
  const divergentOrders = [];
331002
331210
  let reachedOrder = null;
331003
331211
  let environmentalError;
331004
- log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
331212
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
331005
331213
  const session = await createMobileDiscoverySession({
331006
331214
  appId: options.target.appId,
331007
331215
  platform: platform3,
@@ -331021,7 +331229,7 @@ async function harvestPlatformLocators(options) {
331021
331229
  }
331022
331230
  }
331023
331231
  await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331024
- for (const step of options.steps) {
331232
+ for (const step of harvestSteps) {
331025
331233
  if (step.action === "launchApp") continue;
331026
331234
  if (!stepAppliesToPlatform(step, platform3)) continue;
331027
331235
  let elementId = null;
@@ -331265,12 +331473,46 @@ async function tapAt(sessionPath, bounds) {
331265
331473
  throw new Error("TAP requires usable bounds (finite x/y)");
331266
331474
  }
331267
331475
  }
331268
- 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;
331269
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
+ }
331270
331508
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331271
331509
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331272
331510
  elementId = await getActiveElementId2(sessionPath);
331273
331511
  }
331512
+ return elementId;
331513
+ }
331514
+ async function typeText(sessionPath, value, bounds) {
331515
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331274
331516
  if (!elementId) {
331275
331517
  throw new Error("No focused element available for TYPE. Tap an input field first.");
331276
331518
  }
@@ -331280,11 +331522,7 @@ async function typeText(sessionPath, value, bounds) {
331280
331522
  });
331281
331523
  }
331282
331524
  async function clearField(sessionPath, bounds) {
331283
- let elementId = await getActiveElementId2(sessionPath);
331284
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331285
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331286
- elementId = await getActiveElementId2(sessionPath);
331287
- }
331525
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331288
331526
  if (!elementId) {
331289
331527
  throw new Error("No focused element available for CLEAR. Tap an input field first.");
331290
331528
  }
@@ -333034,6 +333272,7 @@ function scrubAuditMessages(messages, testVariables) {
333034
333272
  const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
333035
333273
  if (secretValues.length === 0) return compacted;
333036
333274
  let json = JSON.stringify(compacted);
333275
+ if (typeof json !== "string") return compacted;
333037
333276
  for (const val of secretValues) {
333038
333277
  const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
333039
333278
  json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
@@ -334385,7 +334624,9 @@ ${finalVerifyResult.logs ?? ""}`;
334385
334624
  },
334386
334625
  onSessionEnding: (deviceId) => {
334387
334626
  detachMirrorSession(deviceId);
334388
- }
334627
+ },
334628
+ // Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
334629
+ testVariables: run2.testVariables
334389
334630
  });
334390
334631
  let queuedRunId = null;
334391
334632
  let resultPosted = false;
@@ -335154,7 +335395,11 @@ ${finalVerifyResult.logs ?? ""}`;
335154
335395
  },
335155
335396
  onSessionEnding: (deviceId) => {
335156
335397
  detachMirrorSession(deviceId);
335157
- }
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
335158
335403
  });
335159
335404
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
335160
335405
  ${result2.logs}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"