@validate.qa/runner 1.0.17 → 1.0.19

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 +310 -34
  2. package/dist/cli.mjs +310 -34
  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,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,
@@ -62008,7 +62045,7 @@ var require_mobile_explorer = __commonJS({
62008
62045
  }
62009
62046
  sections.push("");
62010
62047
  if (mode === "survey") {
62011
- sections.push("**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Do NOT submit forms or mutate data. Call finish_exploration when no new screens remain.");
62048
+ sections.push(`**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Get PAST the entry gate into the app's real main content \u2014 the login/landing screens are the ENTRANCE, not the app. If test credentials are provided (see the Test Credentials section), SIGN IN with them FIRST and map the authenticated app; that is the real product surface. Use a guest/demo/skip entry (e.g. "See Demo Profile", "Continue as guest") ONLY as a fallback when no credentials are available or login cannot be completed \u2014 demo mode is a limited sandbox, not the authenticated app. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.`);
62012
62049
  } else {
62013
62050
  sections.push("**Mission:** Use the focus features like a real user. Tap, type, submit, and observe outcomes. After a meaningful flow, call record_journey. Call finish_exploration when the flows are covered.");
62014
62051
  }
@@ -62066,7 +62103,14 @@ var require_mobile_explorer = __commonJS({
62066
62103
  This app has stored login credentials under these keys:
62067
62104
  ${keyList}
62068
62105
 
62069
- When you reach a login / sign-in screen, LOG IN so you can map and test the authenticated app \u2014 this OVERRIDES the "do not submit forms" rule for the login form ONLY. 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 substitutes the real value on the device; you never see, log, or echo it.
62106
+ LOG IN FIRST \u2014 before broad exploration. The authenticated app is the real product surface; a survey that only maps the logged-out/demo screens has missed most of the app. This OVERRIDES the "do not submit forms" rule for the login form ONLY. The login sequence is:
62107
+ 1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
62108
+ 2. Fill the email field: \`mobile_fill_credential({ ref, credentialKey: "TEST_USER_EMAIL" })\`. Fill the password field: \`mobile_fill_credential({ ref, credentialKey: "TEST_USER_PASSWORD" })\`. The runner substitutes the real value on the device; you never see, log, or echo it.
62109
+ 3. TAP THE SUBMIT BUTTON (e.g. "Sign In", "Log In", "Continue"). Filling the fields does NOT log you in \u2014 you MUST tap submit.
62110
+ 4. VERIFY you are actually signed in: re-snapshot and confirm an authenticated signal appeared (a home/feed/dashboard with real content, an account/profile tab, a "Sign out" option, or your user identity). If you are still on the login form, the login failed \u2014 check for an error, re-enter, and submit again (up to 2 attempts).
62111
+ 5. Only AFTER you have confirmed you are signed in, map the authenticated app.
62112
+
62113
+ PREFER real login over any "demo"/"guest"/"skip" entry: those give a limited sandbox, NOT the authenticated app. Use them only if login is impossible.
62070
62114
 
62071
62115
  SECURITY RULES:
62072
62116
  - NEVER call \`mobile_type\` with an 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.
@@ -62373,6 +62417,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62373
62417
  let summary = "";
62374
62418
  let truncated = false;
62375
62419
  let truncatedReason;
62420
+ let finishRedirects = 0;
62421
+ const MAX_FINISH_REDIRECTS = 2;
62376
62422
  try {
62377
62423
  try {
62378
62424
  const seed = await driver.snapshot();
@@ -62502,6 +62548,20 @@ ${statePacket}` }
62502
62548
  continue;
62503
62549
  }
62504
62550
  if (toolName === "finish_exploration") {
62551
+ const frontier = mode === "survey" ? state2.getFrontier() : [];
62552
+ const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
62553
+ if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
62554
+ finishRedirects++;
62555
+ const targets = frontier.slice(0, 6).join(", ");
62556
+ log2(`
62557
+ finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
62558
+ pushResult({
62559
+ acknowledged: false,
62560
+ keepExploring: true,
62561
+ reason: `Not done yet \u2014 ${frontier.length} screen(s) were seen via transitions but never visited, and ${Math.round(budgetRemainingPct * 100)}% of the iteration budget remains. A survey must map EVERY reachable screen. Navigate into the unvisited screens (e.g. ${targets}), snapshot + capture_screen each, then call finish_exploration only once no unvisited screens remain. If a screen is genuinely unreachable (needs credentials you don't have, or leaves the app), note that and move to the next one.`
62562
+ });
62563
+ continue;
62564
+ }
62505
62565
  explorationComplete = true;
62506
62566
  summary = String(toolArgs.summary ?? "");
62507
62567
  log2(`
@@ -62720,6 +62780,14 @@ Exploration complete: ${summary}`);
62720
62780
  ...actionResult.error ? { error: actionResult.error } : {},
62721
62781
  // Report only the KEY that was filled — never the value.
62722
62782
  filledCredentialKey: key,
62783
+ // Code-level reinforcement of the login sequence: filling a field does
62784
+ // NOT submit the form. The observed failure was fill-without-submit —
62785
+ // the model filled email+password then wandered off (into demo content)
62786
+ // without ever tapping Sign In, so it never reached the authenticated
62787
+ // app. This nudge fires on every credential fill so the model is
62788
+ // reminded to complete + verify the login rather than treating the fill
62789
+ // as "logged in".
62790
+ nextStep: "Credential filled \u2014 this does NOT log you in. Once all credential fields are filled, tap the submit button (Sign In / Log In / Continue), then re-snapshot and confirm an authenticated screen (home/feed/account/Sign out) appeared before exploring further.",
62723
62791
  screenId,
62724
62792
  observation,
62725
62793
  ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
@@ -65578,6 +65646,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65578
65646
  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
65647
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65580
65648
  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.
65649
+ 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
65650
 
65582
65651
  ## Output JSON Schema
65583
65652
  {
@@ -65785,7 +65854,8 @@ ${context.appBrief.slice(0, 500)}`);
65785
65854
  sections.push(`## App Under Test
65786
65855
  ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
65787
65856
  if (context.featureName) {
65788
- sections.push(`## Feature: ${context.featureName}`);
65857
+ sections.push(`## Feature: ${context.featureName}
65858
+ 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
65859
  }
65790
65860
  const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
65791
65861
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66170,10 +66240,12 @@ var require_mobile_generator = __commonJS({
66170
66240
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
66171
66241
  "use strict";
66172
66242
  Object.defineProperty(exports, "__esModule", { value: true });
66243
+ exports.collapseRetryCycles = collapseRetryCycles;
66173
66244
  exports.runMobileGeneratePhase = runMobileGeneratePhase;
66174
66245
  var crypto_1 = __require("crypto");
66175
66246
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
66176
66247
  var ai_provider_js_1 = require_ai_provider();
66248
+ var credential_tools_js_1 = require_credential_tools();
66177
66249
  var ai_queue_js_1 = require_ai_queue();
66178
66250
  var ai_retry_js_1 = require_ai_retry();
66179
66251
  var mobile_source_parser_js_1 = require_mobile_source_parser();
@@ -66295,6 +66367,24 @@ var require_mobile_generator = __commonJS({
66295
66367
  }
66296
66368
  }
66297
66369
  };
66370
+ var MOBILE_FILL_CREDENTIAL_TOOL = {
66371
+ type: "function",
66372
+ function: {
66373
+ name: "mobile_fill_credential",
66374
+ 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.',
66375
+ parameters: {
66376
+ type: "object",
66377
+ properties: {
66378
+ ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
66379
+ credentialKey: {
66380
+ type: "string",
66381
+ 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.'
66382
+ }
66383
+ },
66384
+ required: ["ref", "credentialKey"]
66385
+ }
66386
+ }
66387
+ };
66298
66388
  var FINISH_GENERATE_TOOL = {
66299
66389
  type: "function",
66300
66390
  function: {
@@ -66316,6 +66406,7 @@ var require_mobile_generator = __commonJS({
66316
66406
  MOBILE_TAP_TOOL,
66317
66407
  MOBILE_TYPE_TOOL,
66318
66408
  MOBILE_CLEAR_TOOL,
66409
+ MOBILE_FILL_CREDENTIAL_TOOL,
66319
66410
  MOBILE_SWIPE_TOOL,
66320
66411
  MOBILE_BACK_TOOL,
66321
66412
  MOBILE_RELAUNCH_TOOL,
@@ -66394,6 +66485,24 @@ var require_mobile_generator = __commonJS({
66394
66485
  - 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
66486
 
66396
66487
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
66488
+ }
66489
+ function buildGenerateCredentialPromptBlock(secrets) {
66490
+ const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
66491
+ if (keys.length === 0)
66492
+ return "";
66493
+ const keyList = keys.map((k) => `- \`${k}\``).join("\n");
66494
+ return `
66495
+
66496
+ ## Test Credentials (values are HIDDEN from you)
66497
+ This app has stored login credentials under these keys:
66498
+ ${keyList}
66499
+
66500
+ 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.
66501
+
66502
+ SECURITY RULES:
66503
+ - 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.
66504
+ - 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.
66505
+ - NEVER put credential values in test names, descriptions, or assert texts.`;
66397
66506
  }
66398
66507
  function buildScenarioKickoff(scenario) {
66399
66508
  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 +66573,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66464
66573
  }
66465
66574
  }
66466
66575
  async function runScenarioLoop(args) {
66467
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
66576
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
66468
66577
  const actions = [];
66469
66578
  let assertCount = 0;
66470
66579
  let finish = null;
@@ -66581,7 +66690,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66581
66690
  } catch (err) {
66582
66691
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66583
66692
  }
66584
- const systemPrompt = buildSystemPrompt(platform3, targetAppId);
66693
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
66585
66694
  const kickoff = buildScenarioKickoff(scenario);
66586
66695
  const recentExchanges = [];
66587
66696
  let iteration = 0;
@@ -66694,6 +66803,7 @@ ${statePacket}` },
66694
66803
  latest: getLatest,
66695
66804
  absorbObservation,
66696
66805
  resolveRef,
66806
+ credentials,
66697
66807
  screenName: () => lastScreenName || "UnknownScreen",
66698
66808
  recordAction: (action) => {
66699
66809
  actions.push(finalizeAction(action));
@@ -66715,7 +66825,7 @@ ${statePacket}` },
66715
66825
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
66716
66826
  }
