@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.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,
@@ -62003,7 +62040,7 @@ var require_mobile_explorer = __commonJS({
62003
62040
  }
62004
62041
  sections.push("");
62005
62042
  if (mode === "survey") {
62006
- 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.");
62043
+ 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.`);
62007
62044
  } else {
62008
62045
  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.");
62009
62046
  }
@@ -62061,7 +62098,14 @@ var require_mobile_explorer = __commonJS({
62061
62098
  This app has stored login credentials under these keys:
62062
62099
  ${keyList}
62063
62100
 
62064
- 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.
62101
+ 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:
62102
+ 1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
62103
+ 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.
62104
+ 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.
62105
+ 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).
62106
+ 5. Only AFTER you have confirmed you are signed in, map the authenticated app.
62107
+
62108
+ 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.
62065
62109
 
62066
62110
  SECURITY RULES:
62067
62111
  - 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.
@@ -62368,6 +62412,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62368
62412
  let summary = "";
62369
62413
  let truncated = false;
62370
62414
  let truncatedReason;
62415
+ let finishRedirects = 0;
62416
+ const MAX_FINISH_REDIRECTS = 2;
62371
62417
  try {
62372
62418
  try {
62373
62419
  const seed = await driver.snapshot();
@@ -62497,6 +62543,20 @@ ${statePacket}` }
62497
62543
  continue;
62498
62544
  }
62499
62545
  if (toolName === "finish_exploration") {
62546
+ const frontier = mode === "survey" ? state2.getFrontier() : [];
62547
+ const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
62548
+ if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
62549
+ finishRedirects++;
62550
+ const targets = frontier.slice(0, 6).join(", ");
62551
+ log2(`
62552
+ finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
62553
+ pushResult({
62554
+ acknowledged: false,
62555
+ keepExploring: true,
62556
+ 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.`
62557
+ });
62558
+ continue;
62559
+ }
62500
62560
  explorationComplete = true;
62501
62561
  summary = String(toolArgs.summary ?? "");
62502
62562
  log2(`
@@ -62715,6 +62775,14 @@ Exploration complete: ${summary}`);
62715
62775
  ...actionResult.error ? { error: actionResult.error } : {},
62716
62776
  // Report only the KEY that was filled — never the value.
62717
62777
  filledCredentialKey: key,
62778
+ // Code-level reinforcement of the login sequence: filling a field does
62779
+ // NOT submit the form. The observed failure was fill-without-submit —
62780
+ // the model filled email+password then wandered off (into demo content)
62781
+ // without ever tapping Sign In, so it never reached the authenticated
62782
+ // app. This nudge fires on every credential fill so the model is
62783
+ // reminded to complete + verify the login rather than treating the fill
62784
+ // as "logged in".
62785
+ 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.",
62718
62786
  screenId,
62719
62787
  observation,
62720
62788
  ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
@@ -65573,6 +65641,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65573
65641
  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
65642
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65575
65643
  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.
65644
+ 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
65645
 
65577
65646
  ## Output JSON Schema
