@validate.qa/runner 1.0.28 → 1.0.30

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 +588 -98
  2. package/dist/cli.mjs +588 -98
  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"() {
@@ -2297,12 +2413,12 @@ var init_plans = __esm({
2297
2413
  billingModel: "free",
2298
2414
  summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with enough headroom for one real project.",
2299
2415
  maxProjects: 1,
2300
- maxTestsPerProject: 10,
2416
+ maxTestsPerProject: 100,
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: 50,
2419
+ // 25 regression / 25 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,7 +2496,7 @@ 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
2501
  discovery_run: 50,
2386
2502
  // $0.50
@@ -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. Use a guest/demo/skip entry (e.g. "See Demo Profile", "Continue as guest") ONLY as a fallback when no credentials are available or login cannot be completed \u2014 demo mode is a limited sandbox, not the authenticated app. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.`);
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
- PREFER real login over any "demo"/"guest"/"skip" entry: those give a limited sandbox, NOT the authenticated app. Use them only if login is impossible.
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.
@@ -65783,7 +65901,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65783
65901
  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
65902
 
65785
65903
  ## 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.
65904
+ 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
65905
  2. **One purpose per scenario** - each scenario tests ONE thing (happy path, validation, journey, or bug)
65788
65906
  3. **Happy paths first** - then pair edge cases to the same form/flow
65789
65907
  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 +65910,23 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65792
65910
  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
65911
  8. **Auth-aware** - mark scenarios that need a signed-in session. Use process.env.TEST_USER_EMAIL / PASSWORD
65794
65912
  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:
65913
+ 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.
65914
+ 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.
65915
+ 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
65916
  - The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
65799
65917
  - 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
65918
  - 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
65919
  - 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
65920
  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\`.
65921
+ Example - BAD happy path: \`["Launch app", "Tap Create", "Verify sheet opens"]\` \u2192 only tests the trigger.
65922
+ 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"]\`
65923
+ 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"]\`
65924
+ 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.
65925
+ 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.
65926
+ 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>."
65927
+ 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.
65928
+ 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.
65929
+ 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
65930
 
65813
65931
  **Before declaring a prereq as anything other than \`self_owned\`, attempt these IN ORDER:**
65814
65932
 
@@ -65831,7 +65949,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65831
65949
  - \`clean_initial_state\`: never use.
65832
65950
 
65833
65951
  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.
65952
+ 19. **Planner emits design, not implementation.** Live-generate handles execution against a real device snapshot.
65835
65953
 
65836
65954
  **YOUR responsibility:**
65837
65955
  - Scenario \`name\`, \`type\`, \`goal\`, \`prerequisites\`
@@ -65857,10 +65975,10 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65857
65975
  Citing observed labels / accessibility-ids in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
65858
65976
 
65859
65977
  ## 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)
65978
+ - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
65979
+ - 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)
65862
65980
  - 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"
65981
+ - 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
65982
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
65865
65983
 
65866
65984
  ## Output JSON Schema
@@ -65882,7 +66000,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65882
66000
  "featureGroup": "Authentication",
65883
66001
  "goal": "User can sign in with valid credentials and reach the home screen",
65884
66002
  "steps": [
65885
- { "action": "Relaunch the app and tap Sign In to reach the sign-in screen" },
66003
+ { "action": "Launch the app and tap Sign In to reach the sign-in screen" },
65886
66004
  { "action": "Type test credentials into the email field" },
65887
66005
  { "action": "Type test credentials into the password field" },
65888
66006
  { "action": "Tap the Sign In button" },
@@ -65955,6 +66073,59 @@ Each entry is exactly one of:
65955
66073
  function screenDisplayName(state2) {
65956
66074
  return state2.screenName?.trim() || state2.screenId;
65957
66075
  }
66076
+ function isOpaqueMobileScreenName(value, appId) {
66077
+ const trimmed = value.trim();
66078
+ if (!trimmed)
66079
+ return false;
66080
+ if (appId && (trimmed === appId || trimmed.startsWith(`${appId}#`) || trimmed.startsWith(`${appId}.`))) {
66081
+ return true;
66082
+ }
66083
+ return /^[A-Za-z][\w$]*(?:\.[A-Za-z][\w$]*)+(?:#[A-Za-z0-9_-]+)?$/.test(trimmed);
66084
+ }
66085
+ function humanizeToken(token) {
66086
+ const spaced = token.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
66087
+ if (!spaced)
66088
+ return "";
66089
+ return spaced.split(/\s+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
66090
+ }
66091
+ function mobileAppDisplayName(appId) {
66092
+ const parts = (appId ?? "").split(".").map((part) => part.trim()).filter(Boolean).filter((part) => !/^(com|org|net|io|app|mobile|android|ios)$/i.test(part));
66093
+ const token = parts.length > 0 ? parts[parts.length - 1] : "";
66094
+ const human = humanizeToken(token);
66095
+ return human || "Mobile App";
66096
+ }
66097
+ function nonGenericScreenName(state2, appId) {
66098
+ const raw = state2.screenName?.trim();
66099
+ if (!raw || isOpaqueMobileScreenName(raw, appId))
66100
+ return null;
66101
+ const human = humanizeToken(raw.replace(/(?:Activity|ViewController|Fragment|Screen|Page)$/i, ""));
66102
+ if (!human || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human))
66103
+ return null;
66104
+ return human;
66105
+ }
66106
+ function inferMobileFeatureGroupName(states, appId) {
66107
+ const usable = states.filter((state2) => state2.screenType?.toLowerCase() !== "error");
66108
+ const screenTypes = usable.map((state2) => state2.screenType?.toLowerCase()).filter(Boolean);
66109
+ if (screenTypes.length > 0 && screenTypes.every((type) => type === "auth"))
66110
+ return "Authentication";
66111
+ if (screenTypes.length > 0 && screenTypes.every((type) => type === "onboarding" || type === "landing" || type === "splash")) {
66112
+ return "Onboarding";
66113
+ }
66114
+ const named = usable.map((state2) => nonGenericScreenName(state2, appId)).find((name) => Boolean(name));
66115
+ return named ?? `${mobileAppDisplayName(appId)} App`;
66116
+ }
66117
+ function mobileFeatureCanonicalKey(name) {
66118
+ const normalized = name.trim().toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
66119
+ if (!normalized)
66120
+ return "mobile-app";
66121
+ if (/(^|-)(auth|authentication|login|log-in|signin|sign-in|signup|sign-up)($|-)/.test(normalized)) {
66122
+ return "authentication";
66123
+ }
66124
+ if (/(^|-)(onboarding|welcome|landing|splash|intro|walkthrough)($|-)/.test(normalized)) {
66125
+ return "onboarding";
66126
+ }
66127
+ return normalized.replace(/-(app|screen|screens|flow|feature)$/g, "") || normalized;
66128
+ }
65958
66129
  function formatMobileEvidenceForPlanner(graph, context = {}) {
65959
66130
  const sections = [];
65960
66131
  if (context.appBrief) {
@@ -66146,9 +66317,10 @@ ${constraints.join("\n")}`);
66146
66317
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66147
66318
  for (const state2 of orderedScreens) {
66148
66319
  const screenTypeLower = state2.screenType?.toLowerCase();
66149
- const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : screenDisplayName(state2);
66320
+ const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : inferMobileFeatureGroupName([state2], appId);
66150
66321
  const group = groups.get(groupName) ?? {
66151
66322
  name: groupName,
66323
+ canonicalKey: mobileFeatureCanonicalKey(groupName),
66152
66324
  description: `Native mobile screens related to ${groupName}.`,
66153
66325
  routes: [],
66154
66326
  criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
@@ -66174,7 +66346,7 @@ ${constraints.join("\n")}`);
66174
66346
  const to = graph.screens.get(transition.toScreenId);
66175
66347
  if (!from || !to)
66176
66348
  continue;
66177
- const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
66349
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : inferMobileFeatureGroupName([from], appId);
66178
66350
  const group = groups.get(groupName);
66179
66351
  if (!group)
66180
66352
  continue;
@@ -66198,7 +66370,7 @@ ${constraints.join("\n")}`);
66198
66370
  for (const state2 of orderedScreens) {
66199
66371
  if (scenarios.length >= budget)
66200
66372
  break;
66201
- const screenName = screenDisplayName(state2);
66373
+ const screenName = nonGenericScreenName(state2, input.appId) ?? inferMobileFeatureGroupName([state2], input.appId);
66202
66374
  const baseName = `${screenName} - Screen Reachability`;
66203
66375
  const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
66204
66376
  if (usedNames.has(name.toLowerCase()))
@@ -66206,7 +66378,7 @@ ${constraints.join("\n")}`);
66206
66378
  usedNames.add(name.toLowerCase());
66207
66379
  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
66380
  const steps = [
66209
- { action: "Relaunch the app from a clean state", target: entryScreenId },
66381
+ { action: "Launch the app and start from the current launch entry", target: entryScreenId },
66210
66382
  {
66211
66383
  action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
66212
66384
  target: state2.screenId
@@ -66257,16 +66429,27 @@ ${constraints.join("\n")}`);
66257
66429
  }
66258
66430
  return filtered;
66259
66431
  }
66260
- function filterFeatureGroupsToSiteMap(groups, siteMap) {
66432
+ function filterFeatureGroupsToSiteMap(groups, siteMap, graph, appId) {
66261
66433
  if (groups.length === 0)
66262
66434
  return [];
66263
66435
  const validRoutes = new Set(siteMap.pages.map((page) => page.route));
66436
+ const usedNames = /* @__PURE__ */ new Map();
66264
66437
  return groups.flatMap((group) => {
66265
66438
  const routes = group.routes.filter((route2) => validRoutes.has(route2));
66266
66439
  if (routes.length === 0)
66267
66440
  return [];
66441
+ const routeStates = routes.map((route2) => graph?.screens.get(route2)).filter((state2) => Boolean(state2));
66442
+ const rawName = group.name?.trim() || "";
66443
+ const inferredName = rawName && !isOpaqueMobileScreenName(rawName, appId) ? rawName : inferMobileFeatureGroupName(routeStates, appId);
66444
+ const count = usedNames.get(inferredName) ?? 0;
66445
+ usedNames.set(inferredName, count + 1);
66446
+ const name = count === 0 ? inferredName : `${inferredName} ${count + 1}`;
66447
+ const canonicalKey = group.canonicalKey?.trim() || mobileFeatureCanonicalKey(inferredName);
66268
66448
  return [{
66269
66449
  ...group,
66450
+ name,
66451
+ canonicalKey,
66452
+ description: group.description?.trim() || `Native mobile screens related to ${name}.`,
66270
66453
  routes,
66271
66454
  pages: group.pages?.filter((page) => validRoutes.has(page.route))
66272
66455
  }];
@@ -66318,7 +66501,7 @@ ${constraints.join("\n")}`);
66318
66501
  incremental: input.incremental
66319
66502
  })