66717
66827
  async function dispatchGenerateTool(args) {
66718
- const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
66828
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
66719
66829
  const unknownRef = (ref) => ({
66720
66830
  ok: false,
66721
66831
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -66781,6 +66891,14 @@ ${statePacket}` },
66781
66891
  if (!element)
66782
66892
  return unknownRef(toolArgs.ref);
66783
66893
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66894
+ for (const [key, secret] of Object.entries(credentials)) {
66895
+ if (secret && secret.length >= 4 && value.includes(secret)) {
66896
+ return {
66897
+ ok: false,
66898
+ error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
66899
+ };
66900
+ }
66901
+ }
66784
66902
  const beforeScreenName = screenName();
66785
66903
  const result = await driver.type(value, element.bounds);
66786
66904
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
@@ -66794,6 +66912,37 @@ ${statePacket}` },
66794
66912
  }
66795
66913
  return actionPayload(result, absorbed);
66796
66914
  }
66915
+ // KEY-based credential fill: type the real value on-device, persist only a
66916
+ // {{KEY}} placeholder in the recorded step (replay resolves it against the
66917
+ // run's current test variables). The tool result echoes the KEY, never the
66918
+ // value, so the secret cannot enter the transcript.
66919
+ case "mobile_fill_credential": {
66920
+ const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
66921
+ if (!key) {
66922
+ return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
66923
+ }
66924
+ const secret = credentials[key];
66925
+ if (!secret) {
66926
+ const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
66927
+ return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
66928
+ }
66929
+ const element = resolveRef(toolArgs.ref);
66930
+ if (!element)
66931
+ return unknownRef(toolArgs.ref);
66932
+ const beforeScreenName = screenName();
66933
+ const result = await driver.type(secret, element.bounds);
66934
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
66935
+ if (shouldRecordAction(result, absorbed)) {
66936
+ recordAction({
66937
+ type: "TYPE",
66938
+ value: (0, shared_1.mobileCredentialPlaceholder)(key),
66939
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
66940
+ metadata: metadataForElement(element, beforeScreenName, platform3)
66941
+ });
66942
+ log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
66943
+ }
66944
+ return { ...actionPayload(result, absorbed), filledCredentialKey: key };
66945
+ }
66797
66946
  case "mobile_clear": {
66798
66947
  const element = resolveRef(toolArgs.ref);
66799
66948
  if (!element)
@@ -66942,18 +67091,40 @@ ${statePacket}` },
66942
67091
  return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
66943
67092
  }
66944
67093
  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"
67094
+ var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
67095
+ "content",
67096
+ "decor",
67097
+ "decor_content_parent",
67098
+ "action_bar_root",
67099
+ "statusBarBackground",
67100
+ "navigationBarBackground",
67101
+ // WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
67102
+ // expose their framework ROOT container's DOM id as the element id — present on
67103
+ // EVERY in-app screen, so the floor must not anchor to it (observed live on
67104
+ // ThoughtStream: `main-content` produced a screen-agnostic assertion that then
67105
+ // never matched via the text-contains fallback).
67106
+ "main-content",
67107
+ "root",
67108
+ "app",
67109
+ "app-root",
67110
+ "__next",
67111
+ "react-root",
67112
+ "application"
66951
67113
  ]);
66952
67114
  function isAlwaysPresentContainer(element) {
66953
67115
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
66954
67116
  return true;
66955
67117
  const resourceId = element.resourceId?.trim();
66956
- return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
67118
+ if (resourceId) {
67119
+ const slashIdx = resourceId.indexOf("/");
67120
+ const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
67121
+ if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
67122
+ return true;
67123
+ }
67124
+ const accessibilityId = element.accessibilityId?.trim();
67125
+ if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
67126
+ return true;
67127
+ return false;
66957
67128
  }
66958
67129
  function appendHybridFloor(actions, recording, platform3) {
66959
67130
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -66967,9 +67138,38 @@ ${statePacket}` },
66967
67138
  }));