65578
65647
  {
@@ -65780,7 +65849,8 @@ ${context.appBrief.slice(0, 500)}`);
65780
65849
  sections.push(`## App Under Test
65781
65850
  ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
65782
65851
  if (context.featureName) {
65783
- sections.push(`## Feature: ${context.featureName}`);
65852
+ sections.push(`## Feature: ${context.featureName}
65853
+ 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
65854
  }
65785
65855
  const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
65786
65856
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66165,10 +66235,12 @@ var require_mobile_generator = __commonJS({
66165
66235
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
66166
66236
  "use strict";
66167
66237
  Object.defineProperty(exports2, "__esModule", { value: true });
66238
+ exports2.collapseRetryCycles = collapseRetryCycles;
66168
66239
  exports2.runMobileGeneratePhase = runMobileGeneratePhase;
66169
66240
  var crypto_1 = require("crypto");
66170
66241
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
66171
66242
  var ai_provider_js_1 = require_ai_provider();
66243
+ var credential_tools_js_1 = require_credential_tools();
66172
66244
  var ai_queue_js_1 = require_ai_queue();
66173
66245
  var ai_retry_js_1 = require_ai_retry();
66174
66246
  var mobile_source_parser_js_1 = require_mobile_source_parser();
@@ -66290,6 +66362,24 @@ var require_mobile_generator = __commonJS({
66290
66362
  }
66291
66363
  }
66292
66364
  };
66365
+ var MOBILE_FILL_CREDENTIAL_TOOL = {
66366
+ type: "function",
66367
+ function: {
66368
+ name: "mobile_fill_credential",
66369
+ 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.',
66370
+ parameters: {
66371
+ type: "object",
66372
+ properties: {
66373
+ ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
66374
+ credentialKey: {
66375
+ type: "string",
66376
+ 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.'
66377
+ }
66378
+ },
66379
+ required: ["ref", "credentialKey"]
66380
+ }
66381
+ }
66382
+ };
66293
66383
  var FINISH_GENERATE_TOOL = {
66294
66384
  type: "function",
66295
66385
  function: {
@@ -66311,6 +66401,7 @@ var require_mobile_generator = __commonJS({
66311
66401
  MOBILE_TAP_TOOL,
66312
66402
  MOBILE_TYPE_TOOL,
66313
66403
  MOBILE_CLEAR_TOOL,
66404
+ MOBILE_FILL_CREDENTIAL_TOOL,
66314
66405
  MOBILE_SWIPE_TOOL,
66315
66406
  MOBILE_BACK_TOOL,
66316
66407
  MOBILE_RELAUNCH_TOOL,
@@ -66389,6 +66480,24 @@ var require_mobile_generator = __commonJS({
66389
66480
  - 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
66481
 
66391
66482
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
66483
+ }
66484
+ function buildGenerateCredentialPromptBlock(secrets) {
66485
+ const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
66486
+ if (keys.length === 0)
66487
+ return "";
66488
+ const keyList = keys.map((k) => `- \`${k}\``).join("\n");
66489
+ return `
66490
+
66491
+ ## Test Credentials (values are HIDDEN from you)
66492
+ This app has stored login credentials under these keys:
66493
+ ${keyList}
66494
+
66495
+ 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.
66496
+
66497
+ SECURITY RULES:
66498
+ - 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.
66499
+ - 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.
66500
+ - NEVER put credential values in test names, descriptions, or assert texts.`;
66392
66501
  }
66393
66502
  function buildScenarioKickoff(scenario) {
66394
66503
  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 +66568,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66459
66568
  }
66460
66569
  }
66461
66570
  async function runScenarioLoop(args) {
66462
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
66571
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
66463
66572
  const actions = [];
66464
66573
  let assertCount = 0;
66465
66574
  let finish = null;
@@ -66576,7 +66685,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66576
66685
  } catch (err) {
66577
66686
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66578
66687
  }
66579
- const systemPrompt = buildSystemPrompt(platform3, targetAppId);
66688
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
66580
66689
  const kickoff = buildScenarioKickoff(scenario);
66581
66690
  const recentExchanges = [];
66582
66691
  let iteration = 0;
@@ -66689,6 +66798,7 @@ ${statePacket}` },
66689
66798
  latest: getLatest,
66690
66799
  absorbObservation,
66691
66800
  resolveRef,
66801
+ credentials,
66692
66802
  screenName: () => lastScreenName || "UnknownScreen",
66693
66803
  recordAction: (action) => {
66694
66804
  actions.push(finalizeAction(action));
@@ -66710,7 +66820,7 @@ ${statePacket}` },
66710
66820
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
66711
66821
  }
66712
66822
  async function dispatchGenerateTool(args) {
66713
- const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
66823
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
66714
66824
  const unknownRef = (ref) => ({
66715
66825
  ok: false,
66716
66826
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -66776,6 +66886,14 @@ ${statePacket}` },
66776
66886
  if (!element)
66777
66887
  return unknownRef(toolArgs.ref);
66778
66888
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66889
+ for (const [key, secret] of Object.entries(credentials)) {
66890
+ if (secret && secret.length >= 4 && value.includes(secret)) {
66891
+ return {
66892
+ ok: false,
66893
+ error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
66894
+ };
66895
+ }
66896
+ }
66779
66897
  const beforeScreenName = screenName();
66780
66898
  const result = await driver.type(value, element.bounds);
66781
66899
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
@@ -66789,6 +66907,37 @@ ${statePacket}` },
66789
66907
  }
66790
66908
  return actionPayload(result, absorbed);
66791
66909
  }