66320
66503
  });
66321
- const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
66504
+ const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap, input.graph, input.appId);
66322
66505
  const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
66323
66506
  if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
66324
66507
  const fallbackScenarios = buildMobileFallbackScenarios(input);
@@ -66565,7 +66748,15 @@ var require_mobile_generator = __commonJS({
66565
66748
  return { screen, signal, refMap, observation };
66566
66749
  }
66567
66750
  function labelForElement(element) {
66568
- return nonEmpty2(element.label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? nonEmpty2(element.text) ?? "element";
66751
+ const editable = isEditableMobileElement(element);
66752
+ const label = nonEmpty2(element.label);
66753
+ const text = nonEmpty2(element.text);
66754
+ const labelIsEditableValue = editable && label !== void 0 && text !== void 0 && label === text;
66755
+ return (labelIsEditableValue ? void 0 : label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? (editable ? void 0 : text) ?? "element";
66756
+ }
66757
+ var EDITABLE_ELEMENT_ROLE_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
66758
+ function isEditableMobileElement(element) {
66759
+ return EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim());
66569
66760
  }
66570
66761
  function nonEmpty2(value) {
66571
66762
  if (typeof value !== "string")
@@ -66597,17 +66788,21 @@ var require_mobile_generator = __commonJS({
66597
66788
  ## Your job
66598
66789
  1. mobile_snapshot the current screen.
66599
66790
  2. Execute the scenario's steps using the real UI: tap buttons, fill fields (mobile_type), submit, navigate.
66600
- 3. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
66791
+ 3. COMPLETE THE ACTION the scenario is about \u2014 do not stop half-way. If the scenario submits a form (sign in, sign up, search, create), you MUST tap the submit/primary button; filling fields is NOT the behavior under test. If it navigates, you MUST land on the destination. A "happy path" that fills a login form but never taps "Sign In" is WRONG.
66792
+ 4. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
66601
66793
  - mobile_assert_visible({ref}) \u2014 the target screen's stable element is on screen.
66602
66794
  - mobile_assert_text({ref, expected}) \u2014 an element contains expected text (only durable when the element has a stable accessibility-id / resource-id).
66603
- 4. Call finish_generate with verified:true ONLY if you asserted the outcome with an assert tool.
66795
+ - Assert the REAL post-action outcome: after a sign-in, assert a signed-in signal (dashboard/feed content, account tab, "Sign out"); after an invalid submit, assert the error message; after navigation, assert an element unique to the destination screen. Do NOT assert an opaque internal id ("step-1", "step-2") as the outcome \u2014 pick a human-meaningful element.
66796
+ 5. Call finish_generate with verified:true ONLY if you asserted the outcome with an assert tool.
66604
66797
 
66605
66798
  ## Rules
66606
66799
  - Re-snapshot before every assertion so the ref is fresh.
66607
66800
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
66608
- - 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.
66801
+ - 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.
66609
66802
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
66610
66803
  - 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.
66804
+ - 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.
66805
+ - Do NOT re-do a sequence you already completed. If you filled a form then navigated away, do NOT come back and fill it again \u2014 the scenario needs ONE clean pass. Filling the login form, going back, and re-filling it is exactly the kind of wandering that produces a bloated, broken test.
66611
66806
  - Do ONLY the actions this scenario needs. Do NOT re-open a menu you already opened, re-select an option that is already selected, toggle a setting back and forth, or repeat a step that already succeeded \u2014 EVERY action you take is baked into the generated test verbatim, so each extra action becomes a permanent junk step.
66612
66807
  - Each action must make NEW progress toward the goal. If an action leaves the screen unchanged, do NOT repeat it \u2014 pick a different element or move on.
66613
66808
  - Finish promptly once the outcome is proven: the instant the expected outcome is visible, assert it and call finish_generate. Do not keep exploring after the goal is proven.
@@ -67175,6 +67370,13 @@ ${statePacket}` },
67175
67370
  const { resolveRef, screenName, platform: platform3, recordAction } = ctx;
67176
67371
  const element = resolveRef(toolArgs.ref);
67177
67372
  if (element) {
67373
+ const weakReason = weakAssertionTargetReason(element);
67374
+ if (weakReason) {
67375
+ return {
67376
+ ok: false,
67377
+ 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.`
67378
+ };
67379
+ }
67178
67380
  const metadata = metadataForElement(element, screenName(), platform3);
67179
67381
  recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
67180
67382
  return { ok: true, label: metadata.targetLabel ?? "element" };
@@ -67184,6 +67386,13 @@ ${statePacket}` },
67184
67386
  if (!accessibilityId && !text) {
67185
67387
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
67186
67388
  }
67389
+ const directTarget = accessibilityId ?? text;
67390
+ if (directTarget && isStructuralAssertionIdentity(directTarget)) {
67391
+ return {
67392
+ ok: false,
67393
+ error: `"${directTarget}" looks like a structural/internal container id, not a user-visible outcome. Assert a visible label, message, or stable product control instead.`
67394
+ };
67395
+ }
67187
67396
  const screen = screenName();
67188
67397
  if (accessibilityId) {
67189
67398
  const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
@@ -67207,7 +67416,7 @@ ${statePacket}` },
67207
67416
  if (!snapshot || snapshot.screen.elements.length === 0)
67208
67417
  return null;
67209
67418
  const elements = snapshot.screen.elements;
67210
- const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
67419
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
67211
67420
  stableAnchors.sort((a, b) => {
67212
67421
  const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
67213
67422
  return labelled2(a) - labelled2(b);
@@ -67220,8 +67429,8 @@ ${statePacket}` },
67220
67429
  return nonButton;
67221
67430
  if (stableAnchors.length > 0)
67222
67431
  return stableAnchors[0];
67223
- const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67224
- return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
67432
+ const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67433
+ return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
67225
67434
  }
67226
67435
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
67227
67436
  var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
@@ -67233,9 +67442,9 @@ ${statePacket}` },
67233
67442
  "navigationBarBackground",
67234
67443
  // WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
67235
67444
  // expose their framework ROOT container's DOM id as the element id — present on
67236
- // EVERY in-app screen, so the floor must not anchor to it (observed live on
67237
- // ThoughtStream: `main-content` produced a screen-agnostic assertion that then
67238
- // never matched via the text-contains fallback).
67445
+ // EVERY in-app screen, so the floor must not anchor to it (observed live: a
67446
+ // framework root id like `main-content` produced a screen-agnostic assertion
67447
+ // that then never matched via the text-contains fallback).
67239
67448
  "main-content",
67240
67449
  "root",
67241
67450
  "app",
@@ -67244,18 +67453,53 @@ ${statePacket}` },
67244
67453
  "react-root",
67245
67454
  "application"
67246
67455
  ]);
67456
+ var STRUCTURAL_ASSERT_ID_RE = /^(?:step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67457
+ function locatorLocalName(value) {
67458
+ const trimmed = value.trim();
67459
+ const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
67460
+ const slashIdx = withoutStrategy.indexOf("/");
67461
+ return slashIdx >= 0 ? withoutStrategy.slice(slashIdx + 1) : withoutStrategy;
67462
+ }
67463
+ function isStructuralAssertionIdentity(value) {
67464
+ const trimmed = nonEmpty2(value);
67465
+ if (!trimmed)
67466
+ return false;
67467
+ const localName = locatorLocalName(trimmed);
67468
+ return ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName) || STRUCTURAL_ASSERT_ID_RE.test(localName);
67469
+ }
67470
+ function hasHumanAssertionSignal(element) {
67471
+ const label = nonEmpty2(element.label);
67472
+ const text = nonEmpty2(element.text);
67473
+ const accessibilityId = nonEmpty2(element.accessibilityId);
67474
+ return Boolean(label && !isStructuralAssertionIdentity(label) || text && !isEditableMobileElement(element) && !isStructuralAssertionIdentity(text) || accessibilityId && !isStructuralAssertionIdentity(accessibilityId));
67475
+ }
67476
+ function weakAssertionTargetReason(element) {
67477
+ if (isAlwaysPresentContainer(element))
67478
+ return "always-present app/system container";
67479
+ const structuralIdentity = [
67480
+ element.accessibilityId,
67481
+ element.resourceId,
67482
+ element.label,
67483
+ element.text
67484
+ ].some(isStructuralAssertionIdentity);
67485
+ const containerishClass = BARE_CONTAINER_CLASS_RE.test(element.role);
67486
+ const humanSignal = hasHumanAssertionSignal(element);
67487
+ if (structuralIdentity && (containerishClass || !humanSignal))
67488
+ return "structural/internal container id";
67489
+ if (containerishClass && !humanSignal)
67490
+ return "generic container with no visible product signal";
67491
+ return null;
67492
+ }
67247
67493
  function isAlwaysPresentContainer(element) {
67248
67494
  if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
67249
67495
  return true;
67250
67496
  const resourceId = element.resourceId?.trim();
67251
67497
  if (resourceId) {
67252
- const slashIdx = resourceId.indexOf("/");
67253
- const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
67254
- if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
67498
+ if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(resourceId)))
67255
67499
  return true;
67256
67500
  }
67257
67501
  const accessibilityId = element.accessibilityId?.trim();
67258
- if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
67502
+ if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(accessibilityId)))
67259
67503
  return true;
67260
67504
  return false;
67261
67505
  }
@@ -67270,7 +67514,7 @@ ${statePacket}` },
67270
67514
  if (hasIdentity)
67271
67515
  return false;
67272
67516
  const resourceId = bundle.resourceId?.trim() ?? "";
67273
- const localName = resourceId.includes("/") ? resourceId.slice(resourceId.indexOf("/") + 1) : resourceId;
67517
+ const localName = locatorLocalName(resourceId);
67274
67518
  const containerishId = localName !== "" && (localName === "webview" || ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName));
67275
67519
  const containerishClass = BARE_CONTAINER_CLASS_RE.test(bundle.className ?? "");
67276
67520
  if (containerishId)
@@ -67312,6 +67556,22 @@ ${statePacket}` },
67312
67556
  break;
67313
67557
  }
67314
67558
  }
67559
+ for (let window2 = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor((out.length - 1) / 2)); window2 >= 2 && !changed; window2--) {
67560
+ for (let i = 0; i + 2 * window2 + 1 <= out.length; i++) {
67561
+ const first = out.slice(i, i + window2);
67562
+ if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
67563
+ continue;
67564
+ const back = out[i + window2];
67565
+ if (back.type !== "BACK")
67566
+ continue;
67567
+ const second = out.slice(i + window2 + 1, i + window2 + 1 + window2);
67568
+ if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
67569
+ continue;
67570
+ out.splice(i, window2 + 1);
67571
+ changed = true;
67572
+ break;
67573
+ }
67574
+ }
67315
67575
  }
67316
67576
  return out;
67317
67577
  }
@@ -67348,6 +67608,10 @@ ${statePacket}` },
67348
67608
  log2(` compile failed: ${err instanceof Error ? err.message : String(err)}`);
