@validate.qa/runner 1.0.29 → 1.0.31

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 +948 -128
  2. package/dist/cli.mjs +948 -128
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -1589,7 +1589,11 @@ function buildStepTarget(metadata, selector) {
1589
1589
  };
1590
1590
  }
1591
1591
  function labelForAction(action, metadata) {
1592
- return nonEmpty(metadata.targetLabel) ?? nonEmpty(metadata.locatorBundle?.accessibilityId) ?? nonEmpty(metadata.locatorBundle?.resourceId) ?? nonEmpty(metadata.locatorBundle?.text) ?? nonEmpty(action.selector) ?? nonEmpty(action.url) ?? "element";
1592
+ const editable = isEditableMobileClassName(metadata.locatorBundle?.className);
1593
+ const locatorText = nonEmpty(metadata.locatorBundle?.text);
1594
+ const targetLabel = nonEmpty(metadata.targetLabel);
1595
+ const targetLabelIsEditableValue = editable && targetLabel !== void 0 && locatorText !== void 0 && targetLabel === locatorText;
1596
+ return (targetLabelIsEditableValue ? void 0 : targetLabel) ?? nonEmpty(metadata.locatorBundle?.accessibilityId) ?? nonEmpty(metadata.locatorBundle?.resourceId) ?? (editable ? void 0 : locatorText) ?? nonEmpty(action.selector) ?? nonEmpty(action.url) ?? "element";
1593
1597
  }
