@validate.qa/runner 1.0.30 → 1.0.32

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 +576 -55
  2. package/dist/cli.mjs +576 -55
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -2349,40 +2349,40 @@ var init_plans = __esm({
2349
2349
  type: "extra_projects",
2350
2350
  label: "Extra Projects",
2351
2351
  description: "Add more projects to your workspace",
2352
- priceLabel: "$19.99 / month",
2353
- monthlyPriceUsd: 19.99,
2352
+ priceLabel: "$24.99 / month",
2353
+ monthlyPriceUsd: 24.99,
2354
2354
  limitIncrement: 1
2355
2355
  },
2356
2356
  extra_tests: {
2357
2357
  type: "extra_tests",
2358
2358
  label: "Extra Tests",
2359
2359
  description: "Increase tests per project limit",
2360
- priceLabel: "$4.99 / month",
2361
- monthlyPriceUsd: 4.99,
2362
- limitIncrement: 100
2360
+ priceLabel: "$12.99 / month",
2361
+ monthlyPriceUsd: 12.99,
2362
+ limitIncrement: 50
2363
2363
  },
2364
2364
  extra_suite_runs: {
2365
2365
  type: "extra_suite_runs",
2366
2366
  label: "Extra Suite Runs",
2367
2367
  description: "Increase daily suite run limit",
2368
- priceLabel: "$7.99 / month",
2369
- monthlyPriceUsd: 7.99,
2368
+ priceLabel: "$12.99 / month",
2369
+ monthlyPriceUsd: 12.99,
2370
2370
  limitIncrement: 5
2371
2371
  },
2372
2372
  extra_discovery_runs: {
2373
2373
  type: "extra_discovery_runs",
2374
2374
  label: "Extra Discovery Runs",
2375
2375
  description: "Increase discovery run limit",
2376
- priceLabel: "$6.99 / month",
2377
- monthlyPriceUsd: 6.99,
2376
+ priceLabel: "$19.99 / month",
2377
+ monthlyPriceUsd: 19.99,
2378
2378
  limitIncrement: 5
2379
2379
  },
2380
2380
  extra_test_executions: {
2381
2381
  type: "extra_test_executions",
2382
2382
  label: "Extra Test Executions",
2383
2383
  description: "+100 manual test runs per day, on top of your plan\u2019s daily dashboard run budget",
2384
- priceLabel: "$9.99 / month",
2385
- monthlyPriceUsd: 9.99,
2384
+ priceLabel: "$14.99 / month",
2385
+ monthlyPriceUsd: 14.99,
2386
2386
  limitIncrement: 100
2387
2387
  }
2388
2388
  };
