@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.js CHANGED
@@ -2344,40 +2344,40 @@ var init_plans = __esm({
2344
2344
  type: "extra_projects",
2345
2345
  label: "Extra Projects",
2346
2346
  description: "Add more projects to your workspace",
2347
- priceLabel: "$19.99 / month",
2348
- monthlyPriceUsd: 19.99,
2347
+ priceLabel: "$24.99 / month",
2348
+ monthlyPriceUsd: 24.99,
2349
2349
  limitIncrement: 1
2350
2350
  },
2351
2351
  extra_tests: {
2352
2352
  type: "extra_tests",
2353
2353
  label: "Extra Tests",
2354
2354
  description: "Increase tests per project limit",
2355
- priceLabel: "$4.99 / month",
2356
- monthlyPriceUsd: 4.99,
2357
- limitIncrement: 100
2355
+ priceLabel: "$12.99 / month",
2356
+ monthlyPriceUsd: 12.99,
2357
+ limitIncrement: 50
2358
2358
  },
2359
2359
  extra_suite_runs: {
2360
2360
  type: "extra_suite_runs",
2361
2361
  label: "Extra Suite Runs",
2362
2362
  description: "Increase daily suite run limit",
2363
- priceLabel: "$7.99 / month",
2364
- monthlyPriceUsd: 7.99,
2363
+ priceLabel: "$12.99 / month",
2364
+ monthlyPriceUsd: 12.99,
2365
2365
  limitIncrement: 5
2366
2366
  },
2367
2367
  extra_discovery_runs: {
2368
2368
  type: "extra_discovery_runs",
2369
2369
  label: "Extra Discovery Runs",
2370
2370
  description: "Increase discovery run limit",
2371
- priceLabel: "$6.99 / month",
2372
- monthlyPriceUsd: 6.99,
2371
+ priceLabel: "$19.99 / month",
2372
+ monthlyPriceUsd: 19.99,
2373
2373
  limitIncrement: 5
2374
2374
  },
2375
2375
  extra_test_executions: {
2376
2376
  type: "extra_test_executions",
2377
2377
  label: "Extra Test Executions",
2378
2378
  description: "+100 manual test runs per day, on top of your plan\u2019s daily dashboard run budget",
2379
- priceLabel: "$9.99 / month",
2380
- monthlyPriceUsd: 9.99,
2379
+ priceLabel: "$14.99 / month",
2380
+ monthlyPriceUsd: 14.99,
2381
2381
  limitIncrement: 100
2382
2382
  }
2383
2383
  };