66968
67139
  return true;
66969
67140
  }
67141
+ function actionCycleKey(action) {
67142
+ return `${action.type}|${action.selector}|${action.value ?? ""}`;
67143
+ }
67144
+ var MAX_RETRY_CYCLE_LENGTH = 8;
67145
+ function collapseRetryCycles(actions) {
67146
+ const out = [...actions];
67147
+ let changed = true;
67148
+ while (changed) {
67149
+ changed = false;
67150
+ const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
67151
+ for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
67152
+ for (let i = 0; i + 2 * window2 <= out.length; i++) {
67153
+ const first = out.slice(i, i + window2);
67154
+ if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
67155
+ continue;
67156
+ const second = out.slice(i + window2, i + 2 * window2);
67157
+ if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
67158
+ continue;
67159
+ out.splice(i, window2);
67160
+ changed = true;
67161
+ break;
67162
+ }
67163
+ }
67164
+ }
67165
+ return out;
67166
+ }
66970
67167
  function assembleTest(args) {
66971
67168
  const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
66972
- const actions = [...recording.actions];
67169
+ const actions = collapseRetryCycles(recording.actions);
67170
+ if (actions.length < recording.actions.length) {
67171
+ log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
67172
+ }
66973
67173
  const floorAppended = appendHybridFloor(actions, recording, platform3);
66974
67174
  if (floorAppended) {
66975
67175
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -67008,6 +67208,12 @@ ${statePacket}` },
67008
67208
  playwrightCode: "",
67009
67209
  verified,
67010
67210
  tags,
67211
+ // Per-test feature label → TestCase.suite. One deep run plans scenarios for
67212
+ // MULTIPLE feature groups (the planner reuses survey feature names), so the
67213
+ // run-level ctx.featureName fallback in /discovery-test would wrongly file
67214
+ // every test under the deep-dived feature. The scenario's own group is the
67215
+ // correct home; the runner forwards it as `suiteName`.
67216
+ ...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
67011
67217
  // Carry the planner scenario identity so incremental/resume can match an
67012
67218
  // already-generated scenario reliably (scenarioRef.name === the plan's
67013
67219
  // scenario name). Without this the resume skip-set falls back to the
@@ -67025,7 +67231,27 @@ ${statePacket}` },
67025
67231
  startFromLanding: scenario.startFromLanding,
67026
67232
  targetPages: [...scenario.targetPages]
67027
67233
  },