1594
1598
  function descriptionForStep(action, stepAction, metadata) {
1595
1599
  const label = labelForAction(action, metadata);
@@ -2042,6 +2046,9 @@ function resolvePlatformLocatorBundle(target, platform3) {
2042
2046
  function hasPlatformLocator(target, platform3) {
2043
2047
  return Boolean(target?.platformLocators?.[platform3]);
2044
2048
  }
2049
+ function isLocatorRequiringAction(action) {
2050
+ return LOCATOR_REQUIRING_ACTIONS.has(action);
2051
+ }
2045
2052
  function hasCrossPlatformIdentity(bundle) {
2046
2053
  if (!bundle)
2047
2054
  return false;
@@ -2089,6 +2096,115 @@ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
2089
2096
  return true;
2090
2097
  return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
2091
2098
  }
2099
+ function summarizeMobileReadiness(steps, authoringPlatform) {
2100
+ return MOBILE_PLATFORMS.map((platform3) => {
2101
+ const locatorSteps = steps.filter((step) => isLocatorRequiringAction(step.action) && stepAppliesToPlatform(step, platform3)).length;
2102
+ const executableSteps = steps.filter((step) => stepAppliesToPlatform(step, platform3)).length;
2103
+ if (authoringPlatform === platform3) {
2104
+ return {
2105
+ platform: platform3,
2106
+ authoringPlatform,
2107
+ ready: true,
2108
+ status: "ready",
2109
+ locatorSteps,
2110
+ executableSteps,
2111
+ missingLocators: 0,
2112
+ needsReview: 0
2113
+ };
2114
+ }
2115
+ const missingLocators = stepsMissingPlatformLocators(steps, platform3).length;
2116
+ const needsReview = stepsNeedingReviewForPlatform(steps, platform3).length;
2117
+ const ready = missingLocators === 0 && needsReview === 0;
2118
+ return {
2119
+ platform: platform3,
2120
+ ...authoringPlatform ? { authoringPlatform } : {},
2121
+ ready,
2122
+ status: ready ? "ready" : needsReview > 0 ? "needs_review" : "needs_locator_discovery",
2123
+ locatorSteps,
2124
+ executableSteps,
2125
+ missingLocators,
2126
+ needsReview
2127
+ };
2128
+ });
2129
+ }
2130
+ function hasStableNativeIdentity(bundle) {
2131
+ if (!bundle)
2132
+ return false;
2133
+ return Boolean(bundle.accessibilityId?.trim() || bundle.resourceId?.trim() || bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.xpath?.trim() && /@\w+\s*=/.test(bundle.xpath) && !/(?:contains|starts-with)\s*\(/i.test(bundle.xpath));
2134
+ }
2135
+ function isPositionalXpath(xpath) {
2136
+ const value = xpath?.trim();
2137
+ if (!value)
2138
+ return false;
2139
+ return value.startsWith("/") && !/@\w+\s*=/.test(value);
2140
+ }
2141
+ function addSelectorIssue(issues, step, severity, reason) {
2142
+ issues.push({
2143
+ order: step.order,
2144
+ action: step.action,
2145
+ severity,
2146
+ reason,
2147
+ ...step.target?.primary ? { targetLabel: step.target.primary } : {},
2148
+ ...step.target?.screenName ? { screenName: step.target.screenName } : {}
2149
+ });
2150
+ }
2151
+ function analyzeMobileSelectorQuality(steps) {
2152
+ const issues = [];
2153
+ let locatorSteps = 0;
2154
+ let stableLocatorSteps = 0;
2155
+ let crossPlatformIdentitySteps = 0;
2156
+ let coordinateFallbackSteps = 0;
2157
+ let missingLocatorSteps = 0;
2158
+ let positionalXpathSteps = 0;
2159
+ let classOnlySteps = 0;
2160
+ for (const step of steps) {
2161
+ if (!isLocatorRequiringAction(step.action))
2162
+ continue;
2163
+ locatorSteps += 1;
2164
+ const target = step.target;
2165
+ const bundle = target?.locatorBundle;
2166
+ if (!target || !bundle) {
2167
+ missingLocatorSteps += 1;
2168
+ addSelectorIssue(issues, step, "high", "No native locator bundle is stored for this element step.");
2169
+ continue;
2170
+ }
2171
+ if (hasStableNativeIdentity(bundle))
2172
+ stableLocatorSteps += 1;
2173
+ if (hasCrossPlatformIdentity(bundle))
2174
+ crossPlatformIdentitySteps += 1;
2175
+ if (target.bounds && !hasStableNativeIdentity(bundle)) {
2176
+ coordinateFallbackSteps += 1;
2177
+ addSelectorIssue(issues, step, "medium", "Step may replay by coordinates because the locator bundle has no stable native identity.");
2178
+ }
2179
+ if (isPositionalXpath(bundle.xpath)) {
2180
+ positionalXpathSteps += 1;
2181
+ addSelectorIssue(issues, step, "medium", "Locator relies on a positional XPath, which is brittle across app versions and platforms.");
2182
+ }
2183
+ if (bundle.className?.trim() && !hasStableNativeIdentity(bundle)) {
2184
+ classOnlySteps += 1;
2185
+ addSelectorIssue(issues, step, "medium", "Locator is class-only with no accessibility id, resource id, text, or content description.");
2186
+ }
2187
+ if (!hasCrossPlatformIdentity(bundle)) {
2188
+ addSelectorIssue(issues, step, "low", "No cross-platform identity signal is available; iOS/Android reuse may need locator discovery or manual review.");
2189
+ }
2190
+ }
2191
+ const high = issues.filter((issue) => issue.severity === "high").length;
2192
+ const medium = issues.filter((issue) => issue.severity === "medium").length;
2193
+ const low = issues.filter((issue) => issue.severity === "low").length;
2194
+ const score = Math.max(0, Math.min(100, 100 - high * 25 - medium * 10 - low * 3));
2195
+ return {
2196
+ totalSteps: steps.length,
2197
+ locatorSteps,
2198
+ stableLocatorSteps,
2199
+ crossPlatformIdentitySteps,
2200
+ coordinateFallbackSteps,
2201
+ missingLocatorSteps,
2202
+ positionalXpathSteps,
2203
+ classOnlySteps,
2204
+ issues,
2205
+ summary: { high, medium, low, score }
2206
+ };
2207
+ }
2092
2208
  var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
2093
2209
  var init_mobile_cross_platform = __esm({
2094
2210
  "../shared/dist/mobile-cross-platform.js"() {
@@ -2233,40 +2349,40 @@ var init_plans = __esm({
2233
2349
  type: "extra_projects",
2234
2350
  label: "Extra Projects",
2235
2351
  description: "Add more projects to your workspace",
2236
- priceLabel: "$19.99 / month",
2237
- monthlyPriceUsd: 19.99,
2352
+ priceLabel: "$24.99 / month",
2353
+ monthlyPriceUsd: 24.99,
2238
2354
  limitIncrement: 1
2239
2355
  },
2240
2356
  extra_tests: {
2241
2357
  type: "extra_tests",
2242
2358
  label: "Extra Tests",
2243
2359
  description: "Increase tests per project limit",
2244
- priceLabel: "$4.99 / month",
2245
- monthlyPriceUsd: 4.99,
2246
- limitIncrement: 100
2360
+ priceLabel: "$12.99 / month",
2361
+ monthlyPriceUsd: 12.99,
2362
+ limitIncrement: 50
2247
2363
  },
2248
2364
  extra_suite_runs: {
2249
2365
  type: "extra_suite_runs",
2250
2366
  label: "Extra Suite Runs",
2251
2367
  description: "Increase daily suite run limit",
2252
- priceLabel: "$7.99 / month",
2253
- monthlyPriceUsd: 7.99,
2368
+ priceLabel: "$12.99 / month",
2369
+ monthlyPriceUsd: 12.99,
2254
2370
  limitIncrement: 5
2255
2371
  },
2256
2372
  extra_discovery_runs: {
2257
2373
  type: "extra_discovery_runs",
2258
2374
  label: "Extra Discovery Runs",
2259
2375
  description: "Increase discovery run limit",
2260
- priceLabel: "$6.99 / month",
2261
- monthlyPriceUsd: 6.99,
2376
+ priceLabel: "$19.99 / month",
2377
+ monthlyPriceUsd: 19.99,
2262
2378
  limitIncrement: 5
2263
2379
  },
2264
2380
  extra_test_executions: {
2265
2381
  type: "extra_test_executions",
2266
2382
  label: "Extra Test Executions",
2267
2383
  description: "+100 manual test runs per day, on top of your plan\u2019s daily dashboard run budget",
2268
- priceLabel: "$9.99 / month",
2269
- monthlyPriceUsd: 9.99,
2384
+ priceLabel: "$14.99 / month",
2385
+ monthlyPriceUsd: 14.99,
2270
2386
  limitIncrement: 100
2271
2387
  }
2272
2388
  };
@@ -2291,18 +2407,18 @@ var init_plans = __esm({
2291
2407
  PLAN_DEFINITIONS = {
2292
2408
  standard: {
2293
2409
  code: "standard",
2294
- label: "Standard Trial",
2410
+ label: "Standard",
2295
2411
  priceLabel: "Free",
2296
2412
  monthlyPriceUsd: 0,
2297
2413
  billingModel: "free",
2298
- 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.",
2299
2415
  maxProjects: 1,
2300
- maxTestsPerProject: 10,
2416
+ maxTestsPerProject: 20,
2301
2417
  maxSuiteRunsPerDay: 1,
2302
- maxTestExecutionsPerDay: 20,
2303
- // 10 regression / 10 manual at the default 50/50 split
2304
- maxDiscoveryRunsPerDay: 5,
2305
- // ONE-TIME total budget for trial
2418
+ maxTestExecutionsPerDay: 10,
2419
+ // 5 regression / 5 manual at the default 50/50 split
2420
+ maxDiscoveryRunsPerDay: 1,
2421
+ // Per day
2306
2422
  maxConcurrentDiscoveries: 1,
2307
2423
  maxHealsPerDay: 5,
2308
2424
  maxRegenerationsPerDay: 2,
@@ -2314,8 +2430,8 @@ var init_plans = __esm({
2314
2430
  standard_plus: {
2315
2431
  code: "standard_plus",
2316
2432
  label: "Standard +",
2317
- priceLabel: "$19.99 / month",
2318
- monthlyPriceUsd: 19.99,
2433
+ priceLabel: "$99.99 / month",
2434
+ monthlyPriceUsd: 99.99,
2319
2435
  billingModel: "subscription",
2320
2436
  summary: "For solo devs and small teams shipping weekly. 200 tests, 100 test runs/day, full MCP. The plan most teams stay on.",
2321
2437
  maxProjects: 1,
@@ -2380,16 +2496,16 @@ var init_plans = __esm({
2380
2496
  requiresContact: true
2381
2497
  }
2382
2498
  };
2383
- TRIAL_DURATION_DAYS = 14;
2499
+ TRIAL_DURATION_DAYS = 30;
2384
2500
  CREDIT_PRICES_CENTS = {
2385
- discovery_run: 50,
2501
+ discovery_run: 200,
2502
+ // $2.00
2503
+ suite_run: 50,
2386
2504
  // $0.50
2387
- suite_run: 10,
2388
- // $0.10
2389
- test_run: 10,
2390
- // $0.10
2391
- heal: 10
2392
- // $0.10
2505
+ test_run: 15,
2506
+ // $0.15
2507
+ heal: 75
2508
+ // $0.75
2393
2509
  };
2394
2510
  AI_CREDIT_MARGIN = 1.5;
2395
2511
  CREDIT_PACKS_CENTS = [2e3, 5e3, 1e4];
@@ -3796,6 +3912,7 @@ __export(dist_exports, {
3796
3912
  UI_VARIANTS: () => UI_VARIANTS,
3797
3913
  accumulateStatements: () => accumulateStatements,
3798
3914
  aiCostToCreditCents: () => aiCostToCreditCents,
3915
+ analyzeMobileSelectorQuality: () => analyzeMobileSelectorQuality,
3799
3916
  bugFoundQuarantineWhere: () => bugFoundQuarantineWhere,
3800
3917
  buildAppiumCode: () => buildAppiumCode,
3801
3918
  buildLaunchStep: () => buildLaunchStep,
@@ -3882,6 +3999,7 @@ __export(dist_exports, {
3882
3999
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
3883
4000
  stripLoginScaffolding: () => stripLoginScaffolding,
3884
4001
  summarizeHealth: () => summarizeHealth,
4002
+ summarizeMobileReadiness: () => summarizeMobileReadiness,
3885
4003
  summarizeRunHealth: () => summarizeRunHealth,
3886
4004
  toDbPricingPlan: () => toDbPricingPlan,
3887
4005
  validateAssertionStrength: () => validateAssertionStrength
@@ -62159,7 +62277,7 @@ var require_mobile_explorer = __commonJS({
62159
62277
  }
62160
62278
  sections.push("");
62161
62279
  if (mode === "survey") {
62162
- sections.push(`**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Get PAST the entry gate into the app's real main content \u2014 the login/landing screens are the ENTRANCE, not the app. If test credentials are provided (see the Test Credentials section), SIGN IN with them FIRST and map the authenticated app; that is the real product surface. When credentials are provided, the guest/demo/skip entry (e.g. "See Demo Profile", "Continue as guest") is FORBIDDEN \u2014 it is a fake sandbox, not the authenticated app, and mapping it means the survey missed the real product. Only fall back to a demo/guest entry when NO credentials are available at all. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.`);
62280
+ sections.push("**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Get PAST the entry gate into the app's real main content \u2014 the login/landing screens are the ENTRANCE, not the app. If test credentials are provided (see the Test Credentials section), SIGN IN with them FIRST and map the authenticated app; that is the real product surface. Prefer a real login when credentials are provided; only if login genuinely cannot be completed after honest attempts (persistent error, the form will not accept the credential, no valid account) fall back to any guest/demo/skip entry the app offers and map the app fully from there \u2014 reaching the real feature surface that way is far better coverage than getting stuck on the login wall. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.");
62163
62281
  } else {
62164
62282
  sections.push("**Mission:** Use the focus features like a real user. Tap, type, submit, and observe outcomes. After a meaningful flow, call record_journey. Call finish_exploration when the flows are covered.");
62165
62283
  }
@@ -62224,7 +62342,7 @@ LOG IN FIRST \u2014 before broad exploration. The authenticated app is the real
62224
62342
  4. VERIFY you are actually signed in: re-snapshot and confirm an authenticated signal appeared (a home/feed/dashboard with real content, an account/profile tab, a "Sign out" option, or your user identity). If you are still on the login form, the login failed \u2014 check for an error, re-enter, and submit again (up to 2 attempts).
62225
62343
  5. Only AFTER you have confirmed you are signed in, map the authenticated app.
62226
62344
 
62227
- DO NOT USE THE DEMO / GUEST PATH. Because credentials ARE provided above, a "See Demo Profile", "Continue as guest", "Try it out", "Skip", or any preview/sandbox entry is FORBIDDEN for this survey \u2014 it drops you into a fake sandbox account, NOT the real authenticated app, and a survey that maps the demo dashboard has FAILED its core job. Never tap those buttons. The ONLY way in is the real login sequence above. If the real login genuinely cannot be completed after 2 honest attempts (persistent error, no reachable sign-in form), STOP and call finish_exploration with a clear note that login could not be completed \u2014 do NOT substitute the demo experience for the authenticated app.
62345
+ TRY THE REAL LOGIN FIRST (above). It is the preferred entry. BUT your #1 job is to get INTO the app and map its real content \u2014 an empty survey that only maps the login/landing screens is the worst outcome. So: if the real login does not clearly succeed within 2 honest attempts (you fill + submit and are still on the login form / see an error / the form will not accept the credential), DO NOT get stuck \u2014 take any guest/demo/skip entry the app offers (e.g. "Continue as guest", "Skip", "Try it out", a demo/preview mode) instead and map the app fully from there. A guest/demo session that reveals the real feature surface (feed, tabs, settings, profile) is FAR better coverage than a handful of pre-login screens. Prefer authenticated content when you can reach it; never abandon the survey on the login wall when a demo/guest door is open.
62228
62346
 
62229
62347
  SECURITY RULES:
62230
62348
  - NEVER call \`mobile_type\` with an email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
@@ -62270,7 +62388,7 @@ Rules:
62270
62388
  **Phase 2 \u2014 Logged-in breadth first (~35% of budget).** Once signed in, find the primary navigation (bottom tab bar, drawer, or menu) and visit EVERY top-level destination ONCE, capturing each screen, BEFORE drilling into any of them. A survey that maps one tab deeply but never opens the other tabs has failed \u2014 breadth across all tabs/drawer entries is mandatory.
62271
62389
  **Phase 3 \u2014 Depth (remaining budget).** Return to each captured tab and explore its sub-screens: open ONE list row per list (rows share a template), open menus/sheets/dialogs, capture detail screens, and mobile_swipe up/down to reveal off-screen content. record_transition whenever you move between screens.
62272
62390
 
62273
- Rules that apply throughout: capture_screen every new screen (name + type \u2014 use type "onboarding"/"landing" for pre-login screens, "auth" for sign-in/sign-up); record_transition on every move; mobile_back to return; mobile_relaunch only for a clean restart between episodes. When no new screens remain, call finish_exploration.
62391
+ Rules that apply throughout: capture_screen every new screen (name + type \u2014 use screenType "LANDING" for welcome/onboarding/intro screens and "AUTH" for sign-in/sign-up); record_transition on every move; mobile_back to return; mobile_relaunch only for a clean restart between episodes. When no new screens remain, call finish_exploration.
62274
62392
 
62275
62393
  ## Boundaries (survey = map, do not mutate)
62276
62394
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
@@ -64429,6 +64547,78 @@ var require_discover_ai_planner = __commonJS({
64429
64547
  function meaningfulTokens(name) {
64430
64548
  return name.toLowerCase().split(/[\s\--_]+/).filter((t) => t.length > 2 && !JOURNEY_MATCH_STOPWORDS.has(t));
64431
64549
  }
64550
+ function textIncludesCredentialField(text, field) {
64551
+ return field === "email" ? /\b(e-?mail|username|user\s*name|login\s*id)\b/i.test(text) : /\bpassword|passcode|pin\b/i.test(text);
64552
+ }
64553
+ function textIncludesCredentialSubmit(text) {
64554
+ 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);
64555
+ }
64556
+ function targetPagesHaveCredentialControls(siteMap, targetPages) {
64557
+ const targetSet = new Set(targetPages.map(normalizePlannerRoute));
64558
+ let hasEmail = false;
64559
+ let hasPassword = false;
64560
+ let hasSubmit = false;
64561
+ for (const page of siteMap.pages) {
64562
+ if (!targetSet.has(normalizePlannerRoute(page.route)))
64563
+ continue;
64564
+ for (const element of page.elements ?? []) {
64565
+ const label = [
64566
+ element.label,
64567
+ element.role,
64568
+ element.testId,
64569
+ element.name
64570
+ ].filter(Boolean).join(" ");
64571
+ if (!label)
64572
+ continue;
64573
+ if (textIncludesCredentialField(label, "email"))
64574
+ hasEmail = true;
64575
+ if (textIncludesCredentialField(label, "password"))
64576
+ hasPassword = true;
64577
+ 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)) {
64578
+ hasSubmit = true;
64579
+ }
64580
+ }
64581
+ }
64582
+ return hasEmail && hasPassword && hasSubmit;
64583
+ }
64584
+ function journeyCoversCredentialSubmit(journey) {
64585
+ if (!journey)
64586
+ return false;
64587
+ const text = [
64588
+ journey.name,
64589
+ journey.expectedOutcome,
64590
+ ...journey.steps ?? [],
64591
+ ...journey.provenCommands ?? []
64592
+ ].join(" ");
64593
+ const hasCredentialPair = /\bcredentials\b/i.test(text) || textIncludesCredentialField(text, "email") && textIncludesCredentialField(text, "password");
64594
+ return hasCredentialPair && textIncludesCredentialSubmit(text);
64595
+ }
64596
+ function scenarioAuthCredentialPolicy(scenario, steps) {
64597
+ const text = [
64598
+ scenario.name,
64599
+ scenario.goal,
64600
+ scenario.type,
64601
+ scenario.variant,
64602
+ ...steps.map((step) => step.action)
64603
+ ].filter(Boolean).join(" ");
64604
+ if (!/\b(auth|authentication|sign[ -]?in|signin|log[ -]?in|login|session)\b/i.test(text)) {
64605
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64606
+ }
64607
+ 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)) {
64608
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64609
+ }
64610
+ if (!/\b(credential|e-?mail|username|user\s*name|password|valid|invalid|empty|required|wrong|case|special|leading|trailing)\b/i.test(text)) {
64611
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64612
+ }
64613
+ const uiOnly = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/i.test(text);
64614
+ const negative = /\b(invalid|empty|required|missing|wrong|error|validation|case|special|leading|trailing)\b/i.test(text);
64615
+ const happy = /\b(happy|valid\s+credentials|success|successful)\b/i.test(text) || scenario.type === "HAPPY_PATH" || scenario.variant === "happy";
64616
+ return {
64617
+ needsCredentialEvidence: true,
64618
+ needsSubmitStep: !uiOnly && (happy || negative),
64619
+ reason: uiOnly ? "credential UI scenario" : negative ? "credential validation scenario" : "credential sign-in scenario"
64620
+ };
64621
+ }
64432
64622
  function truncateOutcome(text, limit) {
64433
64623
  const normalized = text.replace(/\s+/g, " ").trim();
64434
64624
  if (normalized.length <= limit)
@@ -65194,6 +65384,18 @@ Each entry is exactly one of:
65194
65384
  if (matchSource === "route") {
65195
65385
  warnings.push(`"${sc.name}" matched to journey "${matchingSuggestion.name}" by route overlap only - evidence may be weak`);
65196
65386
  }
65387
+ const authCredentialPolicy = scenarioAuthCredentialPolicy(sc, steps);
65388
+ if (authCredentialPolicy.needsCredentialEvidence) {
65389
+ const hasCredentialEvidence = targetPagesHaveCredentialControls(siteMap, sc.targetPages) || journeyCoversCredentialSubmit(matchingSuggestion);
65390
+ if (!hasCredentialEvidence) {
65391
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} lacks observed email/password/sign-in controls or a recorded credential journey`);
65392
+ continue;
65393
+ }
65394
+ if (authCredentialPolicy.needsSubmitStep && !steps.some((step) => textIncludesCredentialSubmit(step.action)) && !journeyCoversCredentialSubmit(matchingSuggestion)) {
65395
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} does not include a concrete sign-in submit step`);
65396
+ continue;
65397
+ }
65398
+ }
65197
65399
  const evidence = (0, discover_planner_js_1.extractScenarioEvidence)(
65198
65400
  siteMap.pages,
65199
65401
  sc.targetPages,
@@ -65783,7 +65985,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65783
65985
  Return ONLY one valid JSON object matching the schema below. Do NOT include <think> blocks, markdown fences, analysis, prose, or any text outside the JSON object. Put any brief explanation only in the JSON "reasoning" field.
65784
65986
 
65785
65987
  ## Planning Principles
65786
- 1. **Evidence-backed only** - only plan tests for flows the explorer actually observed. EXCEPTION: for async/generative/long-running features (see rule 11), the explorer may have only observed the trigger state and not the successful completion state (because the flow was blocked by a prerequisite, rate limit, slow third party, or the iteration budget ran out). In that case you MAY script the durable-outcome verification step using STRUCTURAL signals only (a new row with a self-created identifier appears in the list, a status badge transitions to a known-observed value, the sheet/dialog dismisses, the input clears). Mark such scenarios with \`"evidenceLevel": "trigger-only"\`. **You MUST NOT assert specific text the explorer never observed** \u2014 phrases like "No results match your search", "Successfully saved", "Operation complete" are FORBIDDEN unless that exact text appears verbatim in evidence.observations or recordedJourneys[*].expectedOutcome. If the only viable verification is text you didn't see, downgrade the assertion to a structural one (the list is empty / the item count dropped to 0 / the input cleared) instead of inventing copy.
65988
+ 1. **Evidence-backed only** - only plan tests for flows the explorer actually observed. EXCEPTION: for async/generative/long-running features (see rule 12), the explorer may have only observed the trigger state and not the successful completion state (because the flow was blocked by a prerequisite, rate limit, slow third party, or the iteration budget ran out). In that case you MAY script the durable-outcome verification step using STRUCTURAL signals only (a new row with a self-created identifier appears in the list, a status badge transitions to a known-observed value, the sheet/dialog dismisses, the input clears). Mark such scenarios with \`"evidenceLevel": "trigger-only"\`. **You MUST NOT assert specific text the explorer never observed** \u2014 phrases like "No results match your search", "Successfully saved", "Operation complete" are FORBIDDEN unless that exact text appears verbatim in evidence.observations or recordedJourneys[*].expectedOutcome. If the only viable verification is text you didn't see, downgrade the assertion to a structural one (the list is empty / the item count dropped to 0 / the input cleared) instead of inventing copy.
65787
65989
  2. **One purpose per scenario** - each scenario tests ONE thing (happy path, validation, journey, or bug)
65788
65990
  3. **Happy paths first** - then pair edge cases to the same form/flow
65789
65991
  4. **Multi-screen journeys** - only when screen transitions were actually observed. When an observed target element sits below the fold, include an explicit swipe step to scroll it into view before the tap (mobile elements off-screen are not hittable until revealed)
@@ -65792,23 +65994,23 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65792
65994
  7. **Expected outcomes are plain-English success criteria** - describe the user-visible product result, not the controls touched along the way. Steps already cover buttons, inputs, tabs, sheets, and menus. Expected outcomes should say what the user can now do or rely on (record saved, message sent, search results filtered, validation explained, home screen reached). Name the target screen or exact copy only when it is the real product outcome; do NOT list "heading visible", "button visible", or similar element inventory unless that exact control is the behavior under test.
65793
65995
  8. **Auth-aware** - mark scenarios that need a signed-in session. Use process.env.TEST_USER_EMAIL / PASSWORD
65794
65996
  9. **No duplicates** - check existing test names before adding
65795
- 10. **All tests start from a hermetic app launch** - every generated test relaunches the app (terminate + activate, a clean cold start) and uses the app's own in-app navigation (tapping tabs / links / buttons) to reach the target screen. Steps must reflect this: say "Relaunch the app and tap Sign In" not "deep-link to the login screen". Do NOT navigate by deep-link; the app's launch + tab/drawer navigation is the only entry path. \`targetPages\` lists the screen-ids involved in the flow.
65796
- 12. **targetPages must be copied CHARACTER-FOR-CHARACTER from the screen-ids in the evidence** (they look like \`com.example.app.MainActivity#ab12cd34\`). NEVER invent, prettify, or abbreviate them \u2014 no "Landing", "AUTH", "Home Screen", or any name that is not literally in the provided screens. A scenario whose targetPages contains even ONE id not present in the evidence is REJECTED WHOLESALE by the validator, so a single made-up route silently destroys the entire scenario. If you are unsure which screen-id a step touches, use the screen-id of the screen where the flow STARTS.
65797
- 11. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" is NOT a happy path - that is a UI interaction test. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
65997
+ 10. **All tests start from the app launch entry** - every generated test starts by launching the target app. The runner clears app data when the device/reset policy safely supports it; otherwise it performs a terminate + activate relaunch, which can preserve app session data. Therefore scenarios MUST navigate through the app's own UI from whatever launch state is valid, and MUST NOT rely on mutable data from a prior scenario disappearing. Steps must reflect this: say "Launch the app and tap Sign In" not "deep-link to the login screen". Do NOT navigate by deep-link; the app's launch + tab/drawer navigation is the only entry path. \`targetPages\` lists the screen-ids involved in the flow.
65998
+ 11. **targetPages must be copied CHARACTER-FOR-CHARACTER from the screen-ids in the evidence** (they look like \`com.example.app.MainActivity#ab12cd34\`). NEVER invent, prettify, or abbreviate them \u2014 no "Landing", "AUTH", "Home Screen", or any name that is not literally in the provided screens. A scenario whose targetPages contains even ONE id not present in the evidence is REJECTED WHOLESALE by the validator, so a single made-up route silently destroys the entire scenario. If you are unsure which screen-id a step touches, use the screen-id of the screen where the flow STARTS.
65999
+ 12. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" is NOT a happy path - that is a UI interaction test. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
65798
66000
  - The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
65799
66001
  - A wait step for the operation to complete. Phrase it by SIGNAL, not wall-clock ("Wait for the result to appear - up to 60 seconds"). Prefer signals the app actually uses: spinner disappears, status badge flips to a success state, a new row appears, an item count increments, a success toast/snackbar shows, the screen transitions to a result screen.
65800
66002
  - A verification step on the DURABLE RESULT - the thing that outlives the transient UI: a new list row, a persisted record, a received message, a committed state change, visible output text. Do NOT assert on the spinner disappearing alone; assert on what replaced it.
65801
66003
  - Where the feature exposes follow-up actions on the result (View / Open / Copy / Share / Delete), include at least one to prove the result is real and usable.
65802
66004
  DO NOT verify subjective qualities of generated content ("relevant to the input", "high quality") - those are not deterministic. Verify structural/existence properties: non-empty, contains a known input value, matches an expected field, row count increased, status = completed.
65803
- Example - BAD happy path: \`["Relaunch app", "Tap Create", "Verify sheet opens"]\` \u2192 only tests the trigger.
65804
- Example - GOOD happy path (generic, async): \`["Relaunch app and sign in", "Navigate to the feature screen", "Note the current count of rows in the list", "Tap the primary action button", "Fill required inputs and submit", "Wait for completion signal (up to 60s)", "Verify the row count increased by 1", "Open the newly created row", "Verify its content is non-empty and contains the input I provided"]\`
65805
- Example - GOOD happy path (sync CRUD): \`["Relaunch app and sign in", "Navigate to the list screen", "Tap New", "Fill required fields", "Tap Save", "Verify the new record appears in the list", "Tap the record and verify its detail screen shows the values I entered"]\`
65806
- 12. **Auth happy paths require concrete session proof.** A sign-in HAPPY_PATH is incomplete if it only proves "user reaches the home screen" or "an authenticated screen is visible." It MUST verify at least one durable signed-in signal that was observed in the evidence: the account/profile tab opens, Sign out is available, the observed display name/email/avatar is present, an authenticated screen remains reachable after a relaunch with the session restored, or another app-specific session control is usable. Prefer concrete observed labels from the evidence (for example "Sign out", "Demo User", "Account", "Profile") over generic phrases like "personalized content." If the explorer only observed a screen transition and no signed-in signal, keep the screen outcome but mark the session-proof outcome reliability "low" and state what concrete signal was missing.
65807
- 13. **Independent execution contract.** Every scenario MUST pass when run alone, first, last, in random order, and against an app whose session was relaunched fresh with no relevant feature data except explicitly guaranteed immutable fixtures. A scenario that depends on a sibling scenario's side effects (e.g. "delete the conversation that another test created") is invalid. If two flows are inherently coupled, bundle them as a single USER_JOURNEY scenario. (Sessions are noReset=true, so a \`mobile_relaunch\` between scenarios is the hermetic reset \u2014 assume it ran.)
65808
- 14. **Scenario-owned state.** If a scenario reads, searches, opens, updates, messages, cancels, or deletes an entity, that scenario MUST create or seed the entity in its own setup steps. "Use an existing record" / "the conversation in the demo account" / "the existing user named X" are forbidden phrasings \u2014 they assume mutable pre-existing app data. Carve-out: baseline observations (current count, list length, "the home screen header is visible") are allowed as prerequisite reads ONLY when the durable assertion targets a self-created entity. Phrase setup as: "Tap New, fill required fields with run-scoped values, submit and verify the new record appears, THEN proceed to <feature under test>."
65809
- 15. **Run-scoped test data.** Generated entities MUST use unique, run-scoped identifiers \u2014 phrase as "test message <Date.now()>" or "QA-<unique id>" in steps. Subsequent assertions MUST target that unique identifier, not "the first row", "the latest message", "the last cell", or any visible display name observed during exploration. Hardcoding any specific display name, username, email, conversation title, or other identifier observed in the explorer's snapshot is forbidden \u2014 that data is incidental, not a stable contract. The same identifiers will not exist (or will look different) when the test runs against a fresh session or a different account.
65810
- 16. **Setup is plumbing, not purpose.** Sign-in + create-prereq + navigate steps DO NOT violate "one purpose per scenario" (rule 2). The scenario's purpose is its primary user goal and the durable assertion that proves it. Treat setup as scaffolding and write it inline; do not split a delete-confirm scenario into "sign in \u2192 create" and "delete \u2192 assert removed" \u2014 they belong in one scenario.
65811
- 17. **Prerequisites \u2014 declare every state assumption, AND prefer rewriting to declaring.** Every scenario MUST list its state prerequisites in the \`prerequisites\` array. The four kinds are \`self_owned\`, \`seed_fixture\`, \`external_relationship\`, and \`clean_initial_state\`.
66005
+ Example - BAD happy path: \`["Launch app", "Tap Create", "Verify sheet opens"]\` \u2192 only tests the trigger.
66006
+ Example - GOOD happy path (generic, async): \`["Launch app and sign in if needed", "Navigate to the feature screen", "Note the current count of rows in the list", "Tap the primary action button", "Fill required inputs and submit", "Wait for completion signal (up to 60s)", "Verify the row count increased by 1", "Open the newly created row", "Verify its content is non-empty and contains the input I provided"]\`
66007
+ Example - GOOD happy path (sync CRUD): \`["Launch app and sign in if needed", "Navigate to the list screen", "Tap New", "Fill required fields", "Tap Save", "Verify the new record appears in the list", "Tap the record and verify its detail screen shows the values I entered"]\`
66008
+ 13. **Auth happy paths require concrete session proof.** A sign-in HAPPY_PATH is incomplete if it only proves "user reaches the home screen" or "an authenticated screen is visible." It MUST verify at least one durable signed-in signal that was observed in the evidence: the account/profile tab opens, Sign out is available, the observed display name/email/avatar is present, an authenticated screen remains reachable after a relaunch with the session restored, or another app-specific session control is usable. Prefer concrete observed labels from the evidence (for example "Sign out", "Account", "Profile", or the user's own displayed name/email) over generic phrases like "personalized content." If the explorer only observed a screen transition and no signed-in signal, keep the screen outcome but mark the session-proof outcome reliability "low" and state what concrete signal was missing.
66009
+ 14. **Independent execution contract.** Every scenario MUST pass when run alone, first, last, in random order, and against an app launch state that may be freshly cleared OR may preserve a previous session depending on the device/reset policy. A scenario that depends on a sibling scenario's side effects (e.g. "delete the conversation that another test created") is invalid. If two flows are inherently coupled, bundle them as a single USER_JOURNEY scenario.
66010
+ 15. **Scenario-owned state.** If a scenario reads, searches, opens, updates, messages, cancels, or deletes an entity, that scenario MUST create or seed the entity in its own setup steps. "Use an existing record" / "the conversation already in the account" / "the existing user named X" are forbidden phrasings \u2014 they assume mutable pre-existing app data. Carve-out: baseline observations (current count, list length, "the home screen header is visible") are allowed as prerequisite reads ONLY when the durable assertion targets a self-created entity. Phrase setup as: "Tap New, fill required fields with run-scoped values, submit and verify the new record appears, THEN proceed to <feature under test>."
66011
+ 16. **Run-scoped test data.** Generated entities MUST use unique, run-scoped identifiers \u2014 phrase as "test message <Date.now()>" or "QA-<unique id>" in steps. Subsequent assertions MUST target that unique identifier, not "the first row", "the latest message", "the last cell", or any visible display name observed during exploration. Hardcoding any specific display name, username, email, conversation title, or other identifier observed in the explorer's snapshot is forbidden \u2014 that data is incidental, not a stable contract. The same identifiers will not exist (or will look different) when the test runs against a fresh session or a different account.
66012
+ 17. **Setup is plumbing, not purpose.** Sign-in + create-prereq + navigate steps DO NOT violate "one purpose per scenario" (rule 2). The scenario's purpose is its primary user goal and the durable assertion that proves it. Treat setup as scaffolding and write it inline; do not split a delete-confirm scenario into "sign in \u2192 create" and "delete \u2192 assert removed" \u2014 they belong in one scenario.
66013
+ 18. **Prerequisites \u2014 declare every state assumption, AND prefer rewriting to declaring.** Every scenario MUST list its state prerequisites in the \`prerequisites\` array. The four kinds are \`self_owned\`, \`seed_fixture\`, \`external_relationship\`, and \`clean_initial_state\`.
65812
66014
 
65813
66015
  **Before declaring a prereq as anything other than \`self_owned\`, attempt these IN ORDER:**
65814
66016
 
@@ -65831,7 +66033,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65831
66033
  - \`clean_initial_state\`: never use.
65832
66034
 
65833
66035
  If your scenario has NO state assumptions (pure navigation, public screen, validation scenarios where ANY state passes), use \`prerequisites: []\`. An empty array is the correct declaration for self-contained scenarios.
65834
- 18. **Planner emits design, not implementation.** Live-generate handles execution against a real device snapshot.
66036
+ 19. **Planner emits design, not implementation.** Live-generate handles execution against a real device snapshot.
65835
66037
 
65836
66038
  **YOUR responsibility:**
65837
66039
  - Scenario \`name\`, \`type\`, \`goal\`, \`prerequisites\`
@@ -65857,10 +66059,11 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65857
66059
  Citing observed labels / accessibility-ids in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
65858
66060
 
65859
66061
  ## Coverage Strategy
65860
- - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 11) + 1 EDGE_CASE (empty/invalid submission)
65861
- - For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 12) + validation (wrong password, empty fields)
66062
+ - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
66063
+ - 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)
66064
+ - 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.
65862
66065
  - 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.
65863
- - For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule 11 - never stop at "button tapped" or "sheet opened"
66066
+ - 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"
65864
66067
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
65865
66068
 
65866
66069
  ## Output JSON Schema
@@ -65882,7 +66085,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65882
66085
  "featureGroup": "Authentication",
65883
66086
  "goal": "User can sign in with valid credentials and reach the home screen",
65884
66087
  "steps": [
65885
- { "action": "Relaunch the app and tap Sign In to reach the sign-in screen" },
66088
+ { "action": "Launch the app and tap Sign In to reach the sign-in screen" },
65886
66089
  { "action": "Type test credentials into the email field" },
65887
66090
  { "action": "Type test credentials into the password field" },
65888
66091
  { "action": "Tap the Sign In button" },
@@ -65955,6 +66158,59 @@ Each entry is exactly one of:
65955
66158
  function screenDisplayName(state2) {
65956
66159
  return state2.screenName?.trim() || state2.screenId;
65957
66160
  }
66161
+ function isOpaqueMobileScreenName(value, appId) {
66162
+ const trimmed = value.trim();
66163
+ if (!trimmed)
66164
+ return false;
66165
+ if (appId && (trimmed === appId || trimmed.startsWith(`${appId}#`) || trimmed.startsWith(`${appId}.`))) {
66166
+ return true;
66167
+ }
66168
+ return /^[A-Za-z][\w$]*(?:\.[A-Za-z][\w$]*)+(?:#[A-Za-z0-9_-]+)?$/.test(trimmed);
66169
+ }
66170
+ function humanizeToken(token) {
66171
+ const spaced = token.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
66172
+ if (!spaced)
66173
+ return "";
66174
+ return spaced.split(/\s+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
66175
+ }
66176
+ function mobileAppDisplayName(appId) {
66177
+ const parts = (appId ?? "").split(".").map((part) => part.trim()).filter(Boolean).filter((part) => !/^(com|org|net|io|app|mobile|android|ios)$/i.test(part));
66178
+ const token = parts.length > 0 ? parts[parts.length - 1] : "";
66179
+ const human = humanizeToken(token);
66180
+ return human || "Mobile App";
66181
+ }
66182
+ function nonGenericScreenName(state2, appId) {
66183
+ const raw = state2.screenName?.trim();
66184
+ if (!raw || isOpaqueMobileScreenName(raw, appId))
66185
+ return null;
66186
+ const human = humanizeToken(raw.replace(/(?:Activity|ViewController|Fragment|Screen|Page)$/i, ""));
66187
+ if (!human || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human))
66188
+ return null;
66189
+ return human;
66190
+ }
66191
+ function inferMobileFeatureGroupName(states, appId) {
66192
+ const usable = states.filter((state2) => state2.screenType?.toLowerCase() !== "error");
66193
+ const screenTypes = usable.map((state2) => state2.screenType?.toLowerCase()).filter(Boolean);
66194
+ if (screenTypes.length > 0 && screenTypes.every((type) => type === "auth"))
66195
+ return "Authentication";
66196
+ if (screenTypes.length > 0 && screenTypes.every((type) => type === "onboarding" || type === "landing" || type === "splash")) {
66197
+ return "Onboarding";
66198
+ }
66199
+ const named = usable.map((state2) => nonGenericScreenName(state2, appId)).find((name) => Boolean(name));
66200
+ return named ?? `${mobileAppDisplayName(appId)} App`;
66201
+ }
66202
+ function mobileFeatureCanonicalKey(name) {
66203
+ const normalized = name.trim().toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
66204
+ if (!normalized)
66205
+ return "mobile-app";
66206
+ if (/(^|-)(auth|authentication|login|log-in|signin|sign-in|signup|sign-up)($|-)/.test(normalized)) {
66207
+ return "authentication";
66208
+ }
66209
+ if (/(^|-)(onboarding|welcome|landing|splash|intro|walkthrough)($|-)/.test(normalized)) {
66210
+ return "onboarding";
66211
+ }
66212
+ return normalized.replace(/-(app|screen|screens|flow|feature)$/g, "") || normalized;
66213
+ }
65958
66214
  function formatMobileEvidenceForPlanner(graph, context = {}) {
65959
66215
  const sections = [];
65960
66216
  if (context.appBrief) {
@@ -66146,9 +66402,10 @@ ${constraints.join("\n")}`);
66146
66402
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66147
66403
  for (const state2 of orderedScreens) {
66148
66404
  const screenTypeLower = state2.screenType?.toLowerCase();
66149
- const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : screenDisplayName(state2);
66405
+ const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : inferMobileFeatureGroupName([state2], appId);
66150
66406
  const group = groups.get(groupName) ?? {
66151
66407
  name: groupName,
66408
+ canonicalKey: mobileFeatureCanonicalKey(groupName),
66152
66409
  description: `Native mobile screens related to ${groupName}.`,
66153
66410
  routes: [],
66154
66411
  criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
@@ -66174,7 +66431,7 @@ ${constraints.join("\n")}`);
66174
66431
  const to = graph.screens.get(transition.toScreenId);
66175
66432
  if (!from || !to)
66176
66433
  continue;
66177
- const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
66434
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : inferMobileFeatureGroupName([from], appId);
66178
66435
  const group = groups.get(groupName);
66179
66436
  if (!group)
66180
66437
  continue;
@@ -66198,7 +66455,7 @@ ${constraints.join("\n")}`);
66198
66455
  for (const state2 of orderedScreens) {
66199
66456
  if (scenarios.length >= budget)
66200
66457
  break;
66201
- const screenName = screenDisplayName(state2);
66458
+ const screenName = nonGenericScreenName(state2, input.appId) ?? inferMobileFeatureGroupName([state2], input.appId);
66202
66459
  const baseName = `${screenName} - Screen Reachability`;
66203
66460
  const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
66204
66461
  if (usedNames.has(name.toLowerCase()))
@@ -66206,7 +66463,7 @@ ${constraints.join("\n")}`);
66206
66463
  usedNames.add(name.toLowerCase());
66207
66464
  const actionableLabels = state2.elements.filter((el) => el.actionable).map((el) => el.label?.trim() || el.text?.trim() || el.accessibilityId || shortResourceId(el.resourceId ?? "") || compactRole(el.role)).filter((label) => Boolean(label)).slice(0, 3);
66208
66465
  const steps = [
66209
- { action: "Relaunch the app from a clean state", target: entryScreenId },
66466
+ { action: "Launch the app and start from the current launch entry", target: entryScreenId },
66210
66467
  {
66211
66468
  action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
66212
66469
  target: state2.screenId
@@ -66257,16 +66514,27 @@ ${constraints.join("\n")}`);
66257
66514
  }
66258
66515
  return filtered;
66259
66516
  }
66260
- function filterFeatureGroupsToSiteMap(groups, siteMap) {
66517
+ function filterFeatureGroupsToSiteMap(groups, siteMap, graph, appId) {
66261
66518
  if (groups.length === 0)
66262
66519
  return [];
66263
66520
  const validRoutes = new Set(siteMap.pages.map((page) => page.route));
66521
+ const usedNames = /* @__PURE__ */ new Map();
66264
66522
  return groups.flatMap((group) => {
66265
66523
  const routes = group.routes.filter((route2) => validRoutes.has(route2));
66266
66524
  if (routes.length === 0)
66267
66525
  return [];
66526
+ const routeStates = routes.map((route2) => graph?.screens.get(route2)).filter((state2) => Boolean(state2));
66527
+ const rawName = group.name?.trim() || "";
66528
+ const inferredName = rawName && !isOpaqueMobileScreenName(rawName, appId) ? rawName : inferMobileFeatureGroupName(routeStates, appId);
66529
+ const count = usedNames.get(inferredName) ?? 0;
66530
+ usedNames.set(inferredName, count + 1);
66531
+ const name = count === 0 ? inferredName : `${inferredName} ${count + 1}`;
66532
+ const canonicalKey = group.canonicalKey?.trim() || mobileFeatureCanonicalKey(inferredName);
66268
66533
  return [{
66269
66534
  ...group,
66535
+ name,
66536
+ canonicalKey,
66537
+ description: group.description?.trim() || `Native mobile screens related to ${name}.`,
66270
66538
  routes,
66271
66539
  pages: group.pages?.filter((page) => validRoutes.has(page.route))
66272
66540
  }];
@@ -66318,7 +66586,7 @@ ${constraints.join("\n")}`);
66318
66586
  incremental: input.incremental
66319
66587
  })
66320
66588
  });
66321
- const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
66589
+ const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap, input.graph, input.appId);
66322
66590
  const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
66323
66591
  if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
66324
66592
  const fallbackScenarios = buildMobileFallbackScenarios(input);
@@ -66565,7 +66833,15 @@ var require_mobile_generator = __commonJS({
66565
66833
  return { screen, signal, refMap, observation };
66566
66834
  }
66567
66835
  function labelForElement(element) {
66568
- return nonEmpty2(element.label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? nonEmpty2(element.text) ?? "element";
66836
+ const editable = isEditableMobileElement(element);
66837
+ const label = nonEmpty2(element.label);
66838
+ const text = nonEmpty2(element.text);
66839
+ const labelIsEditableValue = editable && label !== void 0 && text !== void 0 && label === text;
66840
+ return (labelIsEditableValue ? void 0 : label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? (editable ? void 0 : text) ?? "element";
66841
+ }
66842
+ var EDITABLE_ELEMENT_ROLE_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
66843
+ function isEditableMobileElement(element) {
66844
+ return EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim());
66569
66845
  }
66570
66846
  function nonEmpty2(value) {
66571
66847
  if (typeof value !== "string")
@@ -66607,7 +66883,7 @@ var require_mobile_generator = __commonJS({
66607
66883
  ## Rules
66608
66884
  - Re-snapshot before every assertion so the ref is fresh.
66609
66885
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
66610
- - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs ("Add Your First \u2026", "Get Started", "No items yet"), onboarding banners, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
66886
+ - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
66611
66887
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
66612
66888
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
66613
66889
  - NEVER tap the phone's home screen / launcher (app icons, "Predicted app" chips, search bars, widgets). Those are NOT part of the app under test \u2014 tapping them is junk that poisons the test. If the app ever backgrounds or you find yourself on the launcher, use mobile_relaunch to cold-start the app; do NOT tap a launcher icon or app-prediction to get back in.
@@ -67179,6 +67455,13 @@ ${statePacket}` },
67179
67455
  const { resolveRef, screenName, platform: platform3, recordAction } = ctx;
67180
67456
  const element = resolveRef(toolArgs.ref);
67181
67457
  if (element) {
67458
+ const weakReason = weakAssertionTargetReason(element);
67459
+ if (weakReason) {
67460
+ return {
67461
+ ok: false,
67462
+ error: `Ref ${element.ref} is not a durable assertion target (${weakReason}). Re-snapshot and assert a human-visible label, text, or stable control that proves the screen outcome.`
67463
+ };
67464
+ }
67182
67465
  const metadata = metadataForElement(element, screenName(), platform3);
67183
67466
  recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
67184
67467
  return { ok: true, label: metadata.targetLabel ?? "element" };
@@ -67188,6 +67471,13 @@ ${statePacket}` },
67188
67471
  if (!accessibilityId && !text) {
67189
67472
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
67190
67473
  }
67474
+ const directTarget = accessibilityId ?? text;
67475
+ if (directTarget && isStructuralAssertionIdentity(directTarget)) {
67476
+ return {
67477
+ ok: false,
67478
+ error: `"${directTarget}" looks like a structural/internal container id, not a user-visible outcome. Assert a visible label, message, or stable product control instead.`
67479
+ };
67480
+ }
67191
67481
  const screen = screenName();
67192
67482
  if (accessibilityId) {
67193
67483
  const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
@@ -67211,7 +67501,7 @@ ${statePacket}` },
67211
67501
  if (!snapshot || snapshot.screen.elements.length === 0)
67212
67502
  return null;
67213
67503
  const elements = snapshot.screen.elements;
67214
- const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
67504
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
67215
67505
  stableAnchors.sort((a, b) => {
67216
67506
  const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
67217
67507
  return labelled2(a) - labelled2(b);
@@ -67224,8 +67514,8 @@ ${statePacket}` },
67224
67514
  return nonButton;
67225
67515
  if (stableAnchors.length > 0)
67226
67516
  return stableAnchors[0];
67227
- const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67228
- return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
67517
+ const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67518
+ return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
67229
67519
  }
67230
67520
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
67231
67521
  var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
@@ -67237,9 +67527,9 @@ ${statePacket}` },
67237
67527
  "navigationBarBackground",
67238
67528
  // WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
67239
67529
  // expose their framework ROOT container's DOM id as the element id — present on
67240
- // EVERY in-app screen, so the floor must not anchor to it (observed live on
67241
- // ThoughtStream: `main-content` produced a screen-agnostic assertion that then
67242
- // never matched via the text-contains fallback).
67530
+ // EVERY in-app screen, so the floor must not anchor to it (observed live: a
67531
+ // framework root id like `main-content` produced a screen-agnostic assertion
67532
+ // that then never matched via the text-contains fallback).
67243
67533
  "main-content",
67244
67534
  "root",
67245
67535
  "app",
@@ -67248,18 +67538,53 @@ ${statePacket}` },
67248
67538
  "react-root",
67249
67539
  "application"
67250
67540
  ]);
67541
+ 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;
67542
+ function locatorLocalName(value) {
67543
+ const trimmed = value.trim();
67544
+ const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
67545
+ const slashIdx = withoutStrategy.indexOf("/");
67546
+ return slashIdx >= 0 ? withoutStrategy.slice(slashIdx + 1) : withoutStrategy;
67547
+ }
67548
+ function isStructuralAssertionIdentity(value) {
67549
+ const trimmed = nonEmpty2(value);
67550
+ if (!trimmed)
67551
+ return false;
67552
+ const localName = locatorLocalName(trimmed);
67553
+ return ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName) || STRUCTURAL_ASSERT_ID_RE.test(localName);
67554
+ }
67555
+ function hasHumanAssertionSignal(element) {
67556
+ const label = nonEmpty2(element.label);
67557
+ const text = nonEmpty2(element.text);
67558
+ const accessibilityId = nonEmpty2(element.accessibilityId);
67559
+ return Boolean(label && !isStructuralAssertionIdentity(label) || text && !isEditableMobileElement(element) && !isStructuralAssertionIdentity(text) || accessibilityId && !isStructuralAssertionIdentity(accessibilityId));
67560
+ }
67561
+ function weakAssertionTargetReason(element) {
67562
+ if (isAlwaysPresentContainer(element))
67563
+ return "always-present app/system container";
67564
+ const structuralIdentity = [
67565
+ element.accessibilityId,
67566
+ element.resourceId,
67567
+ element.label,
67568
+ element.text
67569
+ ].some(isStructuralAssertionIdentity);
67570
+ const containerishClass = BARE_CONTAINER_CLASS_RE.test(element.role);
67571
+ const humanSignal = hasHumanAssertionSignal(element);
67572
+ if (structuralIdentity && (containerishClass || !humanSignal))
67573
+ return "structural/internal container id";
67574
+ if (containerishClass && !humanSignal)
67575
+ return "generic container with no visible product signal";
67576
+ return null;
67577
+ }
67251
67578
  function isAlwaysPresentContainer(element) {
67252
67579
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
67253
67580
  return true;
67254
67581
  const resourceId = element.resourceId?.trim();
67255
67582
  if (resourceId) {
67256
- const slashIdx = resourceId.indexOf("/");
67257
- const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
67258
- if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
67583
+ if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(resourceId)))
67259
67584
  return true;
67260
67585
  }
67261
67586
  const accessibilityId = element.accessibilityId?.trim();
67262
- if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
67587
+ if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(accessibilityId)))
67263
67588
  return true;
67264
67589
  return false;
67265
67590
  }
@@ -67274,7 +67599,7 @@ ${statePacket}` },
67274
67599
  if (hasIdentity)
67275
67600
  return false;
67276
67601
  const resourceId = bundle.resourceId?.trim() ?? "";
67277
- const localName = resourceId.includes("/") ? resourceId.slice(resourceId.indexOf("/") + 1) : resourceId;
67602
+ const localName = locatorLocalName(resourceId);
67278
67603
  const containerishId = localName !== "" && (localName === "webview" || ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName));
67279
67604
  const containerishClass = BARE_CONTAINER_CLASS_RE.test(bundle.className ?? "");
67280
67605
  if (containerishId)
@@ -67293,6 +67618,88 @@ ${statePacket}` },
67293
67618
  }));
67294
67619
  return true;
67295
67620
  }
67621
+ function scenarioText(scenario) {
67622
+ return [
67623
+ scenario.name,
67624
+ scenario.goal,
67625
+ ...scenario.steps.flatMap((step) => [step.action, step.target, step.hint]),
67626
+ ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67627
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67628
+ }
67629
+ function actionText(action) {
67630
+ const bundle = action.metadata.locatorBundle;
67631
+ return [
67632
+ action.type,
67633
+ action.selector,
67634
+ action.value,
67635
+ action.innerText,
67636
+ action.metadata.targetLabel,
67637
+ bundle?.text,
67638
+ bundle?.contentDesc,
67639
+ bundle?.accessibilityId,
67640
+ bundle?.resourceId,
67641
+ bundle?.className,
67642
+ bundle?.xpath
67643
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67644
+ }
67645
+ function scenarioIntentMismatchReason(scenario, actions) {
67646
+ const text = scenarioText(scenario);
67647
+ 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);
67648
+ const typedEmailIndexes = [];
67649
+ const typedPasswordIndexes = [];
67650
+ actions.forEach((action, index) => {
67651
+ if (action.type !== "TYPE")
67652
+ return;
67653
+ const textForAction = actionText(action);
67654
+ if (/\b(email|e-mail|username|user name|login)\b|\{\{test_user_email\}\}/i.test(textForAction)) {
67655
+ typedEmailIndexes.push(index);
67656
+ }
67657
+ if (/\b(password|passcode|pin)\b|\{\{test_user_password\}\}/i.test(textForAction)) {
67658
+ typedPasswordIndexes.push(index);
67659
+ }
67660
+ });
67661
+ const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67662
+ const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67663
+ const submitAfterCredentials = actions.some((action, index) => {
67664
+ if (action.type !== "TAP" || index <= lastCredentialIndex)
67665
+ return false;
67666
+ const textForAction = actionText(action);
67667
+ if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
67668
+ return false;
67669
+ return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
67670
+ });
67671
+ const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67672
+ if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67673
+ return "credential fields were filled but no sign-in submit tap was recorded after them";
67674
+ }
67675
+ const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67676
+ const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67677
+ const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67678
+ const loginTapWithoutCredentialPath = actions.some((action) => {
67679
+ if (action.type !== "TAP")
67680
+ return false;
67681
+ const textForAction = actionText(action);
67682
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67683
+ return false;
67684
+ return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67685
+ });
67686
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67687
+ return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67688
+ }
67689
+ if (!isCredentialSignIn)
67690
+ return null;
67691
+ const typedEmail = typedEmailIndexes.length > 0;
67692
+ const typedPassword = typedPasswordIndexes.length > 0;
67693
+ const tappedSubmit = submitAfterCredentials;
67694
+ if (typedEmail && typedPassword && tappedSubmit)
67695
+ return null;
67696
+ const missing = [
67697
+ typedEmail ? null : "email/username entry",
67698
+ typedPassword ? null : "password entry",
67699
+ tappedSubmit ? null : "sign-in submit tap"
67700
+ ].filter((value) => value !== null);
67701
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
67702
+ }
67296
67703
  function actionCycleKey(action) {
67297
67704
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67298
67705
  }