66910
+ // KEY-based credential fill: type the real value on-device, persist only a
66911
+ // {{KEY}} placeholder in the recorded step (replay resolves it against the
66912
+ // run's current test variables). The tool result echoes the KEY, never the
66913
+ // value, so the secret cannot enter the transcript.
66914
+ case "mobile_fill_credential": {
66915
+ const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
66916
+ if (!key) {
66917
+ return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
66918
+ }
66919
+ const secret = credentials[key];
66920
+ if (!secret) {
66921
+ const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
66922
+ return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
66923
+ }
66924
+ const element = resolveRef(toolArgs.ref);
66925
+ if (!element)
66926
+ return unknownRef(toolArgs.ref);
66927
+ const beforeScreenName = screenName();
66928
+ const result = await driver.type(secret, element.bounds);
66929
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
66930
+ if (shouldRecordAction(result, absorbed)) {
66931
+ recordAction({
66932
+ type: "TYPE",
66933
+ value: (0, shared_1.mobileCredentialPlaceholder)(key),
66934
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
66935
+ metadata: metadataForElement(element, beforeScreenName, platform3)
66936
+ });
66937
+ log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
66938
+ }
66939
+ return { ...actionPayload(result, absorbed), filledCredentialKey: key };
66940
+ }
66792
66941
  case "mobile_clear": {
66793
66942
  const element = resolveRef(toolArgs.ref);
66794
66943
  if (!element)
@@ -66937,18 +67086,40 @@ ${statePacket}` },
66937
67086
  return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
66938
67087
  }
66939
67088
  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"
67089
+ var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
67090
+ "content",
67091
+ "decor",
67092
+ "decor_content_parent",
67093
+ "action_bar_root",
67094
+ "statusBarBackground",
67095
+ "navigationBarBackground",
67096
+ // WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
67097
+ // expose their framework ROOT container's DOM id as the element id — present on
67098
+ // EVERY in-app screen, so the floor must not anchor to it (observed live on
67099
+ // ThoughtStream: `main-content` produced a screen-agnostic assertion that then
67100
+ // never matched via the text-contains fallback).
67101
+ "main-content",
67102
+ "root",
67103
+ "app",
67104
+ "app-root",
67105
+ "__next",
67106
+ "react-root",
67107
+ "application"
66946
67108
  ]);
66947
67109
  function isAlwaysPresentContainer(element) {
66948
67110
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
66949
67111
  return true;
66950
67112
  const resourceId = element.resourceId?.trim();
66951
- return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
67113
+ if (resourceId) {
67114
+ const slashIdx = resourceId.indexOf("/");
67115
+ const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
67116
+ if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
67117
+ return true;
67118
+ }
67119
+ const accessibilityId = element.accessibilityId?.trim();
67120
+ if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
67121
+ return true;
67122
+ return false;
66952
67123
  }
66953
67124
  function appendHybridFloor(actions, recording, platform3) {
66954
67125
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -66962,9 +67133,38 @@ ${statePacket}` },
66962
67133
  }));
66963
67134
  return true;
66964
67135
  }
67136
+ function actionCycleKey(action) {
67137
+ return `${action.type}|${action.selector}|${action.value ?? ""}`;
67138
+ }
67139
+ var MAX_RETRY_CYCLE_LENGTH = 8;
67140
+ function collapseRetryCycles(actions) {
67141
+ const out = [...actions];
67142
+ let changed = true;
67143
+ while (changed) {
67144
+ changed = false;
67145
+ const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
67146
+ for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
67147
+ for (let i = 0; i + 2 * window2 <= out.length; i++) {
67148
+ const first = out.slice(i, i + window2);
67149
+ if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
67150
+ continue;
67151
+ const second = out.slice(i + window2, i + 2 * window2);
67152
+ if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
67153
+ continue;
67154
+ out.splice(i, window2);
67155
+ changed = true;
67156
+ break;
67157
+ }
67158
+ }
67159
+ }
67160
+ return out;
67161
+ }
66965
67162
  function assembleTest(args) {
66966
67163
  const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
66967
- const actions = [...recording.actions];
67164
+ const actions = collapseRetryCycles(recording.actions);
67165
+ if (actions.length < recording.actions.length) {
67166
+ log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
67167
+ }
66968
67168
  const floorAppended = appendHybridFloor(actions, recording, platform3);
66969
67169
  if (floorAppended) {
66970
67170
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -67003,6 +67203,12 @@ ${statePacket}` },
67003
67203
  playwrightCode: "",
67004
67204
  verified,
67005
67205
  tags,
67206
+ // Per-test feature label → TestCase.suite. One deep run plans scenarios for
67207
+ // MULTIPLE feature groups (the planner reuses survey feature names), so the
67208
+ // run-level ctx.featureName fallback in /discovery-test would wrongly file
67209
+ // every test under the deep-dived feature. The scenario's own group is the
67210
+ // correct home; the runner forwards it as `suiteName`.
67211
+ ...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
67006
67212
  // Carry the planner scenario identity so incremental/resume can match an
67007
67213
  // already-generated scenario reliably (scenarioRef.name === the plan's
67008
67214
  // scenario name). Without this the resume skip-set falls back to the
@@ -67020,7 +67226,27 @@ ${statePacket}` },
67020
67226
  startFromLanding: scenario.startFromLanding,
67021
67227
  targetPages: [...scenario.targetPages]
67022
67228
  },