67349
67609
  return null;
67350
67610
  }
67611
+ const selectorQuality = (0, shared_1.analyzeMobileSelectorQuality)(compiled.steps);
67612
+ if (selectorQuality.summary.high > 0 || selectorQuality.summary.medium > 0) {
67613
+ log2(` selector quality: score ${selectorQuality.summary.score}/100 (${selectorQuality.summary.high} high, ${selectorQuality.summary.medium} medium issue(s)).`);
67614
+ }
67351
67615
  const claimedVerified = recording.finish?.verified ?? false;
67352
67616
  const verified = claimedVerified && recording.assertCount > 0;
67353
67617
  if (claimedVerified && !verified) {
@@ -67389,6 +67653,7 @@ ${statePacket}` },
67389
67653
  targetPages: [...scenario.targetPages]
67390
67654
  },
67391
67655
  evidence: { observedLocators: [], observedTransitions: [] },
67656
+ mobileSelectorQuality: selectorQuality,
67392
67657
  // Assertion-strength marker for the server promote-seam
67393
67658
  // (resolveStabilityTransition): an UNVERIFIED recording proves none of the
67394
67659
  // scenario's outcomes — only the hybrid floor's landing-screen visibility —
@@ -67413,6 +67678,25 @@ ${statePacket}` },
67413
67678
  };