@@ -67316,6 +67723,22 @@ ${statePacket}` },
67316
67723
  break;
67317
67724
  }
67318
67725
  }
67726
+ for (let window2 = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor((out.length - 1) / 2)); window2 >= 2 && !changed; window2--) {
67727
+ for (let i = 0; i + 2 * window2 + 1 <= out.length; i++) {
67728
+ const first = out.slice(i, i + window2);
67729
+ if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
67730
+ continue;
67731
+ const back = out[i + window2];
67732
+ if (back.type !== "BACK")
67733
+ continue;
67734
+ const second = out.slice(i + window2 + 1, i + window2 + 1 + window2);
67735
+ if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
67736
+ continue;
67737
+ out.splice(i, window2 + 1);
67738
+ changed = true;
67739
+ break;
67740
+ }
67741
+ }
67319
67742
  }
67320
67743
  return out;
67321
67744
  }
@@ -67330,6 +67753,11 @@ ${statePacket}` },
67330
67753
  if (actions.length < beforeContainerDrop) {
67331
67754
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67332
67755
  }
67756
+ const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67757
+ if (intentMismatch) {
67758
+ log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
67759
+ return null;
67760
+ }
67333
67761
  const floorAppended = appendHybridFloor(actions, recording, platform3);
67334
67762
  if (floorAppended) {
67335
67763
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -67352,6 +67780,10 @@ ${statePacket}` },
67352
67780
  log2(` compile failed: ${err instanceof Error ? err.message : String(err)}`);
67353
67781
  return null;
67354
67782
  }
67783
+ const selectorQuality = (0, shared_1.analyzeMobileSelectorQuality)(compiled.steps);
67784
+ if (selectorQuality.summary.high > 0 || selectorQuality.summary.medium > 0) {
67785
+ log2(` selector quality: score ${selectorQuality.summary.score}/100 (${selectorQuality.summary.high} high, ${selectorQuality.summary.medium} medium issue(s)).`);
67786
+ }
67355
67787
  const claimedVerified = recording.finish?.verified ?? false;
67356
67788
  const verified = claimedVerified && recording.assertCount > 0;
67357
67789
  if (claimedVerified && !verified) {
@@ -67393,6 +67825,7 @@ ${statePacket}` },
67393
67825
  targetPages: [...scenario.targetPages]