@@ -2402,16 +2402,16 @@ var init_plans = __esm({
2402
2402
  PLAN_DEFINITIONS = {
2403
2403
  standard: {
2404
2404
  code: "standard",
2405
- label: "Standard Trial",
2405
+ label: "Standard",
2406
2406
  priceLabel: "Free",
2407
2407
  monthlyPriceUsd: 0,
2408
2408
  billingModel: "free",
2409
- summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with enough headroom for one real project.",
2409
+ summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with a starter budget for one real project.",
2410
2410
  maxProjects: 1,
2411
- maxTestsPerProject: 100,
2411
+ maxTestsPerProject: 20,
2412
2412
  maxSuiteRunsPerDay: 1,
2413
- maxTestExecutionsPerDay: 50,
2414
- // 25 regression / 25 manual at the default 50/50 split
2413
+ maxTestExecutionsPerDay: 10,
2414
+ // 5 regression / 5 manual at the default 50/50 split
2415
2415
  maxDiscoveryRunsPerDay: 1,
2416
2416
  // Per day
2417
2417
  maxConcurrentDiscoveries: 1,
@@ -2493,14 +2493,14 @@ var init_plans = __esm({
2493
2493
  };
2494
2494
  TRIAL_DURATION_DAYS = 30;
2495
2495
  CREDIT_PRICES_CENTS = {
2496
- discovery_run: 50,
2496
+ discovery_run: 200,
2497
+ // $2.00
2498
+ suite_run: 50,
2497
2499
  // $0.50
2498
- suite_run: 10,
2499
- // $0.10
2500
- test_run: 10,
2501
- // $0.10
2502
- heal: 10
2503
- // $0.10
2500
+ test_run: 15,
2501
+ // $0.15
2502
+ heal: 75
2503
+ // $0.75
2504
2504
  };
2505
2505
  AI_CREDIT_MARGIN = 1.5;
2506
2506
  CREDIT_PACKS_CENTS = [2e3, 5e3, 1e4];
@@ -5001,6 +5001,29 @@ var require_mobile_observation = __commonJS({
5001
5001
  }
5002
5002
  });
5003
5003
 
5004
+ // ../runner-core/dist/services/mobile/mobile-keyboard.js
5005
+ var require_mobile_keyboard = __commonJS({
5006
+ "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports2) {
5007
+ "use strict";
5008
+ Object.defineProperty(exports2, "__esModule", { value: true });
5009
+ exports2.shouldRetryTapAfterKeyboardDismiss = shouldRetryTapAfterKeyboardDismiss;
5010
+ exports2.dismissKeyboardForRetry = dismissKeyboardForRetry;
5011
+ var EDITABLE_FOCUSED_ROLE_RE = /(EditText|TextField|SecureTextField|SearchField|TextView)$/i;
5012
+ var OUTSIDE_VIEWPORT_ERROR_RE = /(outside|out of).*?(bounds|viewport|visible|screen|window)|coordinate.*?(invalid|outside)/i;
5013
+ function shouldRetryTapAfterKeyboardDismiss(error2, elements) {
5014
+ if (!error2 || !OUTSIDE_VIEWPORT_ERROR_RE.test(error2))
5015
+ return false;
5016
+ return elements.some((el) => el.focused === true && EDITABLE_FOCUSED_ROLE_RE.test(el.role));
5017
+ }
5018
+ async function dismissKeyboardForRetry(driver) {
5019
+ if (typeof driver.dismissKeyboard === "function") {
5020
+ return driver.dismissKeyboard();
5021
+ }
5022
+ return driver.back();
5023
+ }
5024
+ }
5025
+ });
5026
+
5004
5027
  // ../runner-core/dist/utils/scrub-credentials.js
5005
5028
  var require_scrub_credentials = __commonJS({
5006
5029
  "../runner-core/dist/utils/scrub-credentials.js"(exports2) {
@@ -61982,6 +62005,7 @@ var require_mobile_explorer = __commonJS({
61982
62005
  var mobile_source_parser_js_1 = require_mobile_source_parser();
61983
62006
  var mobile_observation_js_1 = require_mobile_observation();
61984
62007
  var mobile_boundary_js_1 = require_mobile_boundary();
62008
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
61985
62009
  var credential_tools_js_1 = require_credential_tools();
61986
62010
  var verification_inbox_js_1 = require_verification_inbox();
61987
62011
  var inbox_tools_js_1 = require_inbox_tools();
@@ -61991,6 +62015,7 @@ var require_mobile_explorer = __commonJS({
61991
62015
  var MAX_RECENT_EXCHANGES = 24;
61992
62016
  var OBSERVATION_MAX_ELEMENTS = 50;
61993
62017
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
62018
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
61994
62019
  var MOBILE_SNAPSHOT_TOOL = {
61995
62020
  type: "function",
61996
62021
  function: {
@@ -62556,9 +62581,46 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62556
62581
  externalOwner: boundary.owner
62557
62582
  };
62558
62583
  }
62559
- log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
62584
+ let lastExternalOwner = recoveredBoundary.owner;
62585
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
62586
+ try {
62587
+ await driver.launchApp();
62588
+ const retrySnap = await driver.snapshot();
62589
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.windowSize, retrySnap.screenSignal);
62590
+ if (!retryRecovered)
62591
+ continue;
62592
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
62593
+ latest = retryRecovered;
62594
+ lastSnapshotIteration = iterationForObservation;
62595
+ if (retryBoundary.kind === "target" || retryBoundary.kind === "unknown") {
62596
+ const screenId = ingestSnapshot(retryRecovered, iterationForObservation);
62597
+ captureScreenshot(retrySnap.screenshot, screenId);
62598
+ log2(` Recovered target app after ${attempt} additional activation attempt(s).`);
62599
+ return {
62600
+ observation: retryRecovered.observation,
62601
+ screenId,
62602
+ externalBlocked: true,
62603
+ externalOwner: boundary.owner
62604
+ };
62605
+ }
62606
+ if (retryBoundary.kind === "transient_external") {
62607
+ log2(` Recovery surfaced system overlay ${retryBoundary.owner}; not adding it to target-app survey evidence.`);
62608
+ return {
62609
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
62610
+ screenId: null,
62611
+ externalBlocked: true,
62612
+ transientExternal: true,
62613
+ externalOwner: boundary.owner
62614
+ };
62615
+ }
62616
+ lastExternalOwner = retryBoundary.owner;
62617
+ } catch (err) {
62618
+ log2(` Target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
62619
+ }
62620
+ }
62621
+ log2(` Relaunch still outside target app (${lastExternalOwner}); no screen recorded.`);
62560
62622
  return {
62561
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
62623
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no screen was recorded)`,
62562
62624
  screenId: null,
62563
62625
  externalBlocked: true,
62564
62626
  externalOwner: boundary.owner
@@ -62936,7 +62998,19 @@ Exploration complete: ${summary}`);
62936
62998
  const fromScreenId = state2.getCurrentScreenId();
62937
62999
  const boundaryBeforeAction = currentBoundary();
62938
63000
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
62939
- const actionResult = await driver.tap(resolved.bounds);
63001
+ let actionResult = await driver.tap(resolved.bounds);
63002
+ if (!actionResult.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(actionResult.error, latest()?.screen.elements ?? [])) {
63003
+ log2(" Tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
63004
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
63005
+ const retryResult = await driver.tap(resolved.bounds);
63006
+ actionResult = retryResult.ok ? retryResult : {
63007
+ ...retryResult,
63008
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
63009
+ source: retryResult.source ?? dismissResult.source,
63010
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
63011
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
63012
+ };
63013
+ }
62940
63014
  const absorbed = await absorbActionResult(actionResult, "mobile_tap");
62941
63015
  const { observation, screenId } = absorbed;
62942
63016
  if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
@@ -64542,6 +64616,78 @@ var require_discover_ai_planner = __commonJS({
64542
64616
  function meaningfulTokens(name) {
64543
64617
  return name.toLowerCase().split(/[\s\--_]+/).filter((t) => t.length > 2 && !JOURNEY_MATCH_STOPWORDS.has(t));
64544
64618
  }
64619
+ function textIncludesCredentialField(text, field) {
64620
+ return field === "email" ? /\b(e-?mail|username|user\s*name|login\s*id)\b/i.test(text) : /\bpassword|passcode|pin\b/i.test(text);
64621
+ }
64622
+ function textIncludesCredentialSubmit(text) {
64623
+ 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);
64624
+ }
64625
+ function targetPagesHaveCredentialControls(siteMap, targetPages) {
64626
+ const targetSet = new Set(targetPages.map(normalizePlannerRoute));
64627
+ let hasEmail = false;
64628
+ let hasPassword = false;
64629
+ let hasSubmit = false;
64630
+ for (const page of siteMap.pages) {
64631
+ if (!targetSet.has(normalizePlannerRoute(page.route)))
64632
+ continue;
64633
+ for (const element of page.elements ?? []) {
64634
+ const label = [
64635
+ element.label,
64636
+ element.role,
64637
+ element.testId,
64638
+ element.name
64639
+ ].filter(Boolean).join(" ");
64640
+ if (!label)
64641
+ continue;
64642
+ if (textIncludesCredentialField(label, "email"))
64643
+ hasEmail = true;
64644
+ if (textIncludesCredentialField(label, "password"))
64645
+ hasPassword = true;
64646
+ 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)) {
64647
+ hasSubmit = true;
64648
+ }
64649
+ }
64650
+ }
64651
+ return hasEmail && hasPassword && hasSubmit;
64652
+ }
64653
+ function journeyCoversCredentialSubmit(journey) {
64654
+ if (!journey)
64655
+ return false;
64656
+ const text = [
64657
+ journey.name,
64658
+ journey.expectedOutcome,
64659
+ ...journey.steps ?? [],
64660
+ ...journey.provenCommands ?? []
64661
+ ].join(" ");
64662
+ const hasCredentialPair = /\bcredentials\b/i.test(text) || textIncludesCredentialField(text, "email") && textIncludesCredentialField(text, "password");
64663
+ return hasCredentialPair && textIncludesCredentialSubmit(text);
64664
+ }
64665
+ function scenarioAuthCredentialPolicy(scenario, steps) {
64666
+ const text = [
64667
+ scenario.name,
64668
+ scenario.goal,
64669
+ scenario.type,
64670
+ scenario.variant,
64671
+ ...steps.map((step) => step.action)
64672
+ ].filter(Boolean).join(" ");
64673
+ if (!/\b(auth|authentication|sign[ -]?in|signin|log[ -]?in|login|session)\b/i.test(text)) {
64674
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64675
+ }
64676
+ 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)) {
64677
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64678
+ }
64679
+ if (!/\b(credential|e-?mail|username|user\s*name|password|valid|invalid|empty|required|wrong|case|special|leading|trailing)\b/i.test(text)) {
64680
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64681
+ }
64682
+ const uiOnly = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/i.test(text);
64683
+ const negative = /\b(invalid|empty|required|missing|wrong|error|validation|case|special|leading|trailing)\b/i.test(text);
64684
+ const happy = /\b(happy|valid\s+credentials|success|successful)\b/i.test(text) || scenario.type === "HAPPY_PATH" || scenario.variant === "happy";
64685
+ return {
64686
+ needsCredentialEvidence: true,
64687
+ needsSubmitStep: !uiOnly && (happy || negative),
64688
+ reason: uiOnly ? "credential UI scenario" : negative ? "credential validation scenario" : "credential sign-in scenario"
64689
+ };
64690
+ }
64545
64691
  function truncateOutcome(text, limit) {
64546
64692
  const normalized = text.replace(/\s+/g, " ").trim();
64547
64693
  if (normalized.length <= limit)
@@ -65307,6 +65453,18 @@ Each entry is exactly one of:
65307
65453
  if (matchSource === "route") {
65308
65454
  warnings.push(`"${sc.name}" matched to journey "${matchingSuggestion.name}" by route overlap only - evidence may be weak`);
65309
65455
  }
65456
+ const authCredentialPolicy = scenarioAuthCredentialPolicy(sc, steps);
65457
+ if (authCredentialPolicy.needsCredentialEvidence) {
65458
+ const hasCredentialEvidence = targetPagesHaveCredentialControls(siteMap, sc.targetPages) || journeyCoversCredentialSubmit(matchingSuggestion);
65459
+ if (!hasCredentialEvidence) {
65460
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} lacks observed email/password/sign-in controls or a recorded credential journey`);
65461
+ continue;
65462
+ }
65463
+ if (authCredentialPolicy.needsSubmitStep && !steps.some((step) => textIncludesCredentialSubmit(step.action)) && !journeyCoversCredentialSubmit(matchingSuggestion)) {
65464
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} does not include a concrete sign-in submit step`);
65465
+ continue;
65466
+ }
65467
+ }
65310
65468
  const evidence = (0, discover_planner_js_1.extractScenarioEvidence)(
65311
65469
  siteMap.pages,
65312
65470
  sc.targetPages,
@@ -65852,6 +66010,25 @@ var require_mobile_discovery_prompts = __commonJS({
65852
66010
  function targetAppScreens(graph, appId) {
65853
66011
  return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
65854
66012
  }
66013
+ function isGenericNonFeatureScreen(state2) {
66014
+ const type = state2.screenType?.trim().toLowerCase();
66015
+ if (type === "error" || type === "permission")
66016
+ return true;
66017
+ if (type && type !== "unknown")
66018
+ return false;
66019
+ const actionableCount = state2.elements.filter((el) => el.actionable).length;
66020
+ if (actionableCount > 0)
66021
+ return false;
66022
+ const evidenceText = [
66023
+ state2.screenName,
66024
+ state2.screenType,
66025
+ ...state2.observations ?? []
66026
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ");
66027
+ return /\b(empty|loading|blank|spinner|intermediate|placeholder|no actionable)\b/i.test(evidenceText);
66028
+ }
66029
+ function plannableTargetAppScreens(graph, appId) {
66030
+ return targetAppScreens(graph, appId).filter((state2) => !isGenericNonFeatureScreen(state2));
66031
+ }
65855
66032
  exports2.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.
65856
66033
 
65857
66034
  ## Output Discipline
@@ -65871,8 +66048,8 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65871
66048
  5. CRUD screens for the same entity belong together
65872
66049
  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).
65873
66050
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65874
- 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.
65875
- 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.
66051
+ 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.
66052
+ 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.
65876
66053
  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.
65877
66054
 
65878
66055
  ## Output JSON Schema
@@ -65972,6 +66149,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65972
66149
  ## Coverage Strategy
65973
66150
  - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
65974
66151
  - 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)
66152
+ - 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.
65975
66153
  - 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.
65976
66154
  - 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"
65977
66155
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
@@ -66138,7 +66316,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
66138
66316
  sections.push(`## Feature: ${context.featureName}
66139
66317
  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.`);
66140
66318
  }
66141
- const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66319
+ const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66142
66320
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66143
66321
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
66144
66322
  for (const state2 of orderedScreens) {
@@ -66247,7 +66425,7 @@ ${constraints.join("\n")}`);
66247
66425
  const now = /* @__PURE__ */ new Date();
66248
66426
  const idByScreen = /* @__PURE__ */ new Map();
66249
66427
  let pageSeq = 0;
66250
- const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66428
+ const orderedScreens = plannableTargetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66251
66429
  for (const state2 of orderedScreens) {
66252
66430
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
66253
66431
  }
@@ -66308,7 +66486,7 @@ ${constraints.join("\n")}`);
66308
66486
  }
66309
66487
  function buildMobileFeatureGroupsFromGraph(graph, appId) {
66310
66488
  const groups = /* @__PURE__ */ new Map();
66311
- const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66489
+ const orderedScreens = plannableTargetAppScreens(graph, appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66312
66490
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66313
66491
  for (const state2 of orderedScreens) {
66314
66492
  const screenTypeLower = state2.screenType?.toLowerCase();
@@ -66355,7 +66533,7 @@ ${constraints.join("\n")}`);
66355
66533
  return [];
66356
66534
  if (input.featureName?.trim())
66357
66535
  return [];
66358
- 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);
66536
+ const orderedScreens = plannableTargetAppScreens(input.graph, input.appId).filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66359
66537
  if (orderedScreens.length === 0)
66360
66538
  return [];
66361
66539
  const entryScreenId = orderedScreens[0].screenId;
@@ -66557,9 +66735,11 @@ var require_mobile_generator = __commonJS({
66557
66735
  var mobile_source_parser_js_2 = require_mobile_source_parser();
66558
66736
  var mobile_observation_js_1 = require_mobile_observation();
66559
66737
  var mobile_boundary_js_1 = require_mobile_boundary();
66738
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
66560
66739
  var MAX_SCENARIO_ITERATIONS = 30;
66561
66740
  var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
66562
66741
  var MAX_RECENT_EXCHANGES = 24;
66742
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
66563
66743
  var OBSERVATION_MAX_ELEMENTS = 50;
66564
66744
  function finalizeAction(input) {
66565
66745
  return { ...input, url: "", selector: input.selector ?? "" };
@@ -66975,10 +67155,47 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
66975
67155
  externalOwner: boundary.owner
66976
67156
  };
66977
67157
  }
66978
- log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
67158
+ let lastExternalOwner = recoveredBoundary.owner;
67159
+ let lastRecovered = recovered;
67160
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
67161
+ try {
67162
+ await driver.launchApp();
67163
+ const retrySnap = await driver.snapshot();
67164
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.screenSignal, retrySnap.windowSize);
67165
+ if (!retryRecovered)
67166
+ continue;
67167
+ lastRecovered = retryRecovered;
67168
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
67169
+ setLatest(retryRecovered);
67170
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(retryBoundary)) {
67171
+ emitScreenshot(retrySnap.screenshot);
67172
+ log2(` recovered target app after ${attempt} additional activation attempt(s).`);
67173
+ return {
67174
+ resolved: retryRecovered,
67175
+ observation: retryRecovered.observation,
67176
+ externalBlocked: true,
67177
+ externalOwner: boundary.owner
67178
+ };
67179
+ }
67180
+ if (retryBoundary.kind === "transient_external") {
67181
+ log2(` recovery surfaced system overlay ${retryBoundary.owner}; not recording it as a target-app step.`);
67182
+ return {
67183
+ resolved: retryRecovered,
67184
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
67185
+ externalBlocked: true,
67186
+ transientExternal: true,
67187
+ externalOwner: boundary.owner
67188
+ };
67189
+ }
67190
+ lastExternalOwner = retryBoundary.owner;
67191
+ } catch (err) {
67192
+ log2(` target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
67193
+ }
67194
+ }
67195
+ log2(` relaunch still outside target app (${lastExternalOwner}); no target-app step recorded.`);
66979
67196
  return {
66980
- resolved: recovered,
66981
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
67197
+ resolved: lastRecovered,
67198
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no target-app step was recorded)`,
66982
67199
  externalBlocked: true,
66983
67200
  externalOwner: boundary.owner
66984
67201
  };
@@ -67192,7 +67409,19 @@ ${statePacket}` },
67192
67409
  return unknownRef(toolArgs.ref);
67193
67410
  const beforeScreenName = screenName();
67194
67411
  const before = latest();
67195
- const result = await driver.tap(element.bounds);
67412
+ let result = await driver.tap(element.bounds);
67413
+ if (!result.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(result.error, before?.screen.elements ?? [])) {
67414
+ log2(" tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
67415
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
67416
+ const retryResult = await driver.tap(element.bounds);
67417
+ result = retryResult.ok ? retryResult : {
67418
+ ...retryResult,
67419
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
67420
+ source: retryResult.source ?? dismissResult.source,
67421
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
67422
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
67423
+ };
67424
+ }
67196
67425
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
67197
67426
  if (shouldRecordAction(result, absorbed) && changedScreen(before, absorbed)) {
67198
67427
  recordAction({
@@ -67448,7 +67677,7 @@ ${statePacket}` },
67448
67677
  "react-root",
67449
67678
  "application"
67450
67679
  ]);
67451
- 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;
67680
+ 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;
67452
67681
  function locatorLocalName(value) {
67453
67682
  const trimmed = value.trim();
67454
67683
  const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
@@ -67528,6 +67757,88 @@ ${statePacket}` },
67528
67757
  }));
67529
67758
  return true;
67530
67759
  }
67760
+ function scenarioText(scenario) {
67761
+ return [
67762
+ scenario.name,
67763
+ scenario.goal,
67764
+ ...scenario.steps.flatMap((step) => [step.action, step.target, step.hint]),
67765
+ ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67766
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67767
+ }
67768
+ function actionText(action) {
67769
+ const bundle = action.metadata.locatorBundle;
67770
+ return [
67771
+ action.type,
67772
+ action.selector,
67773
+ action.value,
67774
+ action.innerText,
67775
+ action.metadata.targetLabel,
67776
+ bundle?.text,
67777
+ bundle?.contentDesc,
67778
+ bundle?.accessibilityId,
67779
+ bundle?.resourceId,
67780
+ bundle?.className,
67781
+ bundle?.xpath
67782
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67783
+ }
67784
+ function scenarioIntentMismatchReason(scenario, actions) {
67785
+ const text = scenarioText(scenario);
67786
+ 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);
67787
+ const typedEmailIndexes = [];
67788
+ const typedPasswordIndexes = [];
67789
+ actions.forEach((action, index) => {
67790
+ if (action.type !== "TYPE")
67791
+ return;
67792
+ const textForAction = actionText(action);
67793
+ if (/\b(email|e-mail|username|user name|login)\b|\{\{test_user_email\}\}/i.test(textForAction)) {
67794
+ typedEmailIndexes.push(index);
67795
+ }
67796
+ if (/\b(password|passcode|pin)\b|\{\{test_user_password\}\}/i.test(textForAction)) {
67797
+ typedPasswordIndexes.push(index);
67798
+ }
67799
+ });
67800
+ const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67801
+ const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67802
+ const submitAfterCredentials = actions.some((action, index) => {
67803
+ if (action.type !== "TAP" || index <= lastCredentialIndex)
67804
+ return false;
67805
+ const textForAction = actionText(action);
67806
+ if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
67807
+ return false;
67808
+ return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
67809
+ });
67810
+ const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67811
+ if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67812
+ return "credential fields were filled but no sign-in submit tap was recorded after them";
67813
+ }
67814
+ const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67815
+ const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67816
+ const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67817
+ const loginTapWithoutCredentialPath = actions.some((action) => {
67818
+ if (action.type !== "TAP")
67819
+ return false;
67820
+ const textForAction = actionText(action);
67821
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67822
+ return false;
67823
+ return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67824
+ });
67825
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67826
+ return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67827
+ }
67828
+ if (!isCredentialSignIn)
67829
+ return null;
67830
+ const typedEmail = typedEmailIndexes.length > 0;
67831
+ const typedPassword = typedPasswordIndexes.length > 0;
67832
+ const tappedSubmit = submitAfterCredentials;
67833
+ if (typedEmail && typedPassword && tappedSubmit)
67834
+ return null;
67835
+ const missing = [
67836
+ typedEmail ? null : "email/username entry",
67837
+ typedPassword ? null : "password entry",
67838
+ tappedSubmit ? null : "sign-in submit tap"
67839
+ ].filter((value) => value !== null);
67840
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
67841
+ }
67531
67842
  function actionCycleKey(action) {
67532
67843
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67533
67844
  }
@@ -67581,6 +67892,11 @@ ${statePacket}` },
67581
67892
  if (actions.length < beforeContainerDrop) {
67582
67893
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67583
67894
  }
67895
+ const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67896
+ if (intentMismatch) {
67897
+ log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
67898
+ return null;
67899
+ }
67584
67900
  const floorAppended = appendHybridFloor(actions, recording, platform3);
67585
67901
  if (floorAppended) {
67586
67902
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -68278,7 +68594,8 @@ Reason over steps + error + tree + registry and return working healed steps. Do
68278
68594
  2. STEP RESULTS: which steps passed before the failure.
68279
68595
  3. TREE: does the current screen match what the failed step expects?
68280
68596
  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).
68281
- 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.
68597
+ 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.
68598
+ 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.
68282
68599
  ## Response \u2014 STRICT JSON, exactly one verdict:
68283
68600
  {"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}
68284
68601
  {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
@@ -68335,6 +68652,104 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68335
68652
  function isDrift(error2) {
68336
68653
  return /unexpected screen|screen drift/i.test(error2 ?? "");
68337
68654
  }
68655
+ function mobileStepSearchText(step) {
68656
+ const rawTarget = step.target;
68657
+ const structuredTarget = rawTarget && typeof rawTarget === "object" ? rawTarget : void 0;
68658
+ const bundle = structuredTarget?.locatorBundle;
68659
+ const stepValue = step.value;
68660
+ return [
68661
+ step.action,
68662
+ step.description,
68663
+ typeof stepValue === "string" ? stepValue : void 0,
68664
+ typeof rawTarget === "string" ? rawTarget : void 0,
68665
+ structuredTarget?.primary,
68666
+ bundle?.text,
68667
+ bundle?.contentDesc,
68668
+ bundle?.accessibilityId,
68669
+ bundle?.resourceId,
68670
+ bundle?.className,
68671
+ bundle?.xpath
68672
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
68673
+ }
68674
+ function detectInvalidExternalAuthSetup(steps, error2) {
68675
+ if (!/foreground app changed|outside target app|com\.google\.android\.gms|external app/i.test(error2 ?? "")) {
68676
+ return null;
68677
+ }
68678
+ const hasBareLoginTap = steps.some((step) => {
68679
+ if (!/\btap\b/i.test(step.action))
68680
+ return false;
68681
+ const text = mobileStepSearchText(step);
68682
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(text))
68683
+ return false;
68684
+ return /\b(sign\s*in|signin|log\s*in|login)\b/.test(text);
68685
+ });
68686
+ if (!hasBareLoginTap)
68687
+ return null;
68688
+ const hasCredentialEntry = steps.some((step) => {
68689
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
68690
+ return false;
68691
+ const text = mobileStepSearchText(step);
68692
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68693
+ });
68694
+ if (hasCredentialEntry)
68695
+ return null;
68696
+ 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";
68697
+ }
68698
+ function isCredentialFieldStep(step) {
68699
+ const text = mobileStepSearchText(step);
68700
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68701
+ }
68702
+ function isCredentialSubmitTap(step) {
68703
+ if (!/\b(tap|click|press)\b/i.test(step.action))
68704
+ return false;
68705
+ const text = mobileStepSearchText(step);
68706
+ 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)) {
68707
+ return false;
68708
+ }
68709
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
68710
+ }
68711
+ function detectInvalidAuthValidationSetup(steps, error2) {
68712
+ if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
68713
+ return null;
68714
+ }
68715
+ const allText = steps.map(mobileStepSearchText).join(" ");
68716
+ if (!/\b(email|e-mail|username|user\s*name|password|passcode|pin)\b/.test(allText))
68717
+ return null;
68718
+ 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);
68719
+ let lastCredentialEditOrder = -1;
68720
+ for (const step of steps) {
68721
+ if (!/\b(type|fill|input|enter|clear)\b/i.test(step.action))
68722
+ continue;
68723
+ if (!isCredentialFieldStep(step))
68724
+ continue;
68725
+ lastCredentialEditOrder = Math.max(lastCredentialEditOrder, step.order ?? -1);
68726
+ }
68727
+ if (lastCredentialEditOrder < 0)
68728
+ return null;
68729
+ if (uiOnlyCredentialScenario) {
68730
+ const hasCredentialFormEvidence = steps.some((step) => {
68731
+ if ((step.order ?? -1) >= lastCredentialEditOrder)
68732
+ return false;
68733
+ const text = mobileStepSearchText(step);
68734
+ 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);
68735
+ });
68736
+ if (hasCredentialFormEvidence)
68737
+ return null;
68738
+ 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";
68739
+ }
68740
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
68741
+ if (hasSubmitAfterCredentialEdit)
68742
+ return null;
68743
+ const hasValidationAssertion = steps.some((step) => {
68744
+ if (!/\b(assert|wait)\b/i.test(step.action))
68745
+ return false;
68746
+ const text = mobileStepSearchText(step);
68747
+ 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);
68748
+ });
68749
+ if (hasValidationAssertion)
68750
+ return null;
68751
+ 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";
68752
+ }
68338
68753
  function stripCodeFences(text) {
68339
68754
  const trimmed = text.trim();
68340
68755
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -68593,6 +69008,24 @@ ${args.directive}`);
68593
69008
  const registryPresent = options.selectorRegistryContext != null;
68594
69009
  const platform3 = target.platform;
68595
69010
  const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
69011
+ const invalidExternalAuthSetup = detectInvalidExternalAuthSetup(steps, ctx.error);
69012
+ if (invalidExternalAuthSetup) {
69013
+ return {
69014
+ passed: false,
69015
+ verdict: "test_invalid",
69016
+ healReason: invalidExternalAuthSetup,
69017
+ attemptsUsed: 0
69018
+ };
69019
+ }
69020
+ const invalidAuthValidationSetup = detectInvalidAuthValidationSetup(steps, ctx.error);
69021
+ if (invalidAuthValidationSetup) {
69022
+ return {
69023
+ passed: false,
69024
+ verdict: "test_invalid",
69025
+ healReason: invalidAuthValidationSetup,
69026
+ attemptsUsed: 0
69027
+ };
69028
+ }
68596
69029
  let attemptCap = base2;
68597
69030
  let recoverableUsed = 0;
68598
69031
  let bonusUsed = 0;
@@ -68778,7 +69211,7 @@ ${args.directive}`);
68778
69211
  removeTags: script.removeTags