67414
67679
  return test;
67415
67680
  }
67681
+ async function prepareMobileScenarioAppState(driver, target, log2) {
67682
+ const resetPolicy = target?.resetPolicy ?? "AUTO";
67683
+ if (resetPolicy === "PRESERVE_APP_DATA") {
67684
+ await driver.relaunch();
67685
+ log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
67686
+ return false;
67687
+ }
67688
+ const appDataCleared = await driver.resetAppState?.().catch((err) => {
67689
+ log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
67690
+ return false;
67691
+ }) ?? false;
67692
+ if (appDataCleared)
67693
+ return true;
67694
+ if (resetPolicy === "CLEAR_APP_DATA") {
67695
+ log2(" CLEAR_APP_DATA requested but app-data reset was unavailable; falling back to relaunch.");
67696
+ }
67697
+ await driver.relaunch();
67698
+ return false;
67699
+ }
67416
67700
  async function runMobileGeneratePhase(args) {
67417
67701
  const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
67418
67702
  const log2 = (line) => {
@@ -67445,11 +67729,9 @@ ${statePacket}` },
67445
67729
  log2(`
67446
67730
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67447
67731
  try {
67448
- const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
67732
+ const appDataCleared = await prepareMobileScenarioAppState(driver, ctx.target, log2);
67449
67733
  if (appDataCleared) {
67450
67734
  log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
67451
- } else {
67452
- await driver.relaunch();
67453
67735
  }
67454
67736
  const recording = await runScenarioLoop({
67455
67737
  scenario,
@@ -68000,8 +68282,8 @@ Reason over steps + error + tree + registry and return working healed steps. Do
68000
68282
  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.
68001
68283
  2. STEP RESULTS: which steps passed before the failure.
68002
68284
  3. TREE: does the current screen match what the failed step expects?
68003
- 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).
68004
- 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.
68285
+ 3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs, first-run prompts, onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
68286
+ 4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68005
68287
  ## Response \u2014 STRICT JSON, exactly one verdict:
68006
68288
  {"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}
68007
68289
  {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
@@ -68504,16 +68786,16 @@ ${args.directive}`);
68504
68786
  var DEFAULT_PHASE2_MAX_PASSES = 6;
68505
68787
  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.
68506
68788
  ## Key insight \u2014 ENTRY-SCREEN DRIFT
68507
- 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.
68789
+ 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.
68508
68790
  ## Analyze
68509
68791
  1. Compare the recorded first step's expected screen to the LIVE tree.
68510
68792
  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().
68511
68793
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68512
- 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.
68794
+ 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.
68513
68795
  4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
68514
68796
  ## Response \u2014 STRICT JSON, exactly one verdict:
68515
68797
  {"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}
68516
- {"verdict":"observe","action":{"type":"tap","target":"mobile.text('Get Started')"}}
68798
+ {"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
68517
68799
  {"verdict":"observe","action":{"type":"back"}}
68518
68800
  {"verdict":"observe","action":{"type":"relaunch"}}
68519
68801
  {"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)
@@ -73652,6 +73934,7 @@ var require_auth_recipe = __commonJS({
73652
73934
  exports.computeRecipeHash = computeRecipeHash;
73653
73935
  exports.buildAuthRecipe = buildAuthRecipe;
73654
73936
  exports.authProofLocator = authProofLocator;
73937
+ exports.urlProofPredicateExpr = urlProofPredicateExpr;
73655
73938
  exports.renderAuthProofAssertion = renderAuthProofAssertion;
73656
73939
  exports.renderAuthFn = renderAuthFn;
73657
73940
  exports.absoluteRoute = absoluteRoute;
@@ -73706,6 +73989,9 @@ var require_auth_recipe = __commonJS({
73706
73989
  };
73707
73990
  }
73708
73991
  function authProofLocator(proof) {
73992
+ if (proof.kind === "url") {
73993
+ throw new Error("authProofLocator: url proof has no element locator; callers must branch on proof.kind");
73994
+ }
73709
73995
  if (proof.kind === "testid") {
73710
73996
  return `page.getByTestId(${JSON.stringify(proof.testId)})`;
73711
73997
  }
@@ -73714,7 +74000,14 @@ var require_auth_recipe = __commonJS({
73714
74000
  }
73715
74001
  return `page.getByRole(${JSON.stringify(proof.role)}).first()`;
73716
74002
  }
74003
+ function urlProofPredicateExpr(route2) {
74004
+ const r = JSON.stringify(route2);
74005
+ return `(u) => u.pathname === ${r} || u.pathname.startsWith(${r} + '/')`;
74006
+ }
73717
74007
  function renderAuthProofAssertion(proof) {
74008
+ if (proof.kind === "url") {
74009
+ return `await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });`;
74010
+ }
73718
74011
  return `await expect(${authProofLocator(proof)}).toBeVisible({ timeout: 15000 });`;
73719
74012
  }
73720
74013
  function normalizeCmd(cmd) {
@@ -73728,9 +74021,16 @@ var require_auth_recipe = __commonJS({
73728
74021
  lines.push(START_MARKER);
73729
74022
  lines.push(`async function __vqaLogin(page) {`);
73730
74023
  if (opts?.warmGuard) {
73731
- const warmLanding = recipe.redirectRoute ?? recipe.preNavRoute;
74024
+ const warmLanding = recipe.redirectRoute ?? (recipe.authProof.kind === "url" ? recipe.authProof.route : recipe.preNavRoute);
74025
+ const loginRoute = recipe.route ?? recipe.preNavRoute;
73732
74026
  lines.push(` await page.goto(${JSON.stringify(warmLanding)}, { waitUntil: 'domcontentloaded' }).catch(() => {});`);
73733
- lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
74027
+ if (recipe.authProof.kind === "url") {
74028
+ lines.push(` if ((${urlProofPredicateExpr(recipe.authProof.route)})(new URL(page.url()))) return;`);
74029
+ } else if (loginRoute) {
74030
+ lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false) && new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname) return;`);
74031
+ } else {
74032
+ lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
74033
+ }
73734
74034
  }
73735
74035
  lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' });`);
73736
74036
  for (const cmd of recipe.preNavCmds)
@@ -73779,6 +74079,11 @@ var require_auth_recipe = __commonJS({
73779
74079
  }`;
73780
74080
  }
73781
74081
  function renderReplaySettleArrow(proof) {
74082
+ if (proof.kind === "url") {
74083
+ return `async (page) => {
74084
+ await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });
74085
+ }`;
74086
+ }
73782
74087
  return `async (page) => {
73783
74088
  await ${authProofLocator(proof)}.waitFor({ state: 'visible', timeout: 15000 });
73784
74089
  }`;
@@ -307869,7 +308174,7 @@ ${keyLines || "- (none provided)"}
307869
308174
  3. If the email/password fields are hidden behind a reveal button (e.g. "Sign in with Email", "Continue with Email"), click it first.
307870
308175
  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.)
307871
308176
  5. Submit the login form.
307872
- 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.
308177
+ 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).
307873
308178
  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.
307874
308179
  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.
307875
308180
 
@@ -307929,7 +308234,11 @@ ${keyLines || "- (none provided)"}
307929
308234
  if (el.testId)
307930
308235
  testIdFreq.set(el.testId, (testIdFreq.get(el.testId) ?? 0) + 1);
307931
308236
  }
307932
- const isPersonalized = (name) => !name || GREETING_RE.test(name) || name.includes("@") || findCredentialLiteralLeaks(name, credentials).length > 0;
308237
+ const isInitialsLike = (name) => {
308238
+ const t = name.trim();
308239
+ return /^[A-Za-z]{1,3}$/.test(t) && t === t.toUpperCase();
308240
+ };
308241
+ const isPersonalized = (name) => !name || GREETING_RE.test(name) || name.includes("@") || findCredentialLiteralLeaks(name, credentials).length > 0 || isInitialsLike(name);
307933
308242
  const absentOnLogin = (roleNameKey, testId) => {
307934
308243
  if (testId && loginTestIds.has(testId))
307935
308244
  return false;
@@ -307964,7 +308273,12 @@ ${keyLines || "- (none provided)"}
307964
308273
  }
307965
308274
  }
307966
308275
  if (ranked.length === 0) {
307967
- 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)");
308276
+ const urlRoute = redirectRoute && redirectRoute !== "/" && !isLoginPage(redirectRoute) ? redirectRoute : landing.route !== "/" && !isLoginPage(landing.route) ? landing.route : null;
308277
+ if (urlRoute) {
308278
+ logs2(` \u2713 url auth-proof: authenticated landing route ${urlRoute} (no durable element proof on the default-state page)`);
308279
+ return { kind: "url", route: urlRoute };
308280
+ }
308281
+ 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)");
307968
308282
  return null;
307969
308283
  }
307970
308284
  ranked.sort((a, b) => a.tier - b.tier);
@@ -308115,7 +308429,7 @@ ${keyLines || "- (none provided)"}
308115
308429
  logs2(" \u26A0 auth unverified (live-verify replay failed \u2014 fleet-config drift) \u2014 falling back to model-authored login");
308116
308430
  return null;
308117
308431
  }
308118
- const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : `testid:${authProof.testId}`;
308432
+ const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : authProof.kind === "testid" ? `testid:${authProof.testId}` : `url:${authProof.route}`;
308119
308433
  logs2(` \u2713 auth recipe verified (hash ${candidate.recipeHash}, proof ${proofDesc}, ${(candidate.replayPlan ?? []).length} replay steps) \u2014 stamping into @needs-auth tests`);
308120
308434
  return candidate;
308121
308435
  } catch (err) {
@@ -314808,13 +315122,13 @@ var require_mobile_discovery_agent = __commonJS({
314808
315122
  exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
314809
315123
  duration: duration2,
314810
315124
  mode,
314811
- ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
315125
+ ...paused ? { paused: true, pauseReason, pausePhase: "generate", remainingScenarios } : {}
314812
315126
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
314813
315127
  };
314814
315128
  }
314815
315129
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
314816
- const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
314817
- 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.");
315130
+ const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
315131
+ log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
314818
315132
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
314819
315133
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
314820
315134
  const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
@@ -314849,6 +315163,32 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
314849
315163
  log2(`[stream] explore-complete callback failed: ${err instanceof Error ? err.message : String(err)}`);
314850
315164
  });
314851
315165
  }
315166
+ if (!isSurvey && shouldPauseAfterTruncatedExplore(explore)) {
315167
+ const duration2 = (Date.now() - startTime) / 1e3;
315168
+ const pauseReason = explore.truncatedReason === "timeout" ? "ai_timeout" : "ai_rate_limit";
315169
+ const summary2 = `Mobile discovery paused after exploration: mapped ${explore.exploreStats.pagesDiscovered} screen(s) before ${explore.truncatedReason ?? "AI"} interruption in ${duration2.toFixed(1)}s.`;
315170
+ log2(summary2);
315171
+ return {
315172
+ collectedPages: explore.collectedPages,
315173
+ collectedTransitions,
315174
+ tests: [],
315175
+ plans: [],
315176
+ logs: logs2.join("\n"),
315177
+ screenshots,
315178
+ pageScreenshots: explore.pageScreenshots,
315179
+ summary: summary2,
315180
+ warning: mergeWarning(void 0, explore),
315181
+ healthObservations: explore.healthObservations,
315182
+ exploreTruncated: true,
315183
+ exploreTruncatedReason: explore.truncatedReason,
315184
+ exploreStats: explore.exploreStats,
315185
+ duration: duration2,
315186
+ mode,
315187
+ paused: true,
315188
+ pauseReason,
315189
+ pausePhase: "explore"
315190
+ };
315191
+ }
314852
315192
  log2("\nRelaunching app for a clean state before planning/generation...");
314853
315193
  await driver.relaunch();
314854
315194
  log2("\n\u2500\u2500 Phase 2: PLAN \u2500\u2500");
@@ -315001,7 +315341,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
315001
315341
  // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
315002
315342
  // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
315003
315343
  // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
315004
- ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
315344
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, pausePhase: "generate", remainingScenarios: generateRemainingScenarios } : {}
315005
315345
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
315006
315346
  };
315007
315347
  } catch (fatalErr) {
@@ -315030,6 +315370,29 @@ Fatal error: ${msg}`);
315030
315370
  };