67394
67826
  },
67395
67827
  evidence: { observedLocators: [], observedTransitions: [] },
67828
+ mobileSelectorQuality: selectorQuality,
67396
67829
  // Assertion-strength marker for the server promote-seam
67397
67830
  // (resolveStabilityTransition): an UNVERIFIED recording proves none of the
67398
67831
  // scenario's outcomes — only the hybrid floor's landing-screen visibility —
@@ -67417,6 +67850,25 @@ ${statePacket}` },
67417
67850
  };
67418
67851
  return test;
67419
67852
  }
67853
+ async function prepareMobileScenarioAppState(driver, target, log2) {
67854
+ const resetPolicy = target?.resetPolicy ?? "AUTO";
67855
+ if (resetPolicy === "PRESERVE_APP_DATA") {
67856
+ await driver.relaunch();
67857
+ log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
67858
+ return false;
67859
+ }
67860
+ const appDataCleared = await driver.resetAppState?.().catch((err) => {
67861
+ log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
67862
+ return false;
67863
+ }) ?? false;
67864
+ if (appDataCleared)
67865
+ return true;
67866
+ if (resetPolicy === "CLEAR_APP_DATA") {
67867
+ log2(" CLEAR_APP_DATA requested but app-data reset was unavailable; falling back to relaunch.");
67868
+ }
67869
+ await driver.relaunch();
67870
+ return false;
67871
+ }
67420
67872
  async function runMobileGeneratePhase(args) {
67421
67873
  const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
67422
67874
  const log2 = (line) => {
@@ -67449,11 +67901,9 @@ ${statePacket}` },
67449
67901
  log2(`
67450
67902
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67451
67903
  try {
67452
- const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
67904
+ const appDataCleared = await prepareMobileScenarioAppState(driver, ctx.target, log2);
67453
67905
  if (appDataCleared) {
67454
67906
  log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
67455
- } else {
67456
- await driver.relaunch();
67457
67907
  }
67458
67908
  const recording = await runScenarioLoop({
67459
67909
  scenario,
@@ -68004,8 +68454,9 @@ Reason over steps + error + tree + registry and return working healed steps. Do
68004
68454
  1. ERROR: which step failed, on what locator/assertion? "element not found"/"no such element" almost always means a WRONG LOCATOR on a screen that DOES exist \u2014 pick a stronger IDENTITY locator that is actually present in the captured tree: a resource-id, an accessibility-id/content-desc, or an EXACT @text= predicate (never a loose contains()). Do NOT declare a feature absent from one failed locator.
68005
68455
  2. STEP RESULTS: which steps passed before the failure.
68006
68456
  3. TREE: does the current screen match what the failed step expects?
68007
- 3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs like "Add Your First \u2026", "No items yet", 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).
68008
- 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 "Get Started" 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.
68457
+ 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).
68458
+ 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.
68459
+ 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.
68009
68460
  ## Response \u2014 STRICT JSON, exactly one verdict:
68010
68461
  {"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}
68011
68462
  {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
@@ -68062,6 +68513,104 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68062
68513
  function isDrift(error2) {
68063
68514
  return /unexpected screen|screen drift/i.test(error2 ?? "");
68064
68515
  }
68516
+ function mobileStepSearchText(step) {
68517
+ const rawTarget = step.target;
68518
+ const structuredTarget = rawTarget && typeof rawTarget === "object" ? rawTarget : void 0;
68519
+ const bundle = structuredTarget?.locatorBundle;
68520
+ const stepValue = step.value;
68521
+ return [
68522
+ step.action,
68523
+ step.description,
68524
+ typeof stepValue === "string" ? stepValue : void 0,
68525
+ typeof rawTarget === "string" ? rawTarget : void 0,
68526
+ structuredTarget?.primary,
68527
+ bundle?.text,
68528
+ bundle?.contentDesc,
68529
+ bundle?.accessibilityId,
68530
+ bundle?.resourceId,
68531
+ bundle?.className,
68532
+ bundle?.xpath
68533
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
68534
+ }
68535
+ function detectInvalidExternalAuthSetup(steps, error2) {
68536
+ if (!/foreground app changed|outside target app|com\.google\.android\.gms|external app/i.test(error2 ?? "")) {
68537
+ return null;
68538
+ }
68539
+ const hasBareLoginTap = steps.some((step) => {
68540
+ if (!/\btap\b/i.test(step.action))
68541
+ return false;
68542
+ const text = mobileStepSearchText(step);
68543
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(text))
68544
+ return false;
68545
+ return /\b(sign\s*in|signin|log\s*in|login)\b/.test(text);
68546
+ });
68547
+ if (!hasBareLoginTap)
68548
+ return null;
68549
+ const hasCredentialEntry = steps.some((step) => {
68550
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
68551
+ return false;
68552
+ const text = mobileStepSearchText(step);
68553
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68554
+ });
68555
+ if (hasCredentialEntry)
68556
+ return null;
68557
+ 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";
68558
+ }
68559
+ function isCredentialFieldStep(step) {
68560
+ const text = mobileStepSearchText(step);
68561
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68562
+ }
68563
+ function isCredentialSubmitTap(step) {
68564
+ if (!/\b(tap|click|press)\b/i.test(step.action))
68565
+ return false;
68566
+ const text = mobileStepSearchText(step);
68567
+ 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)) {
68568
+ return false;
68569
+ }
68570
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
68571
+ }
68572
+ function detectInvalidAuthValidationSetup(steps, error2) {
68573
+ if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
68574
+ return null;
68575
+ }
68576
+ const allText = steps.map(mobileStepSearchText).join(" ");
68577
+ if (!/\b(email|e-mail|username|user\s*name|password|passcode|pin)\b/.test(allText))
68578
+ return null;
68579
+ 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);
68580
+ let lastCredentialEditOrder = -1;
68581
+ for (const step of steps) {
68582
+ if (!/\b(type|fill|input|enter|clear)\b/i.test(step.action))
68583
+ continue;
68584
+ if (!isCredentialFieldStep(step))
68585
+ continue;
68586
+ lastCredentialEditOrder = Math.max(lastCredentialEditOrder, step.order ?? -1);
68587
+ }
68588
+ if (lastCredentialEditOrder < 0)
68589
+ return null;
68590
+ if (uiOnlyCredentialScenario) {
68591
+ const hasCredentialFormEvidence = steps.some((step) => {
68592
+ if ((step.order ?? -1) >= lastCredentialEditOrder)
68593
+ return false;
68594
+ const text = mobileStepSearchText(step);
68595
+ 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);
68596
+ });
68597
+ if (hasCredentialFormEvidence)
68598
+ return null;
68599
+ 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";
68600
+ }
68601
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
68602
+ if (hasSubmitAfterCredentialEdit)
68603
+ return null;
68604
+ const hasValidationAssertion = steps.some((step) => {
68605
+ if (!/\b(assert|wait)\b/i.test(step.action))
68606
+ return false;
68607
+ const text = mobileStepSearchText(step);
68608
+ 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);
68609
+ });
68610
+ if (hasValidationAssertion)
68611
+ return null;
68612
+ 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";
68613
+ }
68065
68614
  function stripCodeFences(text) {
68066
68615
  const trimmed = text.trim();
68067
68616
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -68320,6 +68869,24 @@ ${args.directive}`);
68320
68869
  const registryPresent = options.selectorRegistryContext != null;