@@ -2407,16 +2407,16 @@ var init_plans = __esm({
2407
2407
  PLAN_DEFINITIONS = {
2408
2408
  standard: {
2409
2409
  code: "standard",
2410
- label: "Standard Trial",
2410
+ label: "Standard",
2411
2411
  priceLabel: "Free",
2412
2412
  monthlyPriceUsd: 0,
2413
2413
  billingModel: "free",
2414
- summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with enough headroom for one real project.",
2414
+ summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with a starter budget for one real project.",
2415
2415
  maxProjects: 1,
2416
- maxTestsPerProject: 100,
2416
+ maxTestsPerProject: 20,
2417
2417
  maxSuiteRunsPerDay: 1,
2418
- maxTestExecutionsPerDay: 50,
2419
- // 25 regression / 25 manual at the default 50/50 split
2418
+ maxTestExecutionsPerDay: 10,
2419
+ // 5 regression / 5 manual at the default 50/50 split
2420
2420
  maxDiscoveryRunsPerDay: 1,
2421
2421
  // Per day
2422
2422
  maxConcurrentDiscoveries: 1,
@@ -2498,14 +2498,14 @@ var init_plans = __esm({
2498
2498
  };
2499
2499
  TRIAL_DURATION_DAYS = 30;
2500
2500
  CREDIT_PRICES_CENTS = {
2501
- discovery_run: 50,
2501
+ discovery_run: 200,
2502
+ // $2.00
2503
+ suite_run: 50,
2502
2504
  // $0.50
2503
- suite_run: 10,
2504
- // $0.10
2505
- test_run: 10,
2506
- // $0.10
2507
- heal: 10
2508
- // $0.10
2505
+ test_run: 15,
2506
+ // $0.15
2507
+ heal: 75
2508
+ // $0.75
2509
2509
  };
2510
2510
  AI_CREDIT_MARGIN = 1.5;
2511
2511
  CREDIT_PACKS_CENTS = [2e3, 5e3, 1e4];
@@ -5006,6 +5006,29 @@ var require_mobile_observation = __commonJS({
5006
5006
  }
5007
5007
  });
5008
5008
 
5009
+ // ../runner-core/dist/services/mobile/mobile-keyboard.js
5010
+ var require_mobile_keyboard = __commonJS({
5011
+ "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports) {
5012
+ "use strict";
5013
+ Object.defineProperty(exports, "__esModule", { value: true });
5014
+ exports.shouldRetryTapAfterKeyboardDismiss = shouldRetryTapAfterKeyboardDismiss;
5015
+ exports.dismissKeyboardForRetry = dismissKeyboardForRetry;
5016
+ var EDITABLE_FOCUSED_ROLE_RE = /(EditText|TextField|SecureTextField|SearchField|TextView)$/i;
5017
+ var OUTSIDE_VIEWPORT_ERROR_RE = /(outside|out of).*?(bounds|viewport|visible|screen|window)|coordinate.*?(invalid|outside)/i;
5018
+ function shouldRetryTapAfterKeyboardDismiss(error2, elements) {
5019
+ if (!error2 || !OUTSIDE_VIEWPORT_ERROR_RE.test(error2))
5020
+ return false;
5021
+ return elements.some((el) => el.focused === true && EDITABLE_FOCUSED_ROLE_RE.test(el.role));
5022
+ }
5023
+ async function dismissKeyboardForRetry(driver) {
5024
+ if (typeof driver.dismissKeyboard === "function") {
5025
+ return driver.dismissKeyboard();
5026
+ }
5027
+ return driver.back();
5028
+ }
5029
+ }
5030
+ });
5031
+
5009
5032
  // ../runner-core/dist/utils/scrub-credentials.js
5010
5033
  var require_scrub_credentials = __commonJS({
5011
5034
  "../runner-core/dist/utils/scrub-credentials.js"(exports) {
@@ -61987,6 +62010,7 @@ var require_mobile_explorer = __commonJS({
61987
62010
  var mobile_source_parser_js_1 = require_mobile_source_parser();
61988
62011
  var mobile_observation_js_1 = require_mobile_observation();
61989
62012
  var mobile_boundary_js_1 = require_mobile_boundary();
62013
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
61990
62014
  var credential_tools_js_1 = require_credential_tools();
61991
62015
  var verification_inbox_js_1 = require_verification_inbox();
61992
62016
  var inbox_tools_js_1 = require_inbox_tools();
@@ -61996,6 +62020,7 @@ var require_mobile_explorer = __commonJS({
61996
62020
  var MAX_RECENT_EXCHANGES = 24;
61997
62021
  var OBSERVATION_MAX_ELEMENTS = 50;
61998
62022
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
62023
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
61999
62024
  var MOBILE_SNAPSHOT_TOOL = {
62000
62025
  type: "function",
62001
62026
  function: {
@@ -62561,9 +62586,46 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62561
62586
  externalOwner: boundary.owner
62562
62587
  };
62563
62588
  }
62564
- log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
62589
+ let lastExternalOwner = recoveredBoundary.owner;
62590
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
62591
+ try {
62592
+ await driver.launchApp();
62593
+ const retrySnap = await driver.snapshot();
62594
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.windowSize, retrySnap.screenSignal);
62595
+ if (!retryRecovered)
62596
+ continue;
62597
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
62598
+ latest = retryRecovered;
62599
+ lastSnapshotIteration = iterationForObservation;
62600
+ if (retryBoundary.kind === "target" || retryBoundary.kind === "unknown") {
62601
+ const screenId = ingestSnapshot(retryRecovered, iterationForObservation);
62602
+ captureScreenshot(retrySnap.screenshot, screenId);
62603
+ log2(` Recovered target app after ${attempt} additional activation attempt(s).`);
62604
+ return {
62605
+ observation: retryRecovered.observation,
62606
+ screenId,
62607
+ externalBlocked: true,
62608
+ externalOwner: boundary.owner
62609
+ };
62610
+ }
62611
+ if (retryBoundary.kind === "transient_external") {
62612
+ log2(` Recovery surfaced system overlay ${retryBoundary.owner}; not adding it to target-app survey evidence.`);
62613
+ return {
62614
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
62615
+ screenId: null,
62616
+ externalBlocked: true,
62617
+ transientExternal: true,
62618
+ externalOwner: boundary.owner
62619
+ };
62620
+ }
62621
+ lastExternalOwner = retryBoundary.owner;
62622
+ } catch (err) {
62623
+ log2(` Target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
62624
+ }
62625
+ }
62626
+ log2(` Relaunch still outside target app (${lastExternalOwner}); no screen recorded.`);
62565
62627
  return {
62566
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
62628
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no screen was recorded)`,
62567
62629
  screenId: null,
62568
62630
  externalBlocked: true,
62569
62631
  externalOwner: boundary.owner
@@ -62941,7 +63003,19 @@ Exploration complete: ${summary}`);
62941
63003
  const fromScreenId = state2.getCurrentScreenId();
62942
63004
  const boundaryBeforeAction = currentBoundary();
62943
63005
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
62944
- const actionResult = await driver.tap(resolved.bounds);
63006
+ let actionResult = await driver.tap(resolved.bounds);
63007
+ if (!actionResult.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(actionResult.error, latest()?.screen.elements ?? [])) {
63008
+ log2(" Tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
63009
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
63010
+ const retryResult = await driver.tap(resolved.bounds);
63011
+ actionResult = retryResult.ok ? retryResult : {
63012
+ ...retryResult,
63013
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
63014
+ source: retryResult.source ?? dismissResult.source,
63015
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
63016
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
63017
+ };
63018
+ }
62945
63019
  const absorbed = await absorbActionResult(actionResult, "mobile_tap");
62946
63020
  const { observation, screenId } = absorbed;
62947
63021
  if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
@@ -64547,6 +64621,78 @@ var require_discover_ai_planner = __commonJS({
64547
64621
  function meaningfulTokens(name) {
64548
64622
  return name.toLowerCase().split(/[\s\--_]+/).filter((t) => t.length > 2 && !JOURNEY_MATCH_STOPWORDS.has(t));
64549
64623
  }
64624
+ function textIncludesCredentialField(text, field) {
64625
+ return field === "email" ? /\b(e-?mail|username|user\s*name|login\s*id)\b/i.test(text) : /\bpassword|passcode|pin\b/i.test(text);
64626
+ }
64627
+ function textIncludesCredentialSubmit(text) {
64628
+ return /\b(click|tap|press|submit|send)\b[^.]{0,80}\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(text) || /\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b[^.]{0,80}\b(button|cta|form)\b/i.test(text) || /\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b[^.]{0,80}\b(test\s+)?credentials\b/i.test(text) || /\b(test\s+)?credentials\b[^.]{0,80}\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b/i.test(text);
64629
+ }
64630
+ function targetPagesHaveCredentialControls(siteMap, targetPages) {
64631
+ const targetSet = new Set(targetPages.map(normalizePlannerRoute));
64632
+ let hasEmail = false;
64633
+ let hasPassword = false;
64634
+ let hasSubmit = false;
64635
+ for (const page of siteMap.pages) {
64636
+ if (!targetSet.has(normalizePlannerRoute(page.route)))
64637
+ continue;
64638
+ for (const element of page.elements ?? []) {
64639
+ const label = [
64640
+ element.label,
64641
+ element.role,
64642
+ element.testId,
64643
+ element.name
64644
+ ].filter(Boolean).join(" ");
64645
+ if (!label)
64646
+ continue;
64647
+ if (textIncludesCredentialField(label, "email"))
64648
+ hasEmail = true;
64649
+ if (textIncludesCredentialField(label, "password"))
64650
+ hasPassword = true;
64651
+ if (/\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(label) && !/\b(google|apple|facebook|github|sso|oauth|magic\s*link)\b/i.test(label)) {
64652
+ hasSubmit = true;
64653
+ }
64654
+ }
64655
+ }
64656
+ return hasEmail && hasPassword && hasSubmit;
64657
+ }
64658
+ function journeyCoversCredentialSubmit(journey) {
64659
+ if (!journey)
64660
+ return false;
64661
+ const text = [
64662
+ journey.name,
64663
+ journey.expectedOutcome,
64664
+ ...journey.steps ?? [],
64665
+ ...journey.provenCommands ?? []
64666
+ ].join(" ");
64667
+ const hasCredentialPair = /\bcredentials\b/i.test(text) || textIncludesCredentialField(text, "email") && textIncludesCredentialField(text, "password");
64668
+ return hasCredentialPair && textIncludesCredentialSubmit(text);
64669
+ }
64670
+ function scenarioAuthCredentialPolicy(scenario, steps) {
64671
+ const text = [
64672
+ scenario.name,
64673
+ scenario.goal,
64674
+ scenario.type,
64675
+ scenario.variant,
64676
+ ...steps.map((step) => step.action)
64677
+ ].filter(Boolean).join(" ");
64678
+ if (!/\b(auth|authentication|sign[ -]?in|signin|log[ -]?in|login|session)\b/i.test(text)) {
64679
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64680
+ }
64681
+ if (/\b(log[ -]?out|logout|sign[ -]?out|signout|forgot|reset|recover|magic\s*link|otp|mfa|2fa|sso|google|apple|facebook)\b/i.test(text)) {
64682
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64683
+ }
64684
+ if (!/\b(credential|e-?mail|username|user\s*name|password|valid|invalid|empty|required|wrong|case|special|leading|trailing)\b/i.test(text)) {
64685
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64686
+ }
64687
+ const uiOnly = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/i.test(text);
64688
+ const negative = /\b(invalid|empty|required|missing|wrong|error|validation|case|special|leading|trailing)\b/i.test(text);
64689
+ const happy = /\b(happy|valid\s+credentials|success|successful)\b/i.test(text) || scenario.type === "HAPPY_PATH" || scenario.variant === "happy";
64690
+ return {
64691
+ needsCredentialEvidence: true,
64692
+ needsSubmitStep: !uiOnly && (happy || negative),
64693
+ reason: uiOnly ? "credential UI scenario" : negative ? "credential validation scenario" : "credential sign-in scenario"
64694
+ };
64695
+ }
64550
64696
  function truncateOutcome(text, limit) {
64551
64697
  const normalized = text.replace(/\s+/g, " ").trim();
64552
64698
  if (normalized.length <= limit)
@@ -65312,6 +65458,18 @@ Each entry is exactly one of:
65312
65458
  if (matchSource === "route") {
65313
65459
  warnings.push(`"${sc.name}" matched to journey "${matchingSuggestion.name}" by route overlap only - evidence may be weak`);
65314
65460
  }
65461
+ const authCredentialPolicy = scenarioAuthCredentialPolicy(sc, steps);
65462
+ if (authCredentialPolicy.needsCredentialEvidence) {
65463
+ const hasCredentialEvidence = targetPagesHaveCredentialControls(siteMap, sc.targetPages) || journeyCoversCredentialSubmit(matchingSuggestion);
65464
+ if (!hasCredentialEvidence) {
65465
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} lacks observed email/password/sign-in controls or a recorded credential journey`);
65466
+ continue;
65467
+ }
65468
+ if (authCredentialPolicy.needsSubmitStep && !steps.some((step) => textIncludesCredentialSubmit(step.action)) && !journeyCoversCredentialSubmit(matchingSuggestion)) {
65469
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} does not include a concrete sign-in submit step`);
65470
+ continue;
65471
+ }
65472
+ }
65315
65473
  const evidence = (0, discover_planner_js_1.extractScenarioEvidence)(
65316
65474
  siteMap.pages,
65317
65475
  sc.targetPages,
@@ -65857,6 +66015,25 @@ var require_mobile_discovery_prompts = __commonJS({
65857
66015
  function targetAppScreens(graph, appId) {
65858
66016
  return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
65859
66017
  }
66018
+ function isGenericNonFeatureScreen(state2) {
66019
+ const type = state2.screenType?.trim().toLowerCase();
66020
+ if (type === "error" || type === "permission")
66021
+ return true;
66022
+ if (type && type !== "unknown")
66023
+ return false;
66024
+ const actionableCount = state2.elements.filter((el) => el.actionable).length;
66025
+ if (actionableCount > 0)
66026
+ return false;
66027
+ const evidenceText = [
66028
+ state2.screenName,
66029
+ state2.screenType,
66030
+ ...state2.observations ?? []
66031
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ");
66032
+ return /\b(empty|loading|blank|spinner|intermediate|placeholder|no actionable)\b/i.test(evidenceText);
66033
+ }
66034
+ function plannableTargetAppScreens(graph, appId) {
66035
+ return targetAppScreens(graph, appId).filter((state2) => !isGenericNonFeatureScreen(state2));
66036
+ }
65860
66037
  exports.MOBILE_SURVEY_SYSTEM_PROMPT = `You are a feature analyst for a native mobile application (iOS or Android). You receive evidence from an AI explorer that mapped the app's screens by tapping, typing, and swiping through the live UI. Your job is to organize screens into meaningful feature groups.
65861
66038
 
65862
66039
  ## Output Discipline
@@ -65876,8 +66053,8 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65876
66053
  5. CRUD screens for the same entity belong together
65877
66054
  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).
65878
66055
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65879
- 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.
65880
- 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.
66056
+ 8. Do NOT create standalone features for generic error / empty / loading / intermediate screens (network-error, permission-denied, blank WebView, spinner-only loading). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description. If a screen has no actionable controls and only says it is empty/loading/intermediate, omit it from featureGroups.routes unless it is the only evidence for a real product feature.
66057
+ 9. COVER EVERY PRODUCT SCREEN: every explored product screen except generic error / permission / empty / loading / intermediate screens MUST appear in exactly one feature group's routes. Do not leave usable screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous usable 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.
65881
66058
  10. LOGGED-OUT vs LOGGED-IN SEPARATION: screens shown BEFORE the user is signed in (splash, landing/welcome, theme or language pickers, walkthroughs, sign-up, sign-in, forgot-password, demo/preview screens) must NEVER share a feature group with the signed-in app. Put sign-in / sign-up / forgot-password in "Authentication" (rule 4) and the remaining pre-login screens in an "Onboarding" group. Features named after signed-in areas (feeds, dashboards, tabs, settings) must contain ONLY screens reached after authentication. A pre-login flow is a linear sequence, not a navigation destination \u2014 do not let it swallow the app's main features.
65882
66059
 
65883
66060
  ## Output JSON Schema
@@ -65977,6 +66154,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65977
66154
  ## Coverage Strategy
65978
66155
  - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
65979
66156
  - For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 13) + validation (wrong password, empty fields)
66157
+ - For credential sign-in flows: only plan credential scenarios when evidence shows the credential path itself \u2014 an email/username field, a password field, and a native Sign In/Login/Submit control on the target screen, or an Explorer-Recorded Journey that typed those fields and submitted them. Do NOT treat Google/Apple/Facebook/SSO buttons, magic-link entry, logout menus, profile menus, or already-signed-in screens as evidence for credential sign-in. If the explorer only observed signed-in menu/logout/session surfaces, plan session/logout scenarios only; surface missing credential coverage as a gap instead of inventing credential sign-in variants.
65980
66158
  - For CRUD: each scenario MUST own the entity it operates on. Read/search/update/delete/cancel-on-existing/view-existing scenarios MUST include a create step earlier in the same scenario (or declare an immutable seed fixture by name). Never assume a record from prior runs or sibling tests exists. When create/delete are tightly coupled, bundle into a single USER_JOURNEY: create \u2192 verify \u2192 delete \u2192 verify removal.
65981
66159
  - For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule 12 - never stop at "button tapped" or "sheet opened"
65982
66160
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
@@ -66143,7 +66321,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
66143
66321
  sections.push(`## Feature: ${context.featureName}
66144
66322
  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.`);
66145
66323
  }
66146
- const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66324
+ const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66147
66325
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66148
66326
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
66149
66327
  for (const state2 of orderedScreens) {
@@ -66252,7 +66430,7 @@ ${constraints.join("\n")}`);
66252
66430
  const now = /* @__PURE__ */ new Date();
66253
66431
  const idByScreen = /* @__PURE__ */ new Map();
66254
66432
  let pageSeq = 0;
66255
- const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66433
+ const orderedScreens = plannableTargetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66256
66434
  for (const state2 of orderedScreens) {
66257
66435
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
66258
66436
  }
@@ -66313,7 +66491,7 @@ ${constraints.join("\n")}`);
66313
66491
  }
66314
66492
  function buildMobileFeatureGroupsFromGraph(graph, appId) {
66315
66493
  const groups = /* @__PURE__ */ new Map();
66316
- const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66494
+ const orderedScreens = plannableTargetAppScreens(graph, appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66317
66495
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66318
66496
  for (const state2 of orderedScreens) {
66319
66497
  const screenTypeLower = state2.screenType?.toLowerCase();
@@ -66360,7 +66538,7 @@ ${constraints.join("\n")}`);
66360
66538
  return [];
66361
66539
  if (input.featureName?.trim())
66362
66540
  return [];
66363
- const orderedScreens = targetAppScreens(input.graph, input.appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66541
+ const orderedScreens = plannableTargetAppScreens(input.graph, input.appId).filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66364
66542
  if (orderedScreens.length === 0)
66365
66543
  return [];
66366
66544
  const entryScreenId = orderedScreens[0].screenId;
@@ -66562,9 +66740,11 @@ var require_mobile_generator = __commonJS({
66562
66740
  var mobile_source_parser_js_2 = require_mobile_source_parser();
66563
66741
  var mobile_observation_js_1 = require_mobile_observation();
66564
66742
  var mobile_boundary_js_1 = require_mobile_boundary();
66743
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
66565
66744
  var MAX_SCENARIO_ITERATIONS = 30;
66566
66745
  var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
66567
66746
  var MAX_RECENT_EXCHANGES = 24;
66747
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
66568
66748
  var OBSERVATION_MAX_ELEMENTS = 50;
66569
66749
  function finalizeAction(input) {
66570
66750
  return { ...input, url: "", selector: input.selector ?? "" };
@@ -66980,10 +67160,47 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66980
67160
  externalOwner: boundary.owner
66981
67161
  };
66982
67162
  }
66983
- log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
67163
+ let lastExternalOwner = recoveredBoundary.owner;
67164
+ let lastRecovered = recovered;
67165
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
67166
+ try {
67167
+ await driver.launchApp();
67168
+ const retrySnap = await driver.snapshot();
67169
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.screenSignal, retrySnap.windowSize);
67170
+ if (!retryRecovered)
67171
+ continue;
67172
+ lastRecovered = retryRecovered;
67173
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
67174
+ setLatest(retryRecovered);
67175
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(retryBoundary)) {
67176
+ emitScreenshot(retrySnap.screenshot);
67177
+ log2(` recovered target app after ${attempt} additional activation attempt(s).`);
67178
+ return {
67179
+ resolved: retryRecovered,
67180
+ observation: retryRecovered.observation,
67181
+ externalBlocked: true,
67182
+ externalOwner: boundary.owner
67183
+ };
67184
+ }
67185
+ if (retryBoundary.kind === "transient_external") {
67186
+ log2(` recovery surfaced system overlay ${retryBoundary.owner}; not recording it as a target-app step.`);
67187
+ return {
67188
+ resolved: retryRecovered,
67189
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
67190
+ externalBlocked: true,
67191
+ transientExternal: true,
67192
+ externalOwner: boundary.owner
67193
+ };
67194
+ }
67195
+ lastExternalOwner = retryBoundary.owner;
67196
+ } catch (err) {
67197
+ log2(` target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
67198
+ }
67199
+ }
67200
+ log2(` relaunch still outside target app (${lastExternalOwner}); no target-app step recorded.`);
66984
67201
  return {
66985
- resolved: recovered,
66986
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
67202
+ resolved: lastRecovered,
67203
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no target-app step was recorded)`,
66987
67204
  externalBlocked: true,
66988
67205
  externalOwner: boundary.owner
66989
67206
  };
@@ -67197,7 +67414,19 @@ ${statePacket}` },
67197
67414
  return unknownRef(toolArgs.ref);
67198
67415
  const beforeScreenName = screenName();
67199
67416
  const before = latest();
67200
- const result = await driver.tap(element.bounds);
67417
+ let result = await driver.tap(element.bounds);
67418
+ if (!result.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(result.error, before?.screen.elements ?? [])) {
67419
+ log2(" tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
67420
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
67421
+ const retryResult = await driver.tap(element.bounds);
67422
+ result = retryResult.ok ? retryResult : {
67423
+ ...retryResult,
67424
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
67425
+ source: retryResult.source ?? dismissResult.source,
67426
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
67427
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
67428
+ };
67429
+ }
67201
67430
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
67202
67431
  if (shouldRecordAction(result, absorbed) && changedScreen(before, absorbed)) {
67203
67432
  recordAction({
@@ -67453,7 +67682,7 @@ ${statePacket}` },
67453
67682
  "react-root",
67454
67683
  "application"
67455
67684
  ]);
67456
- var STRUCTURAL_ASSERT_ID_RE = /^(?:step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67685
+ var STRUCTURAL_ASSERT_ID_RE = /^(?:skip\s*to\s*main\s*content|skip[-_]?to[-_]?main[-_]?content|step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67457
67686
  function locatorLocalName(value) {
67458
67687
  const trimmed = value.trim();
67459
67688
  const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
@@ -67533,6 +67762,88 @@ ${statePacket}` },
67533
67762
  }));
67534
67763
  return true;
67535
67764
  }
67765
+ function scenarioText(scenario) {
67766
+ return [
67767
+ scenario.name,
67768
+ scenario.goal,
67769
+ ...scenario.steps.flatMap((step) => [step.action, step.target, step.hint]),
67770
+ ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67771
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67772
+ }
67773
+ function actionText(action) {
67774
+ const bundle = action.metadata.locatorBundle;
67775
+ return [
67776
+ action.type,
67777
+ action.selector,
67778
+ action.value,
67779
+ action.innerText,
67780
+ action.metadata.targetLabel,
67781
+ bundle?.text,
67782
+ bundle?.contentDesc,
67783
+ bundle?.accessibilityId,
67784
+ bundle?.resourceId,
67785
+ bundle?.className,
67786
+ bundle?.xpath
67787
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67788
+ }
67789
+ function scenarioIntentMismatchReason(scenario, actions) {
67790
+ const text = scenarioText(scenario);
67791
+ const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out)\b/.test(text);
67792
+ const typedEmailIndexes = [];
67793
+ const typedPasswordIndexes = [];
67794
+ actions.forEach((action, index) => {
67795
+ if (action.type !== "TYPE")
67796
+ return;
67797
+ const textForAction = actionText(action);
67798
+ if (/\b(email|e-mail|username|user name|login)\b|\{\{test_user_email\}\}/i.test(textForAction)) {
67799
+ typedEmailIndexes.push(index);
67800
+ }
67801
+ if (/\b(password|passcode|pin)\b|\{\{test_user_password\}\}/i.test(textForAction)) {
67802
+ typedPasswordIndexes.push(index);
67803
+ }
67804
+ });
67805
+ const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67806
+ const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67807
+ const submitAfterCredentials = actions.some((action, index) => {
67808
+ if (action.type !== "TAP" || index <= lastCredentialIndex)
67809
+ return false;
67810
+ const textForAction = actionText(action);
67811
+ if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
67812
+ return false;
67813
+ return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
67814
+ });
67815
+ const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67816
+ if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67817
+ return "credential fields were filled but no sign-in submit tap was recorded after them";
67818
+ }
67819
+ const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67820
+ const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67821
+ const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67822
+ const loginTapWithoutCredentialPath = actions.some((action) => {
67823
+ if (action.type !== "TAP")
67824
+ return false;
67825
+ const textForAction = actionText(action);
67826
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67827
+ return false;
67828
+ return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67829
+ });
67830
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67831
+ return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67832
+ }
67833
+ if (!isCredentialSignIn)
67834
+ return null;
67835
+ const typedEmail = typedEmailIndexes.length > 0;
67836
+ const typedPassword = typedPasswordIndexes.length > 0;
67837
+ const tappedSubmit = submitAfterCredentials;
67838
+ if (typedEmail && typedPassword && tappedSubmit)
67839
+ return null;
67840
+ const missing = [
67841
+ typedEmail ? null : "email/username entry",
67842
+ typedPassword ? null : "password entry",
67843
+ tappedSubmit ? null : "sign-in submit tap"
67844
+ ].filter((value) => value !== null);
67845
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
67846
+ }
67536
67847
  function actionCycleKey(action) {
67537
67848
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67538
67849
  }
@@ -67586,6 +67897,11 @@ ${statePacket}` },
67586
67897
  if (actions.length < beforeContainerDrop) {
67587
67898
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67588
67899
  }
67900
+ const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67901
+ if (intentMismatch) {
67902
+ log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
67903
+ return null;
67904
+ }
67589
67905
  const floorAppended = appendHybridFloor(actions, recording, platform3);
67590
67906
  if (floorAppended) {
67591
67907
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -68283,7 +68599,8 @@ Reason over steps + error + tree + registry and return working healed steps. Do
68283
68599
  2. STEP RESULTS: which steps passed before the failure.
68284
68600
  3. TREE: does the current screen match what the failed step expects?
68285
68601
  3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs, first-run prompts, onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
68286
- 4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68602
+ 4. EXTERNAL AUTH HANDOFF: if the failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT heal by choosing Google/Apple/Facebook/SSO or another external app. If the test later expects Logout/session state, return test_invalid or prereq_unmet: the recorded test lacks a real credential setup.
68603
+ 5. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68287
68604
  ## Response \u2014 STRICT JSON, exactly one verdict:
68288
68605
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
68289
68606
  {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
@@ -68340,6 +68657,104 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68340
68657
  function isDrift(error2) {
68341
68658
  return /unexpected screen|screen drift/i.test(error2 ?? "");
68342
68659
  }
68660
+ function mobileStepSearchText(step) {
68661
+ const rawTarget = step.target;
68662
+ const structuredTarget = rawTarget && typeof rawTarget === "object" ? rawTarget : void 0;
68663
+ const bundle = structuredTarget?.locatorBundle;
68664
+ const stepValue = step.value;
68665
+ return [
68666
+ step.action,
68667
+ step.description,
68668
+ typeof stepValue === "string" ? stepValue : void 0,
68669
+ typeof rawTarget === "string" ? rawTarget : void 0,
68670
+ structuredTarget?.primary,
68671
+ bundle?.text,
68672
+ bundle?.contentDesc,
68673
+ bundle?.accessibilityId,
68674
+ bundle?.resourceId,
68675
+ bundle?.className,
68676
+ bundle?.xpath
68677
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
68678
+ }
68679
+ function detectInvalidExternalAuthSetup(steps, error2) {
68680
+ if (!/foreground app changed|outside target app|com\.google\.android\.gms|external app/i.test(error2 ?? "")) {
68681
+ return null;
68682
+ }
68683
+ const hasBareLoginTap = steps.some((step) => {
68684
+ if (!/\btap\b/i.test(step.action))
68685
+ return false;
68686
+ const text = mobileStepSearchText(step);
68687
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(text))
68688
+ return false;
68689
+ return /\b(sign\s*in|signin|log\s*in|login)\b/.test(text);
68690
+ });
68691
+ if (!hasBareLoginTap)
68692
+ return null;
68693
+ const hasCredentialEntry = steps.some((step) => {
68694
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
68695
+ return false;
68696
+ const text = mobileStepSearchText(step);
68697
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68698
+ });
68699
+ if (hasCredentialEntry)
68700
+ return null;
68701
+ return "bare Login/Sign In tap left the target app without any recorded credential setup; auth test is invalid until discovery records a real credential path";
68702
+ }
68703
+ function isCredentialFieldStep(step) {
68704
+ const text = mobileStepSearchText(step);
68705
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68706
+ }
68707
+ function isCredentialSubmitTap(step) {
68708
+ if (!/\b(tap|click|press)\b/i.test(step.action))
68709
+ return false;
68710
+ const text = mobileStepSearchText(step);
68711
+ if (/\b(logout|log\s*out|sign\s*out|reset|forgot|google|apple|facebook|github|sso|showemailform|with\s+email|email\s+form)\b/.test(text)) {
68712
+ return false;
68713
+ }
68714
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
68715
+ }
68716
+ function detectInvalidAuthValidationSetup(steps, error2) {
68717
+ if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
68718
+ return null;
68719
+ }
68720
+ const allText = steps.map(mobileStepSearchText).join(" ");
68721
+ if (!/\b(email|e-mail|username|user\s*name|password|passcode|pin)\b/.test(allText))
68722
+ return null;
68723
+ const uiOnlyCredentialScenario = /\b(mask|masked|unmask|visibility|visible password|show password|hide password|keyboard|ime|input type|max length|maxlength|character limit)\b/.test(allText);
68724
+ let lastCredentialEditOrder = -1;
68725
+ for (const step of steps) {
68726
+ if (!/\b(type|fill|input|enter|clear)\b/i.test(step.action))
68727
+ continue;
68728
+ if (!isCredentialFieldStep(step))
68729
+ continue;
68730
+ lastCredentialEditOrder = Math.max(lastCredentialEditOrder, step.order ?? -1);
68731
+ }
68732
+ if (lastCredentialEditOrder < 0)
68733
+ return null;
68734
+ if (uiOnlyCredentialScenario) {
68735
+ const hasCredentialFormEvidence = steps.some((step) => {
68736
+ if ((step.order ?? -1) >= lastCredentialEditOrder)
68737
+ return false;
68738
+ const text = mobileStepSearchText(step);
68739
+ return /\b(showemailformbtn|with\s+email|email\s+form|sign in with email|loginform|login form|email|e-mail|username|user\s*name)\b/.test(text);
68740
+ });
68741
+ if (hasCredentialFormEvidence)
68742
+ return null;
68743
+ return "credential-control test edits a credential field without recording a path to the email/password form; stale auth-control test is invalid until generation records a complete form path";
68744
+ }
68745
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
68746
+ if (hasSubmitAfterCredentialEdit)
68747
+ return null;
68748
+ const hasValidationAssertion = steps.some((step) => {
68749
+ if (!/\b(assert|wait)\b/i.test(step.action))
68750
+ return false;
68751
+ const text = mobileStepSearchText(step);
68752
+ return /\b(invalid|required|error|empty|missing|incorrect|wrong|try again|validation|must enter|enter (an )?(email|password)|email is required|password is required)\b/.test(text);
68753
+ });
68754
+ if (hasValidationAssertion)
68755
+ return null;
68756
+ return "credential field was edited but the test never submits the form or asserts a validation result; stale auth-validation test is invalid until generation records a complete flow";
68757
+ }
68343
68758
  function stripCodeFences(text) {
68344
68759
  const trimmed = text.trim();
68345
68760
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -68598,6 +69013,24 @@ ${args.directive}`);
68598
69013
  const registryPresent = options.selectorRegistryContext != null;
68599
69014
  const platform3 = target.platform;
68600
69015
  const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
69016
+ const invalidExternalAuthSetup = detectInvalidExternalAuthSetup(steps, ctx.error);
69017
+ if (invalidExternalAuthSetup) {
69018
+ return {
69019
+ passed: false,
69020
+ verdict: "test_invalid",
69021
+ healReason: invalidExternalAuthSetup,
69022
+ attemptsUsed: 0
69023
+ };
69024
+ }
69025
+ const invalidAuthValidationSetup = detectInvalidAuthValidationSetup(steps, ctx.error);
69026
+ if (invalidAuthValidationSetup) {
69027
+ return {
69028
+ passed: false,
69029
+ verdict: "test_invalid",
69030
+ healReason: invalidAuthValidationSetup,
69031
+ attemptsUsed: 0
69032
+ };
69033
+ }
68601
69034
  let attemptCap = base2;
68602
69035
  let recoverableUsed = 0;
68603
69036
  let bonusUsed = 0;
@@ -68783,7 +69216,7 @@ ${args.directive}`);
68783
69216
  removeTags: script.removeTags
68784
69217
  };
68785
69218
  }
68786
- var DEFAULT_PHASE2_MAX_PASSES = 6;
69219
+ var DEFAULT_PHASE2_MAX_PASSES = 4;
68787
69220
  exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test with LIVE device access (Phase 2). You receive: the failing test STEPS as JSON, the PRIOR failure error, and the LIVE current screen accessibility tree observed on-device RIGHT NOW. Unlike a frozen failure snapshot, this LIVE tree is the REAL screen the app is on this instant.
68788
69221
  ## Key insight \u2014 ENTRY-SCREEN DRIFT
68789
69222
  The recorded flow may have expected a DIFFERENT starting screen than the one shown. When the LIVE screen is an earlier/other screen (e.g. an onboarding / welcome / splash screen), the fix is almost always a MISSING NAVIGATION step: AUTHOR the leading step(s) (e.g. a tap on the onboarding CTA present in the tree) that reach the recorded first screen, then keep the recorded steps. This is exactly what a static failing-source cannot reveal but a LIVE screen can.
@@ -68792,7 +69225,8 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
68792
69225
  2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
68793
69226
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68794
69227
  3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA or first-run prompt) that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
68795
- 4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
69228
+ 4. If the prior failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT observe/tap Google/Apple/Facebook/SSO. For logout/session tests, return test_invalid or prereq_unmet because the recorded test lacks a real credential setup.
69229
+ 5. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
68796
69230
  ## Response \u2014 STRICT JSON, exactly one verdict:
68797
69231
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
68798
69232
  {"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
@@ -315127,8 +315561,8 @@ var require_mobile_discovery_agent = __commonJS({
315127
315561
  };
315128
315562
  }
315129
315563
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
315130
- const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
315131
- log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315564
+ const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", mode, log2);
315565
+ log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : mode === "survey" && (target?.resetPolicy ?? "AUTO") === "AUTO" ? "App data reset skipped for mobile survey AUTO \u2014 exploring with preserved app data. Use resetPolicy=CLEAR_APP_DATA for a cold logged-out survey." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315132
315566
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
315133
315567
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
315134
315568
  const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
@@ -315370,10 +315804,11 @@ Fatal error: ${msg}`);
315370
315804
  };