67023
- evidence: { observedLocators: [], observedTransitions: [] }
67229
+ evidence: { observedLocators: [], observedTransitions: [] },
67230
+ // Assertion-strength marker for the server promote-seam
67231
+ // (resolveStabilityTransition): an UNVERIFIED recording proves none of the
67232
+ // scenario's outcomes — only the hybrid floor's landing-screen visibility —
67233
+ // so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
67234
+ // human instead of letting the pass auto-promote it into the ACTIVE suite.
67235
+ // Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
67236
+ ...verified ? {} : {
67237
+ gateActivity: {
67238
+ outcomeGate: {
67239
+ requiredCount: scenario.expectedOutcomes.length,
67240
+ uncoveredCount: scenario.expectedOutcomes.length
67241
+ },
67242
+ strengthGate: {
67243
+ fired: false,
67244
+ severity: "block",
67245
+ blocked: true,
67246
+ 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."
67247
+ }
67248
+ }
67249
+ }
67024
67250
  }
67025
67251
  };
67026
67252
  return test;
@@ -67041,6 +67267,7 @@ ${statePacket}` },
67041
67267
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
67042
67268
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
67043
67269
  const targetAppId = ctx.target?.appId?.trim() || void 0;
67270
+ const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
67044
67271
  const skipSet = new Set(skipScenarioNames ?? []);
67045
67272
  const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
67046
67273
  if (skipSet.size > 0) {
@@ -67069,6 +67296,7 @@ ${statePacket}` },
67069
67296
  onAICall,
67070
67297
  onScreenshot,
67071
67298
  targetAppId,
67299
+ credentials: credentialSecrets,
67072
67300
  log: log2
67073
67301
  });
67074
67302
  consecutiveInfraFailures = 0;
@@ -313691,9 +313919,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
313691
313919
  });
313692
313920
  let featureInsight;
313693
313921
  if (mode === "deep" && ctx.featureName) {
313922
+ const targetName = ctx.featureName.trim().toLowerCase();
313923
+ const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
313924
+ const plannedDescription = plannedGroup?.description?.trim();
313694
313925
  featureInsight = {
313695
313926
  featureName: ctx.featureName,
313696
- description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313927
+ description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
313697
313928
  };
313698
313929
  }
313699
313930
  const duration = (Date.now() - startTime) / 1e3;