68321
68870
  const platform3 = target.platform;
68322
68871
  const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
68872
+ const invalidExternalAuthSetup = detectInvalidExternalAuthSetup(steps, ctx.error);
68873
+ if (invalidExternalAuthSetup) {
68874
+ return {
68875
+ passed: false,
68876
+ verdict: "test_invalid",
68877
+ healReason: invalidExternalAuthSetup,
68878
+ attemptsUsed: 0
68879
+ };
68880
+ }
68881
+ const invalidAuthValidationSetup = detectInvalidAuthValidationSetup(steps, ctx.error);
68882
+ if (invalidAuthValidationSetup) {
68883
+ return {
68884
+ passed: false,
68885
+ verdict: "test_invalid",
68886
+ healReason: invalidAuthValidationSetup,
68887
+ attemptsUsed: 0
68888
+ };
68889
+ }
68323
68890
  let attemptCap = base2;
68324
68891
  let recoverableUsed = 0;
68325
68892
  let bonusUsed = 0;
@@ -68505,19 +69072,20 @@ ${args.directive}`);
68505
69072
  removeTags: script.removeTags
68506
69073
  };
68507
69074
  }
68508
- var DEFAULT_PHASE2_MAX_PASSES = 6;
69075
+ var DEFAULT_PHASE2_MAX_PASSES = 4;
68509
69076
  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.
68510
69077
  ## Key insight \u2014 ENTRY-SCREEN DRIFT
68511
- 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 or "Get Started" screen), the fix is almost always a MISSING NAVIGATION step: AUTHOR the leading step(s) (e.g. a tap on "Get Started") 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.
69078
+ 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.
68512
69079
  ## Analyze
68513
69080
  1. Compare the recorded first step's expected screen to the LIVE tree.
68514
69081
  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().
68515
69082
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68516
- 3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA like "Add Your First \u2026") 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.
68517
- 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.
69083
+ 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.
69084
+ 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.
69085
+ 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.
68518
69086
  ## Response \u2014 STRICT JSON, exactly one verdict:
68519
69087
  {"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}
68520
- {"verdict":"observe","action":{"type":"tap","target":"mobile.text('Get Started')"}}
69088
+ {"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
68521
69089
  {"verdict":"observe","action":{"type":"back"}}
68522
69090
  {"verdict":"observe","action":{"type":"relaunch"}}
68523
69091
  {"verdict":"observe","action":{"type":"reset"}} \u2190 clears app data + cold-relaunches: use when the app is in the WRONG STATE for the recorded flow (e.g. signed in when the test expects the cleared first-launch screens, stale onboarding, poisoned local state)
@@ -73656,6 +74224,7 @@ var require_auth_recipe = __commonJS({
73656
74224
  exports.computeRecipeHash = computeRecipeHash;
73657
74225
  exports.buildAuthRecipe = buildAuthRecipe;
73658
74226
  exports.authProofLocator = authProofLocator;
74227
+ exports.urlProofPredicateExpr = urlProofPredicateExpr;
73659
74228
  exports.renderAuthProofAssertion = renderAuthProofAssertion;
73660
74229
  exports.renderAuthFn = renderAuthFn;
73661
74230
  exports.absoluteRoute = absoluteRoute;
@@ -73710,6 +74279,9 @@ var require_auth_recipe = __commonJS({
73710
74279
  };
73711
74280
  }
73712
74281
  function authProofLocator(proof) {
74282
+ if (proof.kind === "url") {
74283
+ throw new Error("authProofLocator: url proof has no element locator; callers must branch on proof.kind");
74284
+ }
73713
74285
  if (proof.kind === "testid") {
73714
74286
  return `page.getByTestId(${JSON.stringify(proof.testId)})`;
73715
74287
  }
@@ -73718,7 +74290,14 @@ var require_auth_recipe = __commonJS({
73718
74290
  }
73719
74291
  return `page.getByRole(${JSON.stringify(proof.role)}).first()`;
73720
74292
  }
74293
+ function urlProofPredicateExpr(route2) {
74294
+ const r = JSON.stringify(route2);
74295
+ return `(u) => u.pathname === ${r} || u.pathname.startsWith(${r} + '/')`;
74296
+ }
73721
74297
  function renderAuthProofAssertion(proof) {
74298
+ if (proof.kind === "url") {
74299
+ return `await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });`;
74300
+ }
73722
74301
  return `await expect(${authProofLocator(proof)}).toBeVisible({ timeout: 15000 });`;
73723
74302
  }
73724
74303
  function normalizeCmd(cmd) {
@@ -73732,9 +74311,16 @@ var require_auth_recipe = __commonJS({
73732
74311
  lines.push(START_MARKER);
73733
74312
  lines.push(`async function __vqaLogin(page) {`);
73734
74313
  if (opts?.warmGuard) {
73735
- const warmLanding = recipe.redirectRoute ?? recipe.preNavRoute;
74314
+ const warmLanding = recipe.redirectRoute ?? (recipe.authProof.kind === "url" ? recipe.authProof.route : recipe.preNavRoute);
74315
+ const loginRoute = recipe.route ?? recipe.preNavRoute;
73736
74316
  lines.push(` await page.goto(${JSON.stringify(warmLanding)}, { waitUntil: 'domcontentloaded' }).catch(() => {});`);
73737
- lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
74317
+ if (recipe.authProof.kind === "url") {
74318
+ lines.push(` if ((${urlProofPredicateExpr(recipe.authProof.route)})(new URL(page.url()))) return;`);
74319
+ } else if (loginRoute) {
74320
+ lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false) && new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname) return;`);
74321
+ } else {
74322
+ lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
74323
+ }
73738
74324
  }