315031
315371
  }
315032
315372
  }
315373
+ async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
315374
+ void phase;
315375
+ const resetPolicy = target?.resetPolicy ?? "AUTO";
315376
+ if (resetPolicy === "PRESERVE_APP_DATA") {
315377
+ try {
315378
+ await driver.relaunch();
315379
+ } catch (err) {
315380
+ log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
315381
+ }
315382
+ return false;
315383
+ }
315384
+ const cleared = await driver.resetAppState?.().catch((err) => {
315385
+ log2(`App-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
315386
+ return false;
315387
+ }) ?? false;
315388
+ if (!cleared && resetPolicy === "CLEAR_APP_DATA") {
315389
+ log2("CLEAR_APP_DATA requested but app-data reset was unavailable; continuing without clearing app data.");
315390
+ }
315391
+ return cleared;
315392
+ }
315393
+ function shouldPauseAfterTruncatedExplore(explore) {
315394
+ return explore.truncated === true && (explore.truncatedReason === "rate_limit" || explore.truncatedReason === "timeout");
315395
+ }
315033
315396
  function mergeWarning(plannerWarning, explore) {
315034
315397
  if (!explore.truncated)
315035
315398
  return plannerWarning;
@@ -329531,7 +329894,7 @@ var require_package4 = __commonJS({
329531
329894
  "package.json"(exports, module) {
329532
329895
  module.exports = {
329533
329896
  name: "@validate.qa/runner",
329534
- version: "1.0.28",
329897
+ version: "1.0.30",
329535
329898
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329536
329899
  bin: {
329537
329900
  "validate-runner": "dist/cli.js"
@@ -330673,6 +331036,15 @@ var MitmProxy = class {
330673
331036
  }
330674
331037
  return this.tlsFailureReason;
330675
331038
  }
331039
+ get passthroughCount() {
331040
+ return this.tlsPassthroughCount;
331041
+ }
331042
+ get capturedCallCounts() {
331043
+ return {
331044
+ callCount: this.calls.length,
331045
+ apiCallCount: this.calls.filter((call) => (0, import_runner_core7.isApiCall)(call.url, call.responseContentType)).length
331046
+ };
331047
+ }
330676
331048
  async close() {
330677
331049
  const server3 = this.server;
330678
331050
  this.server = null;
@@ -331259,6 +331631,35 @@ async function startMobileNetworkCapture(options) {
331259
331631
  const reason = proxy.interceptionReason ?? wiringReason;
331260
331632
  return reason ? { intercepting: false, reason } : { intercepting: false };
331261
331633
  };
331634
+ const health = () => {
331635
+ const capabilities = getCapabilities();
331636
+ const counts = stopped ? {
331637
+ callCount: finalCalls.length,
331638
+ apiCallCount: finalCalls.filter((call) => call.isApi).length
331639
+ } : proxy.capturedCallCounts;
331640
+ const warnings = [];
331641
+ if (!capabilities.intercepting && capabilities.reason) {
331642
+ warnings.push(capabilities.reason);
331643
+ }
331644
+ if (stopped && counts.callCount === 0) {
331645
+ 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.");
331646
+ }
331647
+ return {
331648
+ enabled: true,
331649
+ active: !stopped,
331650
+ platform: platform3,
331651
+ deviceId,
331652
+ appId: options.appId,
331653
+ proxyHost,
331654
+ proxyPort,
331655
+ intercepting: capabilities.intercepting,
331656
+ ...capabilities.reason ? { reason: capabilities.reason } : {},
331657
+ callCount: counts.callCount,
331658
+ apiCallCount: counts.apiCallCount,
331659
+ tlsPassthroughCount: proxy.passthroughCount,
331660
+ warnings
331661
+ };
331662
+ };
331262
331663
  const stop = async () => {
331263
331664
  if (stopped) return finalCalls;
331264
331665
  stopped = true;
@@ -331297,6 +331698,7 @@ async function startMobileNetworkCapture(options) {
331297
331698
  proxyHost,
331298
331699
  caCertPath: capturedCaCertPath,
331299
331700
  getCapabilities,
331701
+ health,
331300
331702
  stop
331301
331703
  };
331302
331704
  }
@@ -331777,6 +332179,32 @@ function readStepTimeout(step) {
331777
332179
  }
331778
332180
  return void 0;
331779
332181
  }
332182
+ async function revealLikelyOffscreenElement(sessionId) {
332183
+ const windowSize = await getWindowSize2(sessionId);
332184
+ const { startX, startY, endX, endY } = computeMobileSwipePointers(
332185
+ windowSize.width,
332186
+ windowSize.height,
332187
+ "up",
332188
+ clampMobileSwipeDistance(void 0)
332189
+ );
332190
+ await wdRequest(`/session/${sessionId}/actions`, {
332191
+ method: "POST",
332192
+ body: JSON.stringify({
332193
+ actions: [{
332194
+ type: "pointer",
332195
+ id: "finger-reveal",
332196
+ parameters: { pointerType: "touch" },
332197
+ actions: [
332198
+ { type: "pointerMove", duration: 0, x: startX, y: startY },
332199
+ { type: "pointerDown", button: 0 },
332200
+ { type: "pointerMove", duration: 300, x: endX, y: endY },
332201
+ { type: "pointerUp", button: 0 }
332202
+ ]
332203
+ }]
332204
+ })
332205
+ });
332206
+ await new Promise((resolve) => setTimeout(resolve, 250));
332207
+ }
331780
332208
  async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
331781
332209
  const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
331782
332210
  if (strategies.length === 0) {
@@ -331786,6 +332214,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
331786
332214
  }
331787
332215
  const startTime = Date.now();
331788
332216
  let lastError = "";
332217
+ let revealAttempted = false;
331789
332218
  while (Date.now() - startTime < timeoutMs) {
331790
332219
  for (const strategy of strategies) {
331791
332220
  try {
@@ -331800,9 +332229,18 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
331800
332229
  lastError = err instanceof Error ? err.message : String(err);
331801
332230
  }
331802
332231
  }
332232
+ if (!opts?.identityOnly && !revealAttempted && Date.now() - startTime > Math.min(1500, timeoutMs / 3)) {
332233
+ revealAttempted = true;
332234
+ try {
332235
+ await revealLikelyOffscreenElement(sessionId);
332236
+ } catch (err) {
332237
+ if (isTransportError(err)) throw err;
332238
+ lastError = `reveal scroll failed: ${err instanceof Error ? err.message : String(err)}`;
332239
+ }
332240
+ }
331803
332241
  await new Promise((resolve) => setTimeout(resolve, 500));
331804
332242
  }
331805
- throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies. Last error: ${lastError}`);
332243
+ throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
331806
332244
  }
331807
332245
  async function tapCoordinates(sessionId, bounds) {
331808
332246
  const tapX = Math.round(bounds.x + bounds.width / 2);
@@ -332226,21 +332664,29 @@ async function executeMobileTest(options) {
332226
332664
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
332227
332665
  const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
332228
332666
  const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
332229
- if (recordedFromClearedState) {
332667
+ const resetPolicy = options.target.resetPolicy ?? "AUTO";
332668
+ const shouldClearAppData = resetPolicy === "CLEAR_APP_DATA" || resetPolicy === "AUTO" && recordedFromClearedState;
332669
+ let clearedAppDataBeforeLaunch = false;
332670
+ if (shouldClearAppData) {
332230
332671
  if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
332231
332672
  try {
332232
332673
  await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
332233
- log2("App data cleared before launch (test was recorded from a cold logged-out state).");
332674
+ clearedAppDataBeforeLaunch = true;
332675
+ 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).");
332234
332676
  } catch (err) {
332235
332677
  log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
332236
332678
  }
332679
+ } else if (resetPolicy === "CLEAR_APP_DATA") {
332680
+ 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.");
332237
332681
  } else {
332238
332682
  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.");
332239
332683
  }
332684
+ } else if (recordedFromClearedState && resetPolicy === "PRESERVE_APP_DATA") {
332685
+ log2("Test was recorded from a cleared state, but target.resetPolicy=PRESERVE_APP_DATA \u2014 replaying against preserved app data.");
332240
332686
  }
332241
332687
  log2("Opening a fresh app instance for the test...");
332242
332688
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
332243
- if (recordedFromClearedState) {
332689
+ if (clearedAppDataBeforeLaunch) {
332244
332690
  await waitForColdBootContent(sessionId, log2);
332245
332691
  }
332246
332692
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
@@ -334486,8 +334932,9 @@ function envInt(name, fallback) {
334486
334932
  var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
334487
334933
  var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
334488
334934
  function scrubAuditMessages(messages, testVariables) {
334935
+ if (messages === void 0 || messages === null) return messages;
334489
334936
  const compacted = compactForAudit(messages);
334490
- const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
334937
+ const secretValues = Object.values(testVariables ?? {}).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
334491
334938
  if (secretValues.length === 0) return compacted;
334492
334939
  let json = JSON.stringify(compacted);
334493
334940
  if (typeof json !== "string") return compacted;
@@ -334802,6 +335249,9 @@ var AUDIT_FLUSH_COUNT = 10;
334802
335249
  var AUDIT_FLUSH_BYTES = 2 * 1024 * 1024;
334803
335250
  var AUDIT_DRAIN_ATTEMPTS = 3;
334804
335251
  var AUDIT_DRAIN_DELAY_MS = 1e3;
335252
+ var AUDIT_POST_MAX_BYTES = 4 * 1024 * 1024;
335253
+ var AUDIT_POST_MAX_ENTRIES = 50;
335254
+ var AUDIT_QUEUE_MAX_BYTES = 32 * 1024 * 1024;
334805
335255
  var AUDIT_BODY_MAX_BYTES = 1024 * 1024;
334806
335256
  var AUDIT_MAX_STRING_CHARS = 5e4;
334807
335257
  var AUDIT_MAX_ARRAY_ITEMS = 50;
@@ -334852,7 +335302,7 @@ function compactForAudit(value, depth = 0, seen = /* @__PURE__ */ new WeakSet())
334852
335302
  }
334853
335303
  function scrubAuditText(text, testVariables) {
334854
335304
  let out = text;
334855
- for (const [key, value] of Object.entries(testVariables).sort((a, b) => b[1].length - a[1].length)) {
335305
+ for (const [key, value] of Object.entries(testVariables ?? {}).sort((a, b) => b[1].length - a[1].length)) {
334856
335306
  if (!value || value.length < 3) continue;
334857
335307
  const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
334858
335308
  const replacement = SECRET_VAR_PATTERN.test(key) ? "[REDACTED]" : `[TEST_VARIABLE:${key}]`;
@@ -334971,6 +335421,9 @@ function truncateAuditRequestBody(body) {
334971
335421
  if (byteLen <= AUDIT_BODY_MAX_BYTES) return compacted;
334972
335422
  return { _truncated: true, _originalLength: byteLen, preview: serialized.slice(0, 1e3) };
334973
335423
  }
335424
+ function isTerminalAuditStatus(status) {
335425
+ return status === 400 || status === 404 || status === 409 || status === 413;
335426
+ }
334974
335427
  function createAuditFlusher(url, headers) {
334975
335428
  const entries = [];
334976
335429
  let byteEstimate = 0;
@@ -334978,26 +335431,41 @@ function createAuditFlusher(url, headers) {
334978
335431
  const doFlush = () => {
334979
335432
  if (entries.length === 0) return Promise.resolve();
334980
335433
  if (flushInFlight) return flushInFlight;
334981
- const toSend = entries.splice(0);
334982
- const savedBytes = byteEstimate;
334983
- byteEstimate = 0;
334984
335434
  const attempt = async () => {
334985
335435
  try {
334986
- const res = await fetch(url, {
334987
- method: "POST",
334988
- headers: { ...headers, "Content-Type": "application/json" },
334989
- body: JSON.stringify({ entries: toSend }),
334990
- signal: AbortSignal.timeout(3e4)
334991
- });
334992
- if (!res.ok) {
334993
- entries.unshift(...toSend);
334994
- byteEstimate += savedBytes;
334995
- console.warn(`[audit-flush] HTTP ${res.status} - ${toSend.length} entries requeued`);
335436
+ while (entries.length > 0) {
335437
+ const toSend = [];
335438
+ let sendBytes = 0;
335439
+ while (entries.length > 0 && toSend.length < AUDIT_POST_MAX_ENTRIES) {
335440
+ const next = entries[0];
335441
+ if (toSend.length > 0 && sendBytes + next.bytes > AUDIT_POST_MAX_BYTES) break;
335442
+ toSend.push(entries.shift());
335443
+ sendBytes += next.bytes;
335444
+ }
335445
+ byteEstimate = Math.max(0, byteEstimate - sendBytes);
335446
+ try {
335447
+ const res = await fetch(url, {
335448
+ method: "POST",
335449
+ headers: { ...headers, "Content-Type": "application/json" },
335450
+ body: JSON.stringify({ entries: toSend.map((e) => e.entry) }),
335451
+ signal: AbortSignal.timeout(3e4)
335452
+ });
335453
+ if (res.ok) continue;
335454
+ if (isTerminalAuditStatus(res.status)) {
335455
+ console.warn(`[audit-flush] HTTP ${res.status} (terminal) - dropping ${toSend.length} audit entries`);
335456
+ continue;
335457
+ }
335458
+ entries.unshift(...toSend);
335459
+ byteEstimate += sendBytes;
335460
+ console.warn(`[audit-flush] HTTP ${res.status} - ${toSend.length} entries requeued`);
335461
+ return;
335462
+ } catch (err) {
335463
+ entries.unshift(...toSend);
335464
+ byteEstimate += sendBytes;
335465
+ console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
335466
+ return;
335467
+ }
334996
335468
  }
334997
- } catch (err) {
334998
- entries.unshift(...toSend);
334999
- byteEstimate += savedBytes;
335000
- console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
335001
335469
  } finally {
335002
335470
  flushInFlight = null;
335003
335471
  }
@@ -335013,8 +335481,18 @@ function createAuditFlusher(url, headers) {
335013
335481
  if ("responseBody" in safeEntry && safeEntry.responseBody != null) {
335014
335482
  safeEntry = { ...safeEntry, responseBody: truncateAuditRequestBody(safeEntry.responseBody) };
335015
335483
  }
335016
- entries.push(safeEntry);
335017
- byteEstimate += safeJsonByteLength(safeEntry);
335484
+ const bytes = safeJsonByteLength(safeEntry);
335485
+ entries.push({ entry: safeEntry, bytes });
335486
+ byteEstimate += bytes;
335487
+ if (byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
335488
+ let droppedCount = 0;
335489
+ while (entries.length > 1 && byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
335490
+ const dropped = entries.shift();
335491
+ byteEstimate -= dropped.bytes;
335492
+ droppedCount++;
335493
+ }
335494
+ console.warn(`[audit-flush] queue over ${AUDIT_QUEUE_MAX_BYTES} bytes - dropped ${droppedCount} oldest audit entries`);
335495
+ }
335018
335496
  if (entries.length >= AUDIT_FLUSH_COUNT || byteEstimate >= AUDIT_FLUSH_BYTES) {
335019
335497
  void doFlush();
335020
335498
  }
@@ -335858,6 +336336,7 @@ ${finalVerifyResult.logs ?? ""}`;
335858
336336
  platformVersion: harvestTarget.platformVersion,
335859
336337
  recordedDeviceId: harvestTarget.recordedDeviceId,
335860
336338
  assignedDeviceId,
336339
+ resetPolicy: harvestTarget.resetPolicy,
335861
336340
  launchMode: harvestTarget.launchMode,
335862
336341
  deeplink: harvestTarget.deeplink
335863
336342
  },
@@ -336134,6 +336613,7 @@ ${finalVerifyResult.logs ?? ""}`;
336134
336613
  // local-runner projects. Mirrors the Fargate host forward in execute-run.ts.
336135
336614
  paused: rest.paused,
336136
336615
  pauseReason: rest.pauseReason,
336616
+ pausePhase: rest.pausePhase,
336137
336617
  remainingScenarios: rest.remainingScenarios,
336138
336618
  // Operator/UX signals the server persists to DiscoveryRun (Fargate parity).
336139
336619
  coverageGaps: rest.coverageGaps,
@@ -336247,6 +336727,7 @@ ${finalVerifyResult.logs ?? ""}`;
336247
336727
  platformVersion: mobileTarget.platformVersion,
336248
336728
  recordedDeviceId: mobileTarget.recordedDeviceId,
336249
336729
  assignedDeviceId,
336730
+ resetPolicy: mobileTarget.resetPolicy,
336250
336731
  launchMode: mobileTarget.launchMode,
336251
336732
  deeplink: mobileTarget.deeplink
336252
336733
  }, {
@@ -336278,7 +336759,7 @@ ${finalVerifyResult.logs ?? ""}`;
336278
336759
  // Deterministic-state authorization: Android emulators only by
336279
336760
  // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
336280
336761
  // physical device must never have its app data wiped.
336281
- allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
336762
+ allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId)
336282
336763
  });
336283
336764
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
336284
336765
  onLog: mobileOnLog,
@@ -336310,8 +336791,13 @@ ${finalVerifyResult.logs ?? ""}`;
336310
336791
  if (activeCapture) {
336311
336792
  try {
336312
336793
  const apiCalls = await activeCapture.stop();
336794
+ const captureHealth = activeCapture.health();
336313
336795
  if (discoveryResult) {
336314
336796
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
336797
+ const priorHealth = discoveryResult.healthObservations;
336798
+ const healthObject = priorHealth && typeof priorHealth === "object" && !Array.isArray(priorHealth) ? { ...priorHealth } : {};
336799
+ healthObject.mobileNetworkCapture = captureHealth;
336800
+ discoveryResult.healthObservations = healthObject;
336315
336801
  }
336316
336802
  } catch (capErr) {
336317
336803
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
@@ -336628,6 +337114,7 @@ ${finalVerifyResult.logs ?? ""}`;
336628
337114
  platformVersion: target.platformVersion,
336629
337115
  recordedDeviceId: target.recordedDeviceId,
336630
337116
  assignedDeviceId,
337117
+ resetPolicy: target.resetPolicy,
336631
337118
  launchMode: target.launchMode,
336632
337119
  deeplink: target.deeplink,
336633
337120
  authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
@@ -336642,6 +337129,7 @@ ${finalVerifyResult.logs ?? ""}`;
336642
337129
  platformVersion: t.platformVersion,
336643
337130
  recordedDeviceId: t.recordedDeviceId,
336644
337131
  assignedDeviceId: t.assignedDeviceId,
337132
+ resetPolicy: t.resetPolicy,
336645
337133
  launchMode: narrowMobileLaunchMode(t.launchMode),
336646
337134
  deeplink: t.deeplink,
336647
337135
  authoringPlatform: t.authoringPlatform
@@ -336704,6 +337192,7 @@ ${finalVerifyResult.logs ?? ""}`;
336704
337192
  platformVersion: target.platformVersion,
336705
337193
  recordedDeviceId: target.recordedDeviceId,
336706
337194
  assignedDeviceId,
337195
+ resetPolicy: target.resetPolicy,
336707
337196
  launchMode: target.launchMode,
336708
337197
  deeplink: target.deeplink
336709
337198
  });
@@ -336712,7 +337201,7 @@ ${finalVerifyResult.logs ?? ""}`;
336712
337201
  sessionId: liveSession.sessionId,
336713
337202
  platform: target.platform,
336714
337203
  appId: liveSession.appId,
336715
- allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
337204
+ allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, liveSession.deviceId)
336716
337205
  });
336717
337206
  const liveDriver = {
336718
337207
  snapshot: async () => {
@@ -336740,7 +337229,7 @@ ${finalVerifyResult.logs ?? ""}`;
336740
337229
  relaunch: async () => {
336741
337230
  await bridgeDriver.relaunch();
336742
337231
  },
336743
- resetAppState: async () => bridgeDriver.resetAppState()
337232
+ resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : bridgeDriver.resetAppState()
336744
337233
  };
336745
337234
  return await (0, import_runner_core13.executeMobileLiveHeal)({
336746
337235
  steps: a.steps,
@@ -336841,6 +337330,7 @@ ${finalVerifyResult.logs ?? ""}`;
336841
337330
  platformVersion: target.platformVersion,
336842
337331
  recordedDeviceId: target.recordedDeviceId,
336843
337332
  assignedDeviceId,
337333
+ resetPolicy: target.resetPolicy,
336844
337334
  launchMode: target.launchMode,
336845
337335
  deeplink: target.deeplink,
336846
337336
  // Authoring platform (test's session medium). When it differs from