@@ -328229,7 +328460,7 @@ var require_package4 = __commonJS({
328229
328460
  "package.json"(exports2, module2) {
328230
328461
  module2.exports = {
328231
328462
  name: "@validate.qa/runner",
328232
- version: "1.0.17",
328463
+ version: "1.0.19",
328233
328464
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328234
328465
  bin: {
328235
328466
  "validate-runner": "dist/cli.js"
@@ -330507,6 +330738,10 @@ async function executeMobileTest(options) {
330507
330738
  const logLines = [];
330508
330739
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330509
330740
  const startTime = Date.now();
330741
+ const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
330742
+ if (missingVariableKeys.length > 0) {
330743
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
330744
+ }
330510
330745
  const stepResults = [];
330511
330746
  const screenshots = [];
330512
330747
  let apiCalls = [];
@@ -330644,7 +330879,7 @@ async function executeMobileTest(options) {
330644
330879
  let entryDrift = false;
330645
330880
  const runPlatform = options.target.platform;
330646
330881
  const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
330647
- const entryStep = options.steps.find(
330882
+ const entryStep = runSteps.find(
330648
330883
  (s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
330649
330884
  );
330650
330885
  if (entryStep) {
@@ -330661,7 +330896,7 @@ async function executeMobileTest(options) {
330661
330896
  }
330662
330897
  }
330663
330898
  let environmental = null;
330664
- for (const step of options.steps) {
330899
+ for (const step of runSteps) {
330665
330900
  if (entryDrift) break;
330666
330901
  if (!stepAppliesToPlatform(step, runPlatform)) {
330667
330902
  log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
@@ -330749,7 +330984,7 @@ async function executeMobileTest(options) {
330749
330984
  }
330750
330985
  const executedResults = stepResults.filter((r) => r.status !== "skipped");
330751
330986
  const verifyOrders = new Set(
330752
- options.steps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330987
+ runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
330753
330988
  );
330754
330989
  const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
330755
330990
  const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
@@ -330989,11 +331224,15 @@ async function harvestPlatformLocators(options) {
330989
331224
  const log2 = (msg) => {
330990
331225
  logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
330991
331226
  };
331227
+ const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
331228
+ if (missingVariableKeys.length > 0) {
331229
+ log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
331230
+ }
330992
331231
  const bundles = {};
330993
331232
  const divergentOrders = [];
330994
331233
  let reachedOrder = null;
330995
331234
  let environmentalError;
330996
- log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${options.steps.length} steps`);
331235
+ log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
330997
331236
  const session = await createMobileDiscoverySession({
330998
331237
  appId: options.target.appId,
330999
331238
  platform: platform3,
@@ -331013,7 +331252,7 @@ async function harvestPlatformLocators(options) {
331013
331252
  }
331014
331253
  }
331015
331254
  await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
331016
- for (const step of options.steps) {
331255
+ for (const step of harvestSteps) {
331017
331256
  if (step.action === "launchApp") continue;
331018
331257
  if (!stepAppliesToPlatform(step, platform3)) continue;
331019
331258
  let elementId = null;
@@ -331257,12 +331496,46 @@ async function tapAt(sessionPath, bounds) {
331257
331496
  throw new Error("TAP requires usable bounds (finite x/y)");
331258
331497
  }
331259
331498
  }
331260
- async function typeText(sessionPath, value, bounds) {
331499
+ async function getElementRect(sessionPath, elementId) {
331500
+ try {
331501
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331502
+ if (!value || typeof value !== "object") return null;
331503
+ const r = value;
331504
+ 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)) {
331505
+ return null;
331506
+ }
331507
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
331508
+ } catch {
331509
+ return null;
331510
+ }
331511
+ }
331512
+ function rectMatchesIntendedBounds(rect, bounds) {
331513
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
331514
+ const rectCenterX = rect.x + rect.width / 2;
331515
+ const rectCenterY = rect.y + rect.height / 2;
331516
+ const boundsCenterX = bounds.x + bounds.width / 2;
331517
+ const boundsCenterY = bounds.y + bounds.height / 2;
331518
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331519
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331520
+ return rectCenterInBounds || boundsCenterInRect;
331521
+ }
331522
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
331523
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
331261
331524
  let elementId = await getActiveElementId2(sessionPath);
331525
+ if (elementId && box && box.width > 0 && box.height > 0) {
331526
+ const rect = await getElementRect(sessionPath, elementId);
331527
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331528
+ elementId = null;
331529
+ }
331530
+ }
331262
331531
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331263
331532
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331264
331533
  elementId = await getActiveElementId2(sessionPath);
331265
331534
  }
331535
+ return elementId;
331536
+ }
331537
+ async function typeText(sessionPath, value, bounds) {
331538
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331266
331539
  if (!elementId) {
331267
331540
  throw new Error("No focused element available for TYPE. Tap an input field first.");
331268
331541
  }
@@ -331272,11 +331545,7 @@ async function typeText(sessionPath, value, bounds) {
331272
331545
  });
331273
331546
  }
331274
331547
  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
- }
331548
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331280
331549
  if (!elementId) {
331281
331550
  throw new Error("No focused element available for CLEAR. Tap an input field first.");
331282
331551
  }
@@ -333026,6 +333295,7 @@ function scrubAuditMessages(messages, testVariables) {
333026
333295
  const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
333027
333296
  if (secretValues.length === 0) return compacted;
333028
333297
  let json = JSON.stringify(compacted);
333298
+ if (typeof json !== "string") return compacted;
333029
333299
  for (const val of secretValues) {
333030
333300
  const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
333031
333301
  json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
@@ -334377,7 +334647,9 @@ ${finalVerifyResult.logs ?? ""}`;
334377
334647
  },
334378
334648
  onSessionEnding: (deviceId) => {
334379
334649
  detachMirrorSession(deviceId);
334380
- }
334650
+ },
334651
+ // Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
334652
+ testVariables: run2.testVariables
334381
334653
  });
334382
334654
  let queuedRunId = null;
334383
334655
  let resultPosted = false;
@@ -335146,7 +335418,11 @@ ${finalVerifyResult.logs ?? ""}`;
335146
335418
  },
335147
335419
  onSessionEnding: (deviceId) => {
335148
335420
  detachMirrorSession(deviceId);
335149
- }
335421
+ },
335422
+ // Resolve {{KEY}} credential placeholders (recorded by the mobile
335423
+ // discovery generator instead of secret values) against this run's
335424
+ // CURRENT test variables.
335425
+ testVariables: run2.testVariables
335150
335426
  });
335151
335427
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
335152
335428
  ${result2.logs}`);