73739
74325
  lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' });`);
73740
74326
  for (const cmd of recipe.preNavCmds)
@@ -73783,6 +74369,11 @@ var require_auth_recipe = __commonJS({
73783
74369
  }`;
73784
74370
  }
73785
74371
  function renderReplaySettleArrow(proof) {
74372
+ if (proof.kind === "url") {
74373
+ return `async (page) => {
74374
+ await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });
74375
+ }`;
74376
+ }
73786
74377
  return `async (page) => {
73787
74378
  await ${authProofLocator(proof)}.waitFor({ state: 'visible', timeout: 15000 });
73788
74379
  }`;
@@ -307873,7 +308464,7 @@ ${keyLines || "- (none provided)"}
307873
308464
  3. If the email/password fields are hidden behind a reveal button (e.g. "Sign in with Email", "Continue with Email"), click it first.
307874
308465
  4. Fill the credentials with \`fill_credential\` \u2014 ONE call per field (email first, then password), referencing the credential KEYS above. Never type real values with browser_type. (PREFER per-field \`fill_credential\`; \`fill_credential_form\` is less reliable and a misfire wastes a scarce iteration.)
307875
308466
  5. Submit the login form.
307876
- 6. Confirm you are authenticated WITHOUT opening any menu or dropdown: wait until you are signed in \u2014 this may be a REDIRECT to a new route, OR an in-place change on the SAME route (a login modal closing, the page swapping to authenticated content). Then take a \`browser_snapshot\`. The signed-in signal MUST be visible by DEFAULT on the resulting page \u2014 do NOT click an avatar / hamburger / account menu to hunt for a hidden "Logout" link, because the generated test asserts this proof WITHOUT opening any menu, so a menu-hidden control would fail. Any default-visible signal works: a Logout / Sign out control that is already shown, an account / avatar button, a persistent authenticated nav or sidebar item, or a stable post-login heading.
308467
+ 6. Confirm you are authenticated WITHOUT opening any menu or dropdown: wait until you are signed in \u2014 this may be a REDIRECT to a new route, OR an in-place change on the SAME route (a login modal closing, the page swapping to authenticated content). Then take a \`browser_snapshot\`. The signed-in signal MUST be visible by DEFAULT on the resulting page \u2014 do NOT click an avatar / hamburger / account menu to hunt for a hidden "Logout" link, because the generated test asserts this proof WITHOUT opening any menu, so a menu-hidden control would fail. PREFER a default-visible signal with a STABLE, identity-invariant label: a Logout / Sign out control that is already shown, a persistent authenticated nav or sidebar item (e.g. a Dashboard / Settings link), or a stable post-login heading. An avatar / account button whose ONLY label is the signed-in user's initials or name (e.g. "QT", "JS") is a WEAK signal \u2014 it changes per user/session, so do NOT rely on it; if that is the only signed-in signal on the page, still capture the page and finish (the system falls back to a durable route-based proof automatically).
307877
308468
  7. Call \`capture_page\` for the login route ONCE (so its successful proven commands are saved), then call \`capture_page\` for the authenticated landing page IN ITS DEFAULT STATE (do not open any menu first) so the durable, default-visible auth-proof element is recorded.
307878
308469
  8. The MOMENT an authenticated page is visible, your TOP PRIORITY is to \`capture_page\` that authenticated landing (step 7) and then call \`finish_exploration\` \u2014 do this IMMEDIATELY, before anything else, because your iteration budget is tight. Do NOT take extra browser_snapshots, do NOT explore features, do NOT click around the authenticated app, do NOT test validation or wrong-password flows. Capturing the authed landing + finishing is the whole job.
307879
308470
 
@@ -307933,7 +308524,11 @@ ${keyLines || "- (none provided)"}
307933
308524
  if (el.testId)
307934
308525
  testIdFreq.set(el.testId, (testIdFreq.get(el.testId) ?? 0) + 1);
307935
308526
  }
307936
- const isPersonalized = (name) => !name || GREETING_RE.test(name) || name.includes("@") || findCredentialLiteralLeaks(name, credentials).length > 0;
308527
+ const isInitialsLike = (name) => {
308528
+ const t = name.trim();
308529
+ return /^[A-Za-z]{1,3}$/.test(t) && t === t.toUpperCase();
308530
+ };
308531
+ const isPersonalized = (name) => !name || GREETING_RE.test(name) || name.includes("@") || findCredentialLiteralLeaks(name, credentials).length > 0 || isInitialsLike(name);
307937
308532
  const absentOnLogin = (roleNameKey, testId) => {
307938
308533
  if (testId && loginTestIds.has(testId))
307939
308534
  return false;
@@ -307968,7 +308563,12 @@ ${keyLines || "- (none provided)"}
307968
308563
  }
307969
308564
  }
307970
308565
  if (ranked.length === 0) {
307971
- logs2(" \u26A0 auth-proof: no durable, login-absent signed-in element found on the authenticated landing page \u2014 falling back to model-authored login (no blind navigation default)");
308566
+ const urlRoute = redirectRoute && redirectRoute !== "/" && !isLoginPage(redirectRoute) ? redirectRoute : landing.route !== "/" && !isLoginPage(landing.route) ? landing.route : null;
308567
+ if (urlRoute) {
308568
+ logs2(` \u2713 url auth-proof: authenticated landing route ${urlRoute} (no durable element proof on the default-state page)`);
308569
+ return { kind: "url", route: urlRoute };
308570
+ }
308571
+ logs2(" \u26A0 auth-proof: no durable element proof and no concrete authed landing route \u2014 falling back to model-authored login (no blind navigation default)");
307972
308572
  return null;
307973
308573
  }
307974
308574
  ranked.sort((a, b) => a.tier - b.tier);
@@ -308119,7 +308719,7 @@ ${keyLines || "- (none provided)"}
308119
308719
  logs2(" \u26A0 auth unverified (live-verify replay failed \u2014 fleet-config drift) \u2014 falling back to model-authored login");
308120
308720
  return null;
308121
308721
  }
308122
- const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : `testid:${authProof.testId}`;
308722
+ const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : authProof.kind === "testid" ? `testid:${authProof.testId}` : `url:${authProof.route}`;
308123
308723
  logs2(` \u2713 auth recipe verified (hash ${candidate.recipeHash}, proof ${proofDesc}, ${(candidate.replayPlan ?? []).length} replay steps) \u2014 stamping into @needs-auth tests`);
308124
308724
  return candidate;
308125
308725
  } catch (err) {
@@ -314812,13 +315412,13 @@ var require_mobile_discovery_agent = __commonJS({
314812
315412
  exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
314813
315413
  duration: duration2,
314814
315414
  mode,
314815
- ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
315415
+ ...paused ? { paused: true, pauseReason, pausePhase: "generate", remainingScenarios } : {}
314816
315416
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
314817
315417
  };
314818
315418
  }
314819
315419
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
314820
- const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
314821
- log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315420
+ const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
315421
+ 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.");
314822
315422
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
314823
315423
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
314824
315424
  const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
@@ -314853,6 +315453,32 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
314853
315453
  log2(`[stream] explore-complete callback failed: ${err instanceof Error ? err.message : String(err)}`);
314854
315454
  });
314855
315455
  }
315456
+ if (!isSurvey && shouldPauseAfterTruncatedExplore(explore)) {
315457
+ const duration2 = (Date.now() - startTime) / 1e3;
315458
+ const pauseReason = explore.truncatedReason === "timeout" ? "ai_timeout" : "ai_rate_limit";
315459
+ const summary2 = `Mobile discovery paused after exploration: mapped ${explore.exploreStats.pagesDiscovered} screen(s) before ${explore.truncatedReason ?? "AI"} interruption in ${duration2.toFixed(1)}s.`;
315460
+ log2(summary2);
315461
+ return {
315462
+ collectedPages: explore.collectedPages,
315463
+ collectedTransitions,
315464
+ tests: [],
315465
+ plans: [],
315466
+ logs: logs2.join("\n"),
315467
+ screenshots,
315468
+ pageScreenshots: explore.pageScreenshots,
315469
+ summary: summary2,
315470
+ warning: mergeWarning(void 0, explore),
315471
+ healthObservations: explore.healthObservations,
315472
+ exploreTruncated: true,
315473
+ exploreTruncatedReason: explore.truncatedReason,
315474
+ exploreStats: explore.exploreStats,
315475
+ duration: duration2,
315476
+ mode,
315477
+ paused: true,
315478
+ pauseReason,
315479
+ pausePhase: "explore"
315480
+ };
315481
+ }
314856
315482
  log2("\nRelaunching app for a clean state before planning/generation...");
314857
315483
  await driver.relaunch();
314858
315484
  log2("\n\u2500\u2500 Phase 2: PLAN \u2500\u2500");
@@ -315005,7 +315631,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
315005
315631
  // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
315006
315632
  // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
315007
315633
  // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
315008
- ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
315634
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, pausePhase: "generate", remainingScenarios: generateRemainingScenarios } : {}
315009
315635
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
315010
315636
  };
315011
315637
  } catch (fatalErr) {
@@ -315034,6 +315660,29 @@ Fatal error: ${msg}`);
315034
315660
  };
315035
315661
  }
315036
315662
  }