315371
315805
  }
315372
315806
  }
315373
- async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
315807
+ async function prepareMobileAppStateForDiscovery(driver, target, phase, mode, log2) {
315374
315808
  void phase;
315375
315809
  const resetPolicy = target?.resetPolicy ?? "AUTO";
315376
- if (resetPolicy === "PRESERVE_APP_DATA") {
315810
+ const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
315811
+ if (shouldPreserveAppData) {
315377
315812
  try {
315378
315813
  await driver.relaunch();
315379
315814
  } catch (err) {
@@ -329894,7 +330329,7 @@ var require_package4 = __commonJS({
329894
330329
  "package.json"(exports, module) {
329895
330330
  module.exports = {
329896
330331
  name: "@validate.qa/runner",
329897
- version: "1.0.30",
330332
+ version: "1.0.32",
329898
330333
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329899
330334
  bin: {
329900
330335
  "validate-runner": "dist/cli.js"
@@ -330043,6 +330478,8 @@ var MAX_RETRIES = 3;
330043
330478
  var STARTUP_TIMEOUT_MS = 3e4;
330044
330479
  var HEALTH_CHECK_INTERVAL_MS = 5e3;
330045
330480
  var STABLE_AFTER_MS = 6e4;
330481
+ var DEFAULT_APPIUM_LOG_LEVEL = "warn";
330482
+ var MAX_APPIUM_LOG_LINE_CHARS = 4e3;
330046
330483
  var appiumProcess = null;
330047
330484
  var serverState = {
330048
330485
  running: false,
@@ -330102,6 +330539,23 @@ function appendLog(line) {
330102
330539
  logs = logs.slice(-MAX_LOG_LINES);
330103
330540
  }
330104
330541
  }
330542
+ function resolveAppiumLogLevel() {
330543
+ const raw = process.env.APPIUM_LOG_LEVEL?.trim();
330544
+ if (raw && /^[a-z]+(?::[a-z]+)?$/i.test(raw)) return raw;
330545
+ return DEFAULT_APPIUM_LOG_LEVEL;
330546
+ }
330547
+ function buildAppiumArgs(port, drivers) {
330548
+ const args = ["--port", String(port), "--log-level", resolveAppiumLogLevel()];
330549
+ if (drivers && drivers.length > 0) {
330550
+ args.push("--use-drivers", drivers.join(","));
330551
+ }
330552
+ return args;
330553
+ }
330554
+ function sanitizeAppiumLogLine(line) {
330555
+ const redacted = line.replace(/"text"\s*:\s*"[^"]*"/g, '"text":"[REDACTED]"').replace(/"password"\s*:\s*"[^"]*"/gi, '"password":"[REDACTED]"').replace(/"token"\s*:\s*"[^"]*"/gi, '"token":"[REDACTED]"').replace(/"value"\s*:\s*\[(?:"[^"]*",?){8,}\]/g, '"value":["[REDACTED]"]').replace(/"value"\s*:\s*"[A-Za-z0-9+/=]{512,}"/g, '"value":"[BASE64_REDACTED]"');
330556
+ if (redacted.length <= MAX_APPIUM_LOG_LINE_CHARS) return redacted;
330557
+ return `${redacted.slice(0, MAX_APPIUM_LOG_LINE_CHARS)}\u2026 [truncated ${redacted.length - MAX_APPIUM_LOG_LINE_CHARS} chars]`;
330558
+ }
330105
330559
  async function checkAppiumHealth(port = serverState.port) {
330106
330560
  try {
330107
330561
  const controller = new AbortController();
@@ -330134,10 +330588,7 @@ async function startAppiumServer(options) {
330134
330588
  return serverState;
330135
330589
  }
330136
330590
  const port = options?.port ?? APPIUM_PORT;
330137
- const args = ["--port", String(port)];
330138
- if (options?.drivers && options.drivers.length > 0) {
330139
- args.push("--use-drivers", options.drivers.join(","));
330140
- }
330591
+ const args = buildAppiumArgs(port, options?.drivers);
330141
330592
  intentionalStop = false;
330142
330593
  lastStartOptions = { ...lastStartOptions, ...options };
330143
330594
  if (appiumGaveUp) {
@@ -330156,14 +330607,14 @@ async function startAppiumServer(options) {
330156
330607
  serverState.pid = appiumProcess.pid ?? null;
330157
330608
  serverState.port = port;
330158
330609
  appiumProcess.stdout?.on("data", (data) => {
330159
- const line = data.toString().trim();
330610
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330160
330611
  if (line) {
330161
330612
  appendLog(`[appium:stdout] ${line}`);
330162
330613
  options?.onLog?.(line);
330163
330614
  }
330164
330615
  });
330165
330616
  appiumProcess.stderr?.on("data", (data) => {
330166
- const line = data.toString().trim();
330617
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330167
330618
  if (line) {
330168
330619
  appendLog(`[appium:stderr] ${line}`);
330169
330620
  options?.onLog?.(line);
@@ -330322,6 +330773,17 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
330322
330773
  // src/services/mobile/appium-actions.ts
330323
330774
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
330324
330775
  var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
330776
+ var APPIUM_DRIVER_DEAD_RE = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET|invalid session id|no such session/i;
330777
+ var AppiumDriverDeadError = class extends Error {
330778
+ isAppiumDriverDead = true;
330779
+ constructor(message) {
330780
+ super(message);
330781
+ this.name = "AppiumDriverDeadError";
330782
+ }
330783
+ };
330784
+ function isAppiumDriverDeadError(err) {
330785
+ return err instanceof AppiumDriverDeadError || typeof err === "object" && err !== null && err.isAppiumDriverDead === true;
330786
+ }
330325
330787
  async function appiumRequest(path, options) {
330326
330788
  const url = `http://localhost:${getAppiumState().port}${path}`;
330327
330789
  const res = await fetch(url, {
@@ -330331,6 +330793,9 @@ async function appiumRequest(path, options) {
330331
330793
  });
330332
330794
  if (!res.ok) {
330333
330795
  const body = await res.text().catch(() => "");
330796
+ if (APPIUM_DRIVER_DEAD_RE.test(body)) {
330797
+ throw new AppiumDriverDeadError(`Appium driver session is not usable (${res.status}): ${body.slice(0, 200)}`);
330798
+ }
330334
330799
  throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
330335
330800
  }
330336
330801
  return res.json();
@@ -330345,7 +330810,8 @@ async function getScreenshot(sessionPath) {
330345
330810
  try {
330346
330811
  const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
330347
330812
  return typeof value === "string" ? value : null;
330348
- } catch {
330813
+ } catch (err) {
330814
+ if (isAppiumDriverDeadError(err)) throw err;
330349
330815
  return null;
330350
330816
  }
330351
330817
  }
@@ -330353,7 +330819,8 @@ async function getPageSource(sessionPath) {
330353
330819
  try {
330354
330820
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
330355
330821
  return typeof value === "string" ? value : null;
330356
- } catch {
330822
+ } catch (err) {
330823
+ if (isAppiumDriverDeadError(err)) throw err;
330357
330824
  return null;
330358
330825
  }
330359
330826
  }
@@ -330369,7 +330836,8 @@ async function getWindowSize(sessionPath) {
330369
330836
  return { width: w, height: h };
330370
330837
  }
330371
330838
  }
330372
- } catch {
330839
+ } catch (err) {
330840
+ if (isAppiumDriverDeadError(err)) throw err;
330373
330841
  }
330374
330842
  if (attempt < retries - 1) {
330375
330843
  await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
@@ -330525,6 +330993,37 @@ async function swipe(sessionPath, direction, distance, windowSize) {
330525
330993
  async function pressBack(sessionPath) {
330526
330994
  await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
330527
330995
  }
330996
+ function isKeyboardAlreadyHiddenError(err) {
330997
+ const message = err instanceof Error ? err.message : String(err);
330998
+ return /keyboard.*(not|already).*(shown|visible|present|open)|no.*keyboard/i.test(message);
330999
+ }
331000
+ async function dismissKeyboard(sessionPath, platform3) {
331001
+ if (platform3 === "ANDROID") {
331002
+ await pressBack(sessionPath);
331003
+ return;
331004
+ }
331005
+ try {
331006
+ await appiumRequest(`${sessionPath}/appium/device/hide_keyboard`, {
331007
+ method: "POST",
331008
+ body: "{}"
331009
+ });
331010
+ return;
331011
+ } catch (err) {
331012
+ if (isAppiumDriverDeadError(err)) throw err;
331013
+ if (isKeyboardAlreadyHiddenError(err)) return;
331014
+ }
331015
+ try {
331016
+ await appiumRequest(`${sessionPath}/execute/sync`, {
331017
+ method: "POST",
331018
+ body: JSON.stringify({ script: "mobile: hideKeyboard", args: [{}] })
331019
+ });
331020
+ return;
331021
+ } catch (err) {
331022
+ if (isAppiumDriverDeadError(err)) throw err;
331023
+ if (isKeyboardAlreadyHiddenError(err)) return;
331024
+ }
331025
+ await pressBack(sessionPath);
331026
+ }
330528
331027
  async function pressHome(sessionPath, platform3) {
330529
331028
  if (platform3 === "IOS") {
330530
331029
  await appiumRequest(`${sessionPath}/execute/sync`, {
@@ -331713,6 +332212,7 @@ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
331713
332212
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
331714
332213
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
331715
332214
  var WD_ACTION_TIMEOUT_MS = 3e4;
332215
+ var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
331716
332216
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
331717
332217
  var execFileAsync2 = promisify2(execFile2);
331718
332218
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
@@ -331749,6 +332249,9 @@ async function wdRequest(path, options) {
331749
332249
  if (res.status === 404 && /no such session|invalid session id|session is either terminated|deleted session/i.test(body)) {
331750
332250
  throw new WebDriverTransportError(`WebDriver session is no longer available: ${body.slice(0, 200)}`);
331751
332251
  }
332252
+ if (res.status === 500 && APPIUM_DRIVER_DEAD_RE2.test(body)) {
332253
+ throw new WebDriverTransportError(`WebDriver driver process is not usable: ${body.slice(0, 200)}`);
332254
+ }
331752
332255
  if (res.status === 502 || res.status === 503 || res.status === 504) {
331753
332256
  throw new WebDriverTransportError(`Appium server unavailable (${res.status}): ${body.slice(0, 200)}`);
331754
332257
  }
@@ -334517,6 +335020,10 @@ var MobileBridgeDriver = class {
334517
335020
  async back() {
334518
335021
  return this.runAction(() => pressBack(this.sessionPath));
334519
335022
  }
335023
+ /** Platform-aware software keyboard dismissal. */
335024
+ async dismissKeyboard() {
335025
+ return this.runAction(() => dismissKeyboard(this.sessionPath, this.platform));
335026
+ }
334520
335027
  /** System Home — backgrounds the app only (use `relaunch` for hermetic reset). */
334521
335028
  async home() {
334522
335029
  return this.runAction(() => pressHome(this.sessionPath, this.platform));
@@ -334902,8 +335409,12 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
334902
335409
  pageUrl: call.pageUrl ?? pageUrl
334903
335410
  }));
334904
335411
  }
335412
+ var runnerPackage = require_package4();
334905
335413
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
334906
335414
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
335415
+ var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
335416
+ var RUNNER_VERSION = runnerPackage.version ?? "0.0.0";
335417
+ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION;
334907
335418
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
334908
335419
  var DEFAULT_TYPE_LIMITS = {
334909
335420
  discovery: IS_CLOUD ? 2 : 15,
@@ -336706,7 +337217,14 @@ ${finalVerifyResult.logs ?? ""}`;
336706
337217
  if (getBridgeState().hasActiveSession) {
336707
337218
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
336708
337219
  }
336709
- const mobileOnLog = (line) => onLog("progress", line);
337220
+ const mobileProgressLines = [];
337221
+ const mobileOnLog = (line) => {
337222
+ mobileProgressLines.push(line);
337223
+ if (mobileProgressLines.length > 500) {
337224
+ mobileProgressLines.splice(0, mobileProgressLines.length - 500);
337225
+ }
337226
+ onLog("progress", mobileProgressLines.join("\n"));
337227
+ };
336710
337228
  liveBrowserHandle.patch({
336711
337229
  mode: ctx.mode === "survey" ? "survey" : "discovery",
336712
337230
  testName: ctx.featureName ?? "Mobile discovery",
@@ -337730,6 +338248,9 @@ async function startRunner(opts) {
337730
338248
  method: "POST",
337731
338249
  headers,
337732
338250
  body: JSON.stringify({
338251
+ runnerName: RUNNER_NAME,
338252
+ runnerVersion: RUNNER_VERSION,
338253
+ runnerBuildId: RUNNER_BUILD_ID,
337733
338254
  runsCompleted,
337734
338255
  activeRuns,
337735
338256
  maxConcurrent: MAX_CONCURRENT,