67028
- evidence: { observedLocators: [], observedTransitions: [] }
67234
+ evidence: { observedLocators: [], observedTransitions: [] },
67235
+ // Assertion-strength marker for the server promote-seam
67236
+ // (resolveStabilityTransition): an UNVERIFIED recording proves none of the
67237
+ // scenario's outcomes — only the hybrid floor's landing-screen visibility —
67238
+ // so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
67239
+ // human instead of letting the pass auto-promote it into the ACTIVE suite.
67240
+ // Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
67241
+ ...verified ? {} : {
67242
+ gateActivity: {
67243
+ outcomeGate: {
67244
+ requiredCount: scenario.expectedOutcomes.length,
67245
+ uncoveredCount: scenario.expectedOutcomes.length
67246
+ },
67247
+ strengthGate: {
67248
+ fired: false,
67249
+ severity: "block",
67250
+ blocked: true,
67251
+ 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."
67252
+ }
67253
+ }
67254
+ }
67029
67255
  }
67030
67256
  };
67031
67257
  return test;
@@ -67046,6 +67272,7 @@ ${statePacket}` },
67046
67272
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
67047
67273
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
67048
67274
  const targetAppId = ctx.target?.appId?.trim() || void 0;
67275
+ const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
67049
67276
  const skipSet = new Set(skipScenarioNames ?? []);
67050
67277
  const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
67051
67278
  if (skipSet.size > 0) {
@@ -67074,6 +67301,7 @@ ${statePacket}` },
67074
67301
  onAICall,