315663
+ async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
315664
+ void phase;
315665
+ const resetPolicy = target?.resetPolicy ?? "AUTO";
315666
+ if (resetPolicy === "PRESERVE_APP_DATA") {
315667
+ try {
315668
+ await driver.relaunch();
315669
+ } catch (err) {
315670
+ log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
315671
+ }
315672
+ return false;
315673
+ }
315674
+ const cleared = await driver.resetAppState?.().catch((err) => {
315675
+ log2(`App-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
315676
+ return false;
315677
+ }) ?? false;
315678
+ if (!cleared && resetPolicy === "CLEAR_APP_DATA") {
315679
+ log2("CLEAR_APP_DATA requested but app-data reset was unavailable; continuing without clearing app data.");
315680
+ }
315681
+ return cleared;
315682
+ }
315683
+ function shouldPauseAfterTruncatedExplore(explore) {
315684
+ return explore.truncated === true && (explore.truncatedReason === "rate_limit" || explore.truncatedReason === "timeout");
315685
+ }
315037
315686
  function mergeWarning(plannerWarning, explore) {
315038
315687
  if (!explore.truncated)
315039
315688
  return plannerWarning;
@@ -329535,7 +330184,7 @@ var require_package4 = __commonJS({
329535
330184
  "package.json"(exports, module) {
329536
330185
  module.exports = {
329537
330186
  name: "@validate.qa/runner",
329538
- version: "1.0.29",
330187
+ version: "1.0.31",
329539
330188
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329540
330189
  bin: {
329541
330190
  "validate-runner": "dist/cli.js"
@@ -329684,6 +330333,8 @@ var MAX_RETRIES = 3;
329684
330333
  var STARTUP_TIMEOUT_MS = 3e4;
329685
330334
  var HEALTH_CHECK_INTERVAL_MS = 5e3;
329686
330335
  var STABLE_AFTER_MS = 6e4;
330336
+ var DEFAULT_APPIUM_LOG_LEVEL = "warn";
330337
+ var MAX_APPIUM_LOG_LINE_CHARS = 4e3;
329687
330338
  var appiumProcess = null;
329688
330339
  var serverState = {
329689
330340
  running: false,
@@ -329743,6 +330394,23 @@ function appendLog(line) {
329743
330394
  logs = logs.slice(-MAX_LOG_LINES);
329744
330395
  }
329745
330396
  }
330397
+ function resolveAppiumLogLevel() {
330398
+ const raw = process.env.APPIUM_LOG_LEVEL?.trim();
330399
+ if (raw && /^[a-z]+(?::[a-z]+)?$/i.test(raw)) return raw;
330400
+ return DEFAULT_APPIUM_LOG_LEVEL;
330401
+ }
330402
+ function buildAppiumArgs(port, drivers) {
330403
+ const args = ["--port", String(port), "--log-level", resolveAppiumLogLevel()];
330404
+ if (drivers && drivers.length > 0) {
330405
+ args.push("--use-drivers", drivers.join(","));
330406
+ }
330407
+ return args;
330408
+ }
330409
+ function sanitizeAppiumLogLine(line) {
330410
+ 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]"');
330411
+ if (redacted.length <= MAX_APPIUM_LOG_LINE_CHARS) return redacted;
330412
+ return `${redacted.slice(0, MAX_APPIUM_LOG_LINE_CHARS)}\u2026 [truncated ${redacted.length - MAX_APPIUM_LOG_LINE_CHARS} chars]`;
330413
+ }
329746
330414
  async function checkAppiumHealth(port = serverState.port) {
329747
330415
  try {
329748
330416
  const controller = new AbortController();
@@ -329775,10 +330443,7 @@ async function startAppiumServer(options) {
329775
330443
  return serverState;
329776
330444
  }
329777
330445
  const port = options?.port ?? APPIUM_PORT;
329778
- const args = ["--port", String(port)];
329779
- if (options?.drivers && options.drivers.length > 0) {
329780
- args.push("--use-drivers", options.drivers.join(","));
329781
- }
330446
+ const args = buildAppiumArgs(port, options?.drivers);
329782
330447
  intentionalStop = false;
329783
330448
  lastStartOptions = { ...lastStartOptions, ...options };
329784
330449
  if (appiumGaveUp) {
@@ -329797,14 +330462,14 @@ async function startAppiumServer(options) {
329797
330462
  serverState.pid = appiumProcess.pid ?? null;
329798
330463
  serverState.port = port;
329799
330464
  appiumProcess.stdout?.on("data", (data) => {
329800
- const line = data.toString().trim();
330465
+ const line = sanitizeAppiumLogLine(data.toString().trim());
329801
330466
  if (line) {
329802
330467
  appendLog(`[appium:stdout] ${line}`);
329803
330468
  options?.onLog?.(line);
329804
330469
  }
329805
330470
  });
329806
330471
  appiumProcess.stderr?.on("data", (data) => {
329807
- const line = data.toString().trim();
330472
+ const line = sanitizeAppiumLogLine(data.toString().trim());
329808
330473
  if (line) {
329809
330474
  appendLog(`[appium:stderr] ${line}`);
329810
330475
  options?.onLog?.(line);
@@ -329963,6 +330628,17 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
329963
330628
  // src/services/mobile/appium-actions.ts
329964
330629
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
329965
330630
  var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
330631
+ 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;
330632
+ var AppiumDriverDeadError = class extends Error {
330633
+ isAppiumDriverDead = true;
330634
+ constructor(message) {
330635
+ super(message);
330636
+ this.name = "AppiumDriverDeadError";
330637
+ }
330638
+ };
330639
+ function isAppiumDriverDeadError(err) {
330640
+ return err instanceof AppiumDriverDeadError || typeof err === "object" && err !== null && err.isAppiumDriverDead === true;
330641
+ }
329966
330642
  async function appiumRequest(path, options) {
329967
330643
  const url = `http://localhost:${getAppiumState().port}${path}`;
329968
330644
  const res = await fetch(url, {
@@ -329972,6 +330648,9 @@ async function appiumRequest(path, options) {
329972
330648
  });
329973
330649
  if (!res.ok) {
329974
330650
  const body = await res.text().catch(() => "");
330651
+ if (APPIUM_DRIVER_DEAD_RE.test(body)) {
330652
+ throw new AppiumDriverDeadError(`Appium driver session is not usable (${res.status}): ${body.slice(0, 200)}`);
330653
+ }
329975
330654
  throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
329976
330655
  }
329977
330656
  return res.json();
@@ -329986,7 +330665,8 @@ async function getScreenshot(sessionPath) {
329986
330665
  try {
329987
330666
  const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
329988
330667
  return typeof value === "string" ? value : null;
329989
- } catch {
330668
+ } catch (err) {
330669
+ if (isAppiumDriverDeadError(err)) throw err;
329990
330670
  return null;
329991
330671
  }
329992
330672
  }
@@ -329994,7 +330674,8 @@ async function getPageSource(sessionPath) {
329994
330674
  try {
329995
330675
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
329996
330676
  return typeof value === "string" ? value : null;
329997
- } catch {
330677
+ } catch (err) {
330678
+ if (isAppiumDriverDeadError(err)) throw err;
329998
330679
  return null;
329999
330680
  }
330000
330681
  }
@@ -330010,7 +330691,8 @@ async function getWindowSize(sessionPath) {
330010
330691
  return { width: w, height: h };
330011
330692
  }
330012
330693
  }
330013
- } catch {
330694
+ } catch (err) {
330695
+ if (isAppiumDriverDeadError(err)) throw err;
330014
330696
  }
330015
330697
  if (attempt < retries - 1) {
330016
330698
  await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
@@ -330677,6 +331359,15 @@ var MitmProxy = class {
330677
331359
  }
330678
331360
  return this.tlsFailureReason;
330679
331361
  }
331362
+ get passthroughCount() {
331363
+ return this.tlsPassthroughCount;
331364
+ }
331365
+ get capturedCallCounts() {
331366
+ return {
331367
+ callCount: this.calls.length,
331368
+ apiCallCount: this.calls.filter((call) => (0, import_runner_core7.isApiCall)(call.url, call.responseContentType)).length
331369
+ };
331370
+ }
330680
331371
  async close() {
330681
331372
  const server3 = this.server;
330682
331373
  this.server = null;
@@ -331263,6 +331954,35 @@ async function startMobileNetworkCapture(options) {
331263
331954
  const reason = proxy.interceptionReason ?? wiringReason;
331264
331955
  return reason ? { intercepting: false, reason } : { intercepting: false };
331265
331956
  };
331957
+ const health = () => {
331958
+ const capabilities = getCapabilities();
331959
+ const counts = stopped ? {
331960
+ callCount: finalCalls.length,
331961
+ apiCallCount: finalCalls.filter((call) => call.isApi).length
331962
+ } : proxy.capturedCallCounts;
331963
+ const warnings = [];
331964
+ if (!capabilities.intercepting && capabilities.reason) {
331965
+ warnings.push(capabilities.reason);
331966
+ }
331967
+ if (stopped && counts.callCount === 0) {
331968
+ warnings.push("No mobile network calls were captured. The app may not have generated traffic, the device proxy may be blocked, or the app may bypass system proxy settings.");
331969
+ }
331970
+ return {
331971
+ enabled: true,
331972
+ active: !stopped,
331973
+ platform: platform3,
331974
+ deviceId,
331975
+ appId: options.appId,
331976
+ proxyHost,
331977
+ proxyPort,
331978
+ intercepting: capabilities.intercepting,
331979
+ ...capabilities.reason ? { reason: capabilities.reason } : {},
331980
+ callCount: counts.callCount,
331981
+ apiCallCount: counts.apiCallCount,
331982
+ tlsPassthroughCount: proxy.passthroughCount,
331983
+ warnings
331984
+ };
331985
+ };
331266
331986
  const stop = async () => {
331267
331987
  if (stopped) return finalCalls;
331268
331988
  stopped = true;
@@ -331301,6 +332021,7 @@ async function startMobileNetworkCapture(options) {
331301
332021
  proxyHost,
331302
332022
  caCertPath: capturedCaCertPath,
331303
332023
  getCapabilities,
332024
+ health,
331304
332025
  stop
331305
332026
  };
331306
332027
  }
@@ -331315,6 +332036,7 @@ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
331315
332036
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
331316
332037
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
331317
332038
  var WD_ACTION_TIMEOUT_MS = 3e4;
332039
+ var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
331318
332040
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
331319
332041
  var execFileAsync2 = promisify2(execFile2);
331320
332042
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
@@ -331351,6 +332073,9 @@ async function wdRequest(path, options) {
331351
332073
  if (res.status === 404 && /no such session|invalid session id|session is either terminated|deleted session/i.test(body)) {
331352
332074
  throw new WebDriverTransportError(`WebDriver session is no longer available: ${body.slice(0, 200)}`);
331353
332075
  }
332076
+ if (res.status === 500 && APPIUM_DRIVER_DEAD_RE2.test(body)) {
332077
+ throw new WebDriverTransportError(`WebDriver driver process is not usable: ${body.slice(0, 200)}`);
332078
+ }
331354
332079
  if (res.status === 502 || res.status === 503 || res.status === 504) {
331355
332080
  throw new WebDriverTransportError(`Appium server unavailable (${res.status}): ${body.slice(0, 200)}`);
331356
332081
  }
@@ -331781,6 +332506,32 @@ function readStepTimeout(step) {
331781
332506
  }
331782
332507
  return void 0;
331783
332508
  }
332509
+ async function revealLikelyOffscreenElement(sessionId) {
332510
+ const windowSize = await getWindowSize2(sessionId);
332511
+ const { startX, startY, endX, endY } = computeMobileSwipePointers(
332512
+ windowSize.width,
332513
+ windowSize.height,
332514
+ "up",
332515
+ clampMobileSwipeDistance(void 0)
332516
+ );
332517
+ await wdRequest(`/session/${sessionId}/actions`, {
332518
+ method: "POST",
332519
+ body: JSON.stringify({
332520
+ actions: [{
332521
+ type: "pointer",
332522
+ id: "finger-reveal",
332523
+ parameters: { pointerType: "touch" },
332524
+ actions: [
332525
+ { type: "pointerMove", duration: 0, x: startX, y: startY },
332526
+ { type: "pointerDown", button: 0 },
332527
+ { type: "pointerMove", duration: 300, x: endX, y: endY },
332528
+ { type: "pointerUp", button: 0 }
332529
+ ]
332530
+ }]
332531
+ })
332532
+ });
332533
+ await new Promise((resolve) => setTimeout(resolve, 250));
332534
+ }
331784
332535
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
331785
332536
  const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
331786
332537
  if (strategies.length === 0) {
@@ -331790,6 +332541,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
331790
332541
  }
331791
332542
  const startTime = Date.now();
331792
332543
  let lastError = "";
332544
+ let revealAttempted = false;
331793
332545
  while (Date.now() - startTime < timeoutMs) {
331794
332546
  for (const strategy of strategies) {
331795
332547
  try {
@@ -331804,9 +332556,18 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
331804
332556
  lastError = err instanceof Error ? err.message : String(err);
331805
332557
  }
331806
332558
  }
332559
+ if (!opts?.identityOnly && !revealAttempted && Date.now() - startTime > Math.min(1500, timeoutMs / 3)) {
332560
+ revealAttempted = true;
332561
+ try {
332562
+ await revealLikelyOffscreenElement(sessionId);
332563
+ } catch (err) {
332564
+ if (isTransportError(err)) throw err;
332565
+ lastError = `reveal scroll failed: ${err instanceof Error ? err.message : String(err)}`;
332566
+ }
332567
+ }
331807
332568
  await new Promise((resolve) => setTimeout(resolve, 500));
331808
332569
  }
331809
- throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies. Last error: ${lastError}`);
332570
+ throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
331810
332571
  }
331811
332572
  async function tapCoordinates(sessionId, bounds) {
331812
332573
  const tapX = Math.round(bounds.x + bounds.width / 2);
@@ -332230,21 +332991,29 @@ async function executeMobileTest(options) {
332230
332991
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
332231
332992
  const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
332232
332993
  const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
332233
- if (recordedFromClearedState) {
332994
+ const resetPolicy = options.target.resetPolicy ?? "AUTO";
332995
+ const shouldClearAppData = resetPolicy === "CLEAR_APP_DATA" || resetPolicy === "AUTO" && recordedFromClearedState;
332996
+ let clearedAppDataBeforeLaunch = false;
332997
+ if (shouldClearAppData) {
332234
332998
  if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
332235
332999
  try {
332236
333000
  await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
332237
- log2("App data cleared before launch (test was recorded from a cold logged-out state).");
333001
+ clearedAppDataBeforeLaunch = true;
333002
+ log2(resetPolicy === "CLEAR_APP_DATA" ? "App data cleared before launch (target resetPolicy=CLEAR_APP_DATA)." : "App data cleared before launch (test was recorded from a cold logged-out state).");
332238
333003
  } catch (err) {
332239
333004
  log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
332240
333005
  }
333006
+ } else if (resetPolicy === "CLEAR_APP_DATA") {
333007
+ log2("target.resetPolicy=CLEAR_APP_DATA requested, but app-data reset is disabled by device policy (physical device / VALIDATEQA_MOBILE_APP_RESET=never) \u2014 replaying against preserved data.");
332241
333008
  } else {
332242
333009
  log2("Test expects a cleared app state, but app-data reset is disabled by policy (physical device / VALIDATEQA_MOBILE_APP_RESET=never) \u2014 replaying against the current app state.");
332243
333010
  }
333011
+ } else if (recordedFromClearedState && resetPolicy === "PRESERVE_APP_DATA") {
333012
+ log2("Test was recorded from a cleared state, but target.resetPolicy=PRESERVE_APP_DATA \u2014 replaying against preserved app data.");
332244
333013
  }
332245
333014
  log2("Opening a fresh app instance for the test...");
332246
333015
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
332247
- if (recordedFromClearedState) {
333016
+ if (clearedAppDataBeforeLaunch) {
332248
333017
  await waitForColdBootContent(sessionId, log2);
332249
333018
  }
332250
333019
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
@@ -334490,8 +335259,9 @@ function envInt(name, fallback) {
334490
335259
  var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
334491
335260
  var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
334492
335261
  function scrubAuditMessages(messages, testVariables) {
335262
+ if (messages === void 0 || messages === null) return messages;
334493
335263
  const compacted = compactForAudit(messages);
334494
- const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
335264
+ const secretValues = Object.values(testVariables ?? {}).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
334495
335265
  if (secretValues.length === 0) return compacted;
334496
335266
  let json = JSON.stringify(compacted);
334497
335267
  if (typeof json !== "string") return compacted;
@@ -334806,6 +335576,9 @@ var AUDIT_FLUSH_COUNT = 10;
334806
335576
  var AUDIT_FLUSH_BYTES = 2 * 1024 * 1024;
334807
335577
  var AUDIT_DRAIN_ATTEMPTS = 3;
334808
335578
  var AUDIT_DRAIN_DELAY_MS = 1e3;
335579
+ var AUDIT_POST_MAX_BYTES = 4 * 1024 * 1024;
335580
+ var AUDIT_POST_MAX_ENTRIES = 50;
335581
+ var AUDIT_QUEUE_MAX_BYTES = 32 * 1024 * 1024;
334809
335582
  var AUDIT_BODY_MAX_BYTES = 1024 * 1024;
334810
335583
  var AUDIT_MAX_STRING_CHARS = 5e4;
334811
335584
  var AUDIT_MAX_ARRAY_ITEMS = 50;
@@ -334856,7 +335629,7 @@ function compactForAudit(value, depth = 0, seen = /* @__PURE__ */ new WeakSet())
334856
335629
  }
334857
335630
  function scrubAuditText(text, testVariables) {
334858
335631
  let out = text;
334859
- for (const [key, value] of Object.entries(testVariables).sort((a, b) => b[1].length - a[1].length)) {
335632
+ for (const [key, value] of Object.entries(testVariables ?? {}).sort((a, b) => b[1].length - a[1].length)) {
334860
335633
  if (!value || value.length < 3) continue;
334861
335634
  const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
334862
335635
  const replacement = SECRET_VAR_PATTERN.test(key) ? "[REDACTED]" : `[TEST_VARIABLE:${key}]`;
@@ -334975,6 +335748,9 @@ function truncateAuditRequestBody(body) {
334975
335748
  if (byteLen <= AUDIT_BODY_MAX_BYTES) return compacted;
334976
335749
  return { _truncated: true, _originalLength: byteLen, preview: serialized.slice(0, 1e3) };
334977
335750
  }
335751
+ function isTerminalAuditStatus(status) {
335752
+ return status === 400 || status === 404 || status === 409 || status === 413;
335753
+ }
334978
335754
  function createAuditFlusher(url, headers) {
334979
335755
  const entries = [];
334980
335756
  let byteEstimate = 0;
@@ -334982,26 +335758,41 @@ function createAuditFlusher(url, headers) {
334982
335758
  const doFlush = () => {
334983
335759
  if (entries.length === 0) return Promise.resolve();
334984
335760
  if (flushInFlight) return flushInFlight;
334985
- const toSend = entries.splice(0);
334986
- const savedBytes = byteEstimate;
334987
- byteEstimate = 0;
334988
335761
  const attempt = async () => {
334989
335762
  try {
334990
- const res = await fetch(url, {
334991
- method: "POST",
334992
- headers: { ...headers, "Content-Type": "application/json" },
334993
- body: JSON.stringify({ entries: toSend }),
334994
- signal: AbortSignal.timeout(3e4)
334995
- });
334996
- if (!res.ok) {
334997
- entries.unshift(...toSend);
334998
- byteEstimate += savedBytes;
334999
- console.warn(`[audit-flush] HTTP ${res.status} - ${toSend.length} entries requeued`);
335763
+ while (entries.length > 0) {
335764
+ const toSend = [];
335765
+ let sendBytes = 0;
335766
+ while (entries.length > 0 && toSend.length < AUDIT_POST_MAX_ENTRIES) {
335767
+ const next = entries[0];
335768
+ if (toSend.length > 0 && sendBytes + next.bytes > AUDIT_POST_MAX_BYTES) break;
335769
+ toSend.push(entries.shift());
335770
+ sendBytes += next.bytes;
335771
+ }
335772
+ byteEstimate = Math.max(0, byteEstimate - sendBytes);
335773
+ try {
335774
+ const res = await fetch(url, {
335775
+ method: "POST",
335776
+ headers: { ...headers, "Content-Type": "application/json" },
335777
+ body: JSON.stringify({ entries: toSend.map((e) => e.entry) }),
335778
+ signal: AbortSignal.timeout(3e4)
335779
+ });
335780
+ if (res.ok) continue;
335781
+ if (isTerminalAuditStatus(res.status)) {
335782
+ console.warn(`[audit-flush] HTTP ${res.status} (terminal) - dropping ${toSend.length} audit entries`);
335783
+ continue;
335784
+ }
335785
+ entries.unshift(...toSend);
335786
+ byteEstimate += sendBytes;
335787
+ console.warn(`[audit-flush] HTTP ${res.status} - ${toSend.length} entries requeued`);
335788
+ return;
335789
+ } catch (err) {
335790
+ entries.unshift(...toSend);
335791
+ byteEstimate += sendBytes;
335792
+ console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
335793
+ return;
335794
+ }
335000
335795
  }
335001
- } catch (err) {
335002
- entries.unshift(...toSend);
335003
- byteEstimate += savedBytes;
335004
- console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
335005
335796
  } finally {
335006
335797
  flushInFlight = null;
335007
335798
  }
@@ -335017,8 +335808,18 @@ function createAuditFlusher(url, headers) {
335017
335808
  if ("responseBody" in safeEntry && safeEntry.responseBody != null) {
335018
335809
  safeEntry = { ...safeEntry, responseBody: truncateAuditRequestBody(safeEntry.responseBody) };
335019
335810
  }
335020
- entries.push(safeEntry);
335021
- byteEstimate += safeJsonByteLength(safeEntry);
335811
+ const bytes = safeJsonByteLength(safeEntry);
335812
+ entries.push({ entry: safeEntry, bytes });
335813
+ byteEstimate += bytes;
335814
+ if (byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
335815
+ let droppedCount = 0;
335816
+ while (entries.length > 1 && byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
335817
+ const dropped = entries.shift();
335818
+ byteEstimate -= dropped.bytes;
335819
+ droppedCount++;
335820
+ }
335821
+ console.warn(`[audit-flush] queue over ${AUDIT_QUEUE_MAX_BYTES} bytes - dropped ${droppedCount} oldest audit entries`);
335822
+ }
335022
335823
  if (entries.length >= AUDIT_FLUSH_COUNT || byteEstimate >= AUDIT_FLUSH_BYTES) {
335023
335824
  void doFlush();
335024
335825
  }
@@ -335862,6 +336663,7 @@ ${finalVerifyResult.logs ?? ""}`;
335862
336663
  platformVersion: harvestTarget.platformVersion,
335863
336664
  recordedDeviceId: harvestTarget.recordedDeviceId,
335864
336665
  assignedDeviceId,
336666
+ resetPolicy: harvestTarget.resetPolicy,
335865
336667
  launchMode: harvestTarget.launchMode,
335866
336668
  deeplink: harvestTarget.deeplink
335867
336669
  },
@@ -336138,6 +336940,7 @@ ${finalVerifyResult.logs ?? ""}`;
336138
336940
  // local-runner projects. Mirrors the Fargate host forward in execute-run.ts.
336139
336941
  paused: rest.paused,
336140
336942
  pauseReason: rest.pauseReason,
336943
+ pausePhase: rest.pausePhase,
336141
336944
  remainingScenarios: rest.remainingScenarios,
336142
336945
  // Operator/UX signals the server persists to DiscoveryRun (Fargate parity).
336143
336946
  coverageGaps: rest.coverageGaps,
@@ -336230,7 +337033,14 @@ ${finalVerifyResult.logs ?? ""}`;
336230
337033
  if (getBridgeState().hasActiveSession) {
336231
337034
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
336232
337035
  }
336233
- const mobileOnLog = (line) => onLog("progress", line);
337036
+ const mobileProgressLines = [];
337037
+ const mobileOnLog = (line) => {
337038
+ mobileProgressLines.push(line);
337039
+ if (mobileProgressLines.length > 500) {
337040
+ mobileProgressLines.splice(0, mobileProgressLines.length - 500);
337041
+ }
337042
+ onLog("progress", mobileProgressLines.join("\n"));
337043
+ };
336234
337044
  liveBrowserHandle.patch({
336235
337045
  mode: ctx.mode === "survey" ? "survey" : "discovery",
336236
337046
  testName: ctx.featureName ?? "Mobile discovery",
@@ -336251,6 +337061,7 @@ ${finalVerifyResult.logs ?? ""}`;
336251
337061
  platformVersion: mobileTarget.platformVersion,
336252
337062
  recordedDeviceId: mobileTarget.recordedDeviceId,
336253
337063
  assignedDeviceId,
337064
+ resetPolicy: mobileTarget.resetPolicy,
336254
337065
  launchMode: mobileTarget.launchMode,
336255
337066
  deeplink: mobileTarget.deeplink
336256
337067
  }, {
@@ -336282,7 +337093,7 @@ ${finalVerifyResult.logs ?? ""}`;
336282
337093
  // Deterministic-state authorization: Android emulators only by
336283
337094
  // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
336284
337095
  // physical device must never have its app data wiped.
336285
- allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
337096
+ allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId)
336286
337097
  });
336287
337098
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
336288
337099
  onLog: mobileOnLog,
@@ -336314,8 +337125,13 @@ ${finalVerifyResult.logs ?? ""}`;
336314
337125
  if (activeCapture) {
336315
337126
  try {
336316
337127
  const apiCalls = await activeCapture.stop();
337128
+ const captureHealth = activeCapture.health();
336317
337129
  if (discoveryResult) {
336318
337130
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
337131
+ const priorHealth = discoveryResult.healthObservations;
337132
+ const healthObject = priorHealth && typeof priorHealth === "object" && !Array.isArray(priorHealth) ? { ...priorHealth } : {};
337133
+ healthObject.mobileNetworkCapture = captureHealth;
337134
+ discoveryResult.healthObservations = healthObject;
336319
337135
  }
336320
337136
  } catch (capErr) {
336321
337137
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
@@ -336632,6 +337448,7 @@ ${finalVerifyResult.logs ?? ""}`;
336632
337448
  platformVersion: target.platformVersion,
336633
337449
  recordedDeviceId: target.recordedDeviceId,
336634
337450
  assignedDeviceId,
337451
+ resetPolicy: target.resetPolicy,
336635
337452
  launchMode: target.launchMode,
336636
337453
  deeplink: target.deeplink,
336637
337454
  authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
@@ -336646,6 +337463,7 @@ ${finalVerifyResult.logs ?? ""}`;
336646
337463
  platformVersion: t.platformVersion,
336647
337464
  recordedDeviceId: t.recordedDeviceId,
336648
337465
  assignedDeviceId: t.assignedDeviceId,
337466
+ resetPolicy: t.resetPolicy,
336649
337467
  launchMode: narrowMobileLaunchMode(t.launchMode),
336650
337468
  deeplink: t.deeplink,
336651
337469
  authoringPlatform: t.authoringPlatform
@@ -336708,6 +337526,7 @@ ${finalVerifyResult.logs ?? ""}`;
336708
337526
  platformVersion: target.platformVersion,
336709
337527
  recordedDeviceId: target.recordedDeviceId,
336710
337528
  assignedDeviceId,
337529
+ resetPolicy: target.resetPolicy,
336711
337530
  launchMode: target.launchMode,
336712
337531
  deeplink: target.deeplink
336713
337532
  });