68779
69212
  };
68780
69213
  }
68781
- var DEFAULT_PHASE2_MAX_PASSES = 6;
69214
+ var DEFAULT_PHASE2_MAX_PASSES = 4;
68782
69215
  exports2.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.
68783
69216
  ## Key insight \u2014 ENTRY-SCREEN DRIFT
68784
69217
  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.
@@ -68787,7 +69220,8 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
68787
69220
  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().
68788
69221
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68789
69222
  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.
68790
- 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.
69223
+ 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.
69224
+ 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.
68791
69225
  ## Response \u2014 STRICT JSON, exactly one verdict:
68792
69226
  {"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}
68793
69227
  {"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
@@ -315122,8 +315556,8 @@ var require_mobile_discovery_agent = __commonJS({
315122
315556
  };
315123
315557
  }
315124
315558
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
315125
- const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
315126
- 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.");
315559
+ const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", mode, log2);
315560
+ 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.");
315127
315561
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
315128
315562
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
315129
315563
  const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
@@ -315365,10 +315799,11 @@ Fatal error: ${msg}`);
315365
315799
  };
315366
315800
  }
315367
315801
  }
315368
- async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
315802
+ async function prepareMobileAppStateForDiscovery(driver, target, phase, mode, log2) {
315369
315803
  void phase;
315370
315804
  const resetPolicy = target?.resetPolicy ?? "AUTO";
315371
- if (resetPolicy === "PRESERVE_APP_DATA") {
315805
+ const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
315806
+ if (shouldPreserveAppData) {
315372
315807
  try {
315373
315808
  await driver.relaunch();
315374
315809
  } catch (err) {
@@ -329889,7 +330324,7 @@ var require_package4 = __commonJS({
329889
330324
  "package.json"(exports2, module2) {
329890
330325
  module2.exports = {
329891
330326
  name: "@validate.qa/runner",
329892
- version: "1.0.30",
330327
+ version: "1.0.32",
329893
330328
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329894
330329
  bin: {
329895
330330
  "validate-runner": "dist/cli.js"
@@ -330038,6 +330473,8 @@ var MAX_RETRIES = 3;
330038
330473
  var STARTUP_TIMEOUT_MS = 3e4;
330039
330474
  var HEALTH_CHECK_INTERVAL_MS = 5e3;
330040
330475
  var STABLE_AFTER_MS = 6e4;
330476
+ var DEFAULT_APPIUM_LOG_LEVEL = "warn";
330477
+ var MAX_APPIUM_LOG_LINE_CHARS = 4e3;
330041
330478
  var appiumProcess = null;
330042
330479
  var serverState = {
330043
330480
  running: false,
@@ -330097,6 +330534,23 @@ function appendLog(line) {
330097
330534
  logs = logs.slice(-MAX_LOG_LINES);
330098
330535
  }
330099
330536
  }
330537
+ function resolveAppiumLogLevel() {
330538
+ const raw = process.env.APPIUM_LOG_LEVEL?.trim();
330539
+ if (raw && /^[a-z]+(?::[a-z]+)?$/i.test(raw)) return raw;
330540
+ return DEFAULT_APPIUM_LOG_LEVEL;
330541
+ }
330542
+ function buildAppiumArgs(port, drivers) {
330543
+ const args = ["--port", String(port), "--log-level", resolveAppiumLogLevel()];
330544
+ if (drivers && drivers.length > 0) {
330545
+ args.push("--use-drivers", drivers.join(","));
330546
+ }
330547
+ return args;
330548
+ }
330549
+ function sanitizeAppiumLogLine(line) {
330550
+ 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]"');
330551
+ if (redacted.length <= MAX_APPIUM_LOG_LINE_CHARS) return redacted;
330552
+ return `${redacted.slice(0, MAX_APPIUM_LOG_LINE_CHARS)}\u2026 [truncated ${redacted.length - MAX_APPIUM_LOG_LINE_CHARS} chars]`;
330553
+ }
330100
330554
  async function checkAppiumHealth(port = serverState.port) {
330101
330555
  try {
330102
330556
  const controller = new AbortController();
@@ -330129,10 +330583,7 @@ async function startAppiumServer(options) {
330129
330583
  return serverState;
330130
330584
  }
330131
330585
  const port = options?.port ?? APPIUM_PORT;
330132
- const args = ["--port", String(port)];
330133
- if (options?.drivers && options.drivers.length > 0) {
330134
- args.push("--use-drivers", options.drivers.join(","));
330135
- }
330586
+ const args = buildAppiumArgs(port, options?.drivers);
330136
330587
  intentionalStop = false;
330137
330588
  lastStartOptions = { ...lastStartOptions, ...options };
330138
330589
  if (appiumGaveUp) {
@@ -330151,14 +330602,14 @@ async function startAppiumServer(options) {
330151
330602
  serverState.pid = appiumProcess.pid ?? null;
330152
330603
  serverState.port = port;
330153
330604
  appiumProcess.stdout?.on("data", (data) => {
330154
- const line = data.toString().trim();
330605
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330155
330606
  if (line) {
330156
330607
  appendLog(`[appium:stdout] ${line}`);
330157
330608
  options?.onLog?.(line);
330158
330609
  }
330159
330610
  });
330160
330611
  appiumProcess.stderr?.on("data", (data) => {
330161
- const line = data.toString().trim();
330612
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330162
330613
  if (line) {
330163
330614
  appendLog(`[appium:stderr] ${line}`);
330164
330615
  options?.onLog?.(line);
@@ -330317,6 +330768,17 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
330317
330768
  // src/services/mobile/appium-actions.ts
330318
330769
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
330319
330770
  var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
330771
+ 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;
330772
+ var AppiumDriverDeadError = class extends Error {
330773
+ isAppiumDriverDead = true;
330774
+ constructor(message) {
330775
+ super(message);
330776
+ this.name = "AppiumDriverDeadError";
330777
+ }
330778
+ };
330779
+ function isAppiumDriverDeadError(err) {
330780
+ return err instanceof AppiumDriverDeadError || typeof err === "object" && err !== null && err.isAppiumDriverDead === true;
330781
+ }
330320
330782
  async function appiumRequest(path, options) {
330321
330783
  const url = `http://localhost:${getAppiumState().port}${path}`;
330322
330784
  const res = await fetch(url, {
@@ -330326,6 +330788,9 @@ async function appiumRequest(path, options) {
330326
330788
  });
330327
330789
  if (!res.ok) {
330328
330790
  const body = await res.text().catch(() => "");
330791
+ if (APPIUM_DRIVER_DEAD_RE.test(body)) {
330792
+ throw new AppiumDriverDeadError(`Appium driver session is not usable (${res.status}): ${body.slice(0, 200)}`);
330793
+ }
330329
330794
  throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
330330
330795
  }
330331
330796
  return res.json();
@@ -330340,7 +330805,8 @@ async function getScreenshot(sessionPath) {
330340
330805
  try {
330341
330806
  const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
330342
330807
  return typeof value === "string" ? value : null;
330343
- } catch {
330808
+ } catch (err) {
330809
+ if (isAppiumDriverDeadError(err)) throw err;
330344
330810
  return null;
330345
330811
  }
330346
330812
  }
@@ -330348,7 +330814,8 @@ async function getPageSource(sessionPath) {
330348
330814
  try {
330349
330815
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
330350
330816
  return typeof value === "string" ? value : null;
330351
- } catch {
330817
+ } catch (err) {
330818
+ if (isAppiumDriverDeadError(err)) throw err;
330352
330819
  return null;
330353
330820
  }
330354
330821
  }
@@ -330364,7 +330831,8 @@ async function getWindowSize(sessionPath) {
330364
330831
  return { width: w, height: h };
330365
330832
  }
330366
330833
  }
330367
- } catch {
330834
+ } catch (err) {
330835
+ if (isAppiumDriverDeadError(err)) throw err;
330368
330836
  }
330369
330837
  if (attempt < retries - 1) {
330370
330838
  await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
@@ -330520,6 +330988,37 @@ async function swipe(sessionPath, direction, distance, windowSize) {
330520
330988
  async function pressBack(sessionPath) {
330521
330989
  await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
330522
330990
  }
330991
+ function isKeyboardAlreadyHiddenError(err) {
330992
+ const message = err instanceof Error ? err.message : String(err);
330993
+ return /keyboard.*(not|already).*(shown|visible|present|open)|no.*keyboard/i.test(message);
330994
+ }
330995
+ async function dismissKeyboard(sessionPath, platform3) {
330996
+ if (platform3 === "ANDROID") {
330997
+ await pressBack(sessionPath);
330998
+ return;
330999
+ }
331000
+ try {
331001
+ await appiumRequest(`${sessionPath}/appium/device/hide_keyboard`, {
331002
+ method: "POST",
331003
+ body: "{}"
331004
+ });
331005
+ return;
331006
+ } catch (err) {
331007
+ if (isAppiumDriverDeadError(err)) throw err;
331008
+ if (isKeyboardAlreadyHiddenError(err)) return;
331009
+ }
331010
+ try {
331011
+ await appiumRequest(`${sessionPath}/execute/sync`, {
331012
+ method: "POST",
331013
+ body: JSON.stringify({ script: "mobile: hideKeyboard", args: [{}] })
331014
+ });
331015
+ return;
331016
+ } catch (err) {
331017
+ if (isAppiumDriverDeadError(err)) throw err;
331018
+ if (isKeyboardAlreadyHiddenError(err)) return;
331019
+ }
331020
+ await pressBack(sessionPath);
331021
+ }
330523
331022
  async function pressHome(sessionPath, platform3) {
330524
331023
  if (platform3 === "IOS") {
330525
331024
  await appiumRequest(`${sessionPath}/execute/sync`, {
@@ -331705,6 +332204,7 @@ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
331705
332204
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
331706
332205
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
331707
332206
  var WD_ACTION_TIMEOUT_MS = 3e4;
332207
+ var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
331708
332208
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
331709
332209
  var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
331710
332210
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
@@ -331741,6 +332241,9 @@ async function wdRequest(path, options) {
331741
332241
  if (res.status === 404 && /no such session|invalid session id|session is either terminated|deleted session/i.test(body)) {
331742
332242
  throw new WebDriverTransportError(`WebDriver session is no longer available: ${body.slice(0, 200)}`);
331743
332243
  }
332244
+ if (res.status === 500 && APPIUM_DRIVER_DEAD_RE2.test(body)) {
332245
+ throw new WebDriverTransportError(`WebDriver driver process is not usable: ${body.slice(0, 200)}`);
332246
+ }
331744
332247
  if (res.status === 502 || res.status === 503 || res.status === 504) {
331745
332248
  throw new WebDriverTransportError(`Appium server unavailable (${res.status}): ${body.slice(0, 200)}`);
331746
332249
  }
@@ -334509,6 +335012,10 @@ var MobileBridgeDriver = class {
334509
335012
  async back() {
334510
335013
  return this.runAction(() => pressBack(this.sessionPath));
334511
335014
  }
335015
+ /** Platform-aware software keyboard dismissal. */
335016
+ async dismissKeyboard() {
335017
+ return this.runAction(() => dismissKeyboard(this.sessionPath, this.platform));
335018
+ }
334512
335019
  /** System Home — backgrounds the app only (use `relaunch` for hermetic reset). */
334513
335020
  async home() {
334514
335021
  return this.runAction(() => pressHome(this.sessionPath, this.platform));
@@ -334894,8 +335401,12 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
334894
335401
  pageUrl: call.pageUrl ?? pageUrl
334895
335402
  }));
334896
335403
  }
335404
+ var runnerPackage = require_package4();
334897
335405
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
334898
335406
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
335407
+ var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
335408
+ var RUNNER_VERSION = runnerPackage.version ?? "0.0.0";
335409
+ 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;
334899
335410
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
334900
335411
  var DEFAULT_TYPE_LIMITS = {
334901
335412
  discovery: IS_CLOUD ? 2 : 15,
@@ -336698,7 +337209,14 @@ ${finalVerifyResult.logs ?? ""}`;
336698
337209
  if (getBridgeState().hasActiveSession) {
336699
337210
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
336700
337211
  }
336701
- const mobileOnLog = (line) => onLog("progress", line);
337212
+ const mobileProgressLines = [];
337213
+ const mobileOnLog = (line) => {
337214
+ mobileProgressLines.push(line);
337215
+ if (mobileProgressLines.length > 500) {
337216
+ mobileProgressLines.splice(0, mobileProgressLines.length - 500);
337217
+ }
337218
+ onLog("progress", mobileProgressLines.join("\n"));
337219
+ };
336702
337220
  liveBrowserHandle.patch({
336703
337221
  mode: ctx.mode === "survey" ? "survey" : "discovery",
336704
337222
  testName: ctx.featureName ?? "Mobile discovery",
@@ -337722,6 +338240,9 @@ async function startRunner(opts) {
337722
338240
  method: "POST",
337723
338241
  headers,
337724
338242
  body: JSON.stringify({
338243
+ runnerName: RUNNER_NAME,
338244
+ runnerVersion: RUNNER_VERSION,
338245
+ runnerBuildId: RUNNER_BUILD_ID,
337725
338246
  runsCompleted,
337726
338247
  activeRuns,
337727
338248
  maxConcurrent: MAX_CONCURRENT,