67075
67302
  onScreenshot,
67076
67303
  targetAppId,
67304
+ credentials: credentialSecrets,
67077
67305
  log: log2
67078
67306
  });
67079
67307
  consecutiveInfraFailures = 0;
@@ -313696,9 +313924,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
313696
313924
  });
313697
313925
  let featureInsight;
313698
313926
  if (mode === "deep" && ctx.featureName) {
313927
+ const targetName = ctx.featureName.trim().toLowerCase();
313928
+ const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
313929
+ const plannedDescription = plannedGroup?.description?.trim();
313699
313930
  featureInsight = {
313700
313931
  featureName: ctx.featureName,
313701
- description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313932
+ description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313702
313933
  };
313703
313934
  }
313704
313935
  const duration = (Date.now() - startTime) / 1e3;
@@ -328234,7 +328465,7 @@ var require_package4 = __commonJS({
328234
328465
  "package.json"(exports, module) {
328235
328466
  module.exports = {
328236
328467
  name: "@validate.qa/runner",
328237
- version: "1.0.17",
328468
+ version: "1.0.19",
328238
328469
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328239
328470
  bin: {
328240
328471
  "validate-runner": "dist/cli.js"
@@ -330515,6 +330746,10 @@ async function executeMobileTest(options) {
330515
330746
  const logLines = [];
330516
330747
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330517
330748
  const startTime = Date.now();
330749
+ const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
330750
+ if (missingVariableKeys.length > 0) {
330751
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
330752
+ }
330518
330753
  const stepResults = [];
330519
330754
  const screenshots = [];
330520
330755
  let apiCalls = [];
@@ -330652,7 +330887,7 @@ async function executeMobileTest(options) {
330652
330887
  let entryDrift = false;
330653
330888
  const runPlatform = options.target.platform;
330654
330889
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330655
- const entryStep = options.steps.find(
330890
+ const entryStep = runSteps.find(
330656
330891
  (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330657
330892
  );
330658
330893
  if (entryStep) {
@@ -330669,7 +330904,7 @@ async function executeMobileTest(options) {
330669
330904
  }
330670
330905
  }
330671
330906
  let environmental = null;
330672
- for (const step of options.steps) {
330907
+ for (const step of runSteps) {
330673
330908
  if (entryDrift) break;
330674
330909
  if (!stepAppliesToPlatform(step, runPlatform)) {
330675
330910
  log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
@@ -330757,7 +330992,7 @@ async function executeMobileTest(options) {
330757
330992
  }
330758
330993
  const executedResults = stepResults.filter((r) => r.status !== "skipped");
330759
330994
  const verifyOrders = new Set(
330760
- options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330995
+ runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330761
330996
  );
330762
330997
  const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330763
330998
  const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
@@ -330997,11 +331232,15 @@ async function harvestPlatformLocators(options) {
330997
331232
  const log2 = (msg) => {
330998
331233
  logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330999
331234
  };
331235
+ const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
331236
+ if (missingVariableKeys.length > 0) {
331237
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
331238
+ }
331000
331239
  const bundles = {};
331001
331240
  const divergentOrders = [];
331002
331241
  let reachedOrder = null;
331003
331242
  let environmentalError;
331004
- log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
331243
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
331005
331244
  const session = await createMobileDiscoverySession({
331006
331245
  appId: options.target.appId,
331007
331246
  platform: platform3,
@@ -331021,7 +331260,7 @@ async function harvestPlatformLocators(options) {
331021
331260
  }
331022
331261
  }
331023
331262
  await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331024
- for (const step of options.steps) {
331263
+ for (const step of harvestSteps) {
331025
331264
  if (step.action === "launchApp") continue;
331026
331265
  if (!stepAppliesToPlatform(step, platform3)) continue;
331027
331266
  let elementId = null;
@@ -331265,12 +331504,46 @@ async function tapAt(sessionPath, bounds) {
331265
331504
  throw new Error("TAP requires usable bounds (finite x/y)");
331266
331505
  }
331267
331506
  }
331268
- async function typeText(sessionPath, value, bounds) {
331507
+ async function getElementRect(sessionPath, elementId) {
331508
+ try {
331509
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331510
+ if (!value || typeof value !== "object") return null;
331511
+ const r = value;
331512
+ 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)) {
331513
+ return null;
331514
+ }
331515
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
331516
+ } catch {
331517
+ return null;
331518
+ }
331519
+ }
331520
+ function rectMatchesIntendedBounds(rect, bounds) {
331521
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
331522
+ const rectCenterX = rect.x + rect.width / 2;
331523
+ const rectCenterY = rect.y + rect.height / 2;
331524
+ const boundsCenterX = bounds.x + bounds.width / 2;
331525
+ const boundsCenterY = bounds.y + bounds.height / 2;
331526
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331527
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331528
+ return rectCenterInBounds || boundsCenterInRect;
331529
+ }
331530
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
331531
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
331269
331532
  let elementId = await getActiveElementId2(sessionPath);
331533
+ if (elementId && box && box.width > 0 && box.height > 0) {
331534
+ const rect = await getElementRect(sessionPath, elementId);
331535
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331536
+ elementId = null;
331537
+ }
331538
+ }
331270
331539
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331271
331540
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331272
331541
  elementId = await getActiveElementId2(sessionPath);
331273
331542
  }
331543
+ return elementId;
331544
+ }
331545
+ async function typeText(sessionPath, value, bounds) {
331546
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331274
331547
  if (!elementId) {
331275
331548
  throw new Error("No focused element available for TYPE. Tap an input field first.");
331276
331549
  }
@@ -331280,11 +331553,7 @@ async function typeText(sessionPath, value, bounds) {
331280
331553
  });
331281
331554
  }
331282
331555
  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
- }
331556
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331288
331557
  if (!elementId) {
331289
331558
  throw new Error("No focused element available for CLEAR. Tap an input field first.");
331290
331559
  }
@@ -333034,6 +333303,7 @@ function scrubAuditMessages(messages, testVariables) {
333034
333303
  const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
333035
333304
  if (secretValues.length === 0) return compacted;
333036
333305
  let json = JSON.stringify(compacted);
333306
+ if (typeof json !== "string") return compacted;
333037
333307
  for (const val of secretValues) {
333038
333308
  const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
333039
333309
  json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
@@ -334385,7 +334655,9 @@ ${finalVerifyResult.logs ?? ""}`;
334385
334655
  },
334386
334656
  onSessionEnding: (deviceId) => {
334387
334657
  detachMirrorSession(deviceId);
334388
- }
334658
+ },
334659
+ // Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
334660
+ testVariables: run2.testVariables
334389
334661
  });
334390
334662
  let queuedRunId = null;
334391
334663
  let resultPosted = false;
@@ -335154,7 +335426,11 @@ ${finalVerifyResult.logs ?? ""}`;
335154
335426
  },
335155
335427
  onSessionEnding: (deviceId) => {
335156
335428
  detachMirrorSession(deviceId);
335157
- }
335429
+ },
335430
+ // Resolve {{KEY}} credential placeholders (recorded by the mobile
335431
+ // discovery generator instead of secret values) against this run's
335432
+ // CURRENT test variables.
335433
+ testVariables: run2.testVariables
335158
335434
  });
335159
335435
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
335160
335436
  ${result2.logs}`);