@@ -336716,7 +337535,7 @@ ${finalVerifyResult.logs ?? ""}`;
336716
337535
  sessionId: liveSession.sessionId,
336717
337536
  platform: target.platform,
336718
337537
  appId: liveSession.appId,
336719
- allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
337538
+ allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, liveSession.deviceId)
336720
337539
  });
336721
337540
  const liveDriver = {
336722
337541
  snapshot: async () => {
@@ -336744,7 +337563,7 @@ ${finalVerifyResult.logs ?? ""}`;
336744
337563
  relaunch: async () => {
336745
337564
  await bridgeDriver.relaunch();
336746
337565
  },
336747
- resetAppState: async () => bridgeDriver.resetAppState()
337566
+ resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : bridgeDriver.resetAppState()
336748
337567
  };
336749
337568
  return await (0, import_runner_core13.executeMobileLiveHeal)({
336750
337569
  steps: a.steps,
@@ -336845,6 +337664,7 @@ ${finalVerifyResult.logs ?? ""}`;
336845
337664
  platformVersion: target.platformVersion,
336846
337665
  recordedDeviceId: target.recordedDeviceId,
336847
337666
  assignedDeviceId,
337667
+ resetPolicy: target.resetPolicy,
336848
337668
  launchMode: target.launchMode,
336849
337669
  deeplink: target.deeplink,
336850
337670
  // Authoring platform (test's session medium). When it differs from