@validate.qa/runner 1.0.84 → 1.0.86

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 +635 -88
  2. package/dist/cli.mjs +635 -88
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -980,10 +980,11 @@ var require_mobile_source_parser = __commonJS({
980
980
  function extractIOS(attrs) {
981
981
  const role = nonEmpty3(attrs.type) ?? "XCUIElementTypeOther";
982
982
  const name = nonEmpty3(attrs.name);
983
+ const identifier = nonEmpty3(attrs.identifier) ?? nonEmpty3(attrs.accessibilityIdentifier);
983
984
  const labelAttr = nonEmpty3(attrs.label);
984
985
  const value = nonEmpty3(attrs.value);
985
986
  const label = name ?? labelAttr ?? value;
986
- const accessibilityId = name;
987
+ const accessibilityId = identifier ?? name;
987
988
  const text = labelAttr ?? (isStateValueRole(role) ? void 0 : value);
988
989
  const bounds = parseIOSBounds(attrs);
989
990
  const enabled = attrBoolDefaultTrue(attrs.enabled);
@@ -1024,7 +1025,8 @@ var require_mobile_source_parser = __commonJS({
1024
1025
  const isGenericView = /android\.view\.View$/i.test(role);
1025
1026
  const moderateHitTarget = hasArea && bounds.width >= 24 && bounds.height >= 24 && bounds.width <= 2e3 && bounds.height <= 1800;
1026
1027
  const webHybridLabelledControl = isGenericView && hasHumanIdentity && moderateHitTarget && (focusable || clickable || longClickable || Boolean(contentDesc));
1027
- const actionable = enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1028
+ const isAnrChrome = typeof resourceId === "string" && /(?:android:id\/)?aerr_/i.test(resourceId) || typeof text === "string" && /isn['’]?t responding/i.test(text) || typeof label === "string" && /isn['’]?t responding/i.test(label);
1029
+ const actionable = !isAnrChrome && enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1028
1030
  return {
1029
1031
  role,
1030
1032
  label,
@@ -1202,6 +1204,7 @@ var require_mobile_source_parser = __commonJS({
1202
1204
  element.secure = fields.secure;
1203
1205
  if (fields.enabled !== void 0)
1204
1206
  element.enabled = fields.enabled;
1207
+ element.visible = fields.visible;
1205
1208
  if (isCredibleStructuralNavigationTarget(element) && chainHasNativeNavigationSemantics(chain, platform3)) {
1206
1209
  markNavigation(element, "native-navigation-semantics");
1207
1210
  }
@@ -1576,6 +1579,8 @@ var require_mobile_boundary = __commonJS({
1576
1579
  "../runner-core/dist/services/mobile/mobile-boundary.js"(exports) {
1577
1580
  "use strict";
1578
1581
  Object.defineProperty(exports, "__esModule", { value: true });
1582
+ exports.isAndroidAnrSystemResourceId = isAndroidAnrSystemResourceId;
1583
+ exports.looksLikeAndroidAnrUi = looksLikeAndroidAnrUi;
1579
1584
  exports.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1580
1585
  exports.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1581
1586
  exports.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
@@ -1593,6 +1598,28 @@ var require_mobile_boundary = __commonJS({
1593
1598
  "com.miui.securitycenter",
1594
1599
  "com.samsung.android.permissioncontroller"
1595
1600
  ]);
1601
+ var ANDROID_ANR_RESOURCE_ID_RE = /^(?:android:id\/)?(?:aerr_(?:wait|close|close_app|mute|report|restart)|alertTitle|button1|button2|button3|parentPanel|topPanel|contentPanel|customPanel|buttonPanel|scrollView|textSpacerNoButtons|textSpacerNoTitle)$/i;
1602
+ var ANDROID_ANR_TEXT_RE = /isn['’]?t responding|isn['’]?t responding\.|anr|close app|wait/i;
1603
+ function isAndroidAnrSystemResourceId(resourceId) {
1604
+ const rid = resourceId?.trim();
1605
+ if (!rid)
1606
+ return false;
1607
+ if (/(?:android:id\/)?aerr_/i.test(rid))
1608
+ return true;
1609
+ return ANDROID_ANR_RESOURCE_ID_RE.test(rid) && /aerr_/i.test(rid);
1610
+ }
1611
+ function looksLikeAndroidAnrUi(input) {
1612
+ if (isAndroidAnrSystemResourceId(input.resourceId))
1613
+ return true;
1614
+ const rid = input.resourceId?.trim() ?? "";
1615
+ const blob = [input.text, input.label, input.contentDesc].filter(Boolean).join(" ");
1616
+ if (/(?:android:id\/)?(?:alertTitle|aerr_)/i.test(rid) && ANDROID_ANR_TEXT_RE.test(blob)) {
1617
+ return true;
1618
+ }
1619
+ if (/isn['’]?t responding/i.test(blob))
1620
+ return true;
1621
+ return false;
1622
+ }
1596
1623
  var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1597
1624
  "com.apple.springboard",
1598
1625
  "com.apple.authenticationservicesui",
@@ -1750,7 +1777,7 @@ var init_constants = __esm({
1750
1777
  MOBILE_APP_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?$/;
1751
1778
  MOBILE_FOCUS_SETTLE_MS = 150;
1752
1779
  MOBILE_POST_ACTION_SETTLE_MS = 1800;
1753
- MOBILE_COLD_BOOT_SETTLE_MS = 12e3;
1780
+ MOBILE_COLD_BOOT_SETTLE_MS = 25e3;
1754
1781
  MOBILE_DEFAULT_SWIPE_DISTANCE = 500;
1755
1782
  MOBILE_MAX_SWIPE_DISTANCE = 2e3;
1756
1783
  SELECTOR_PRIORITIES = [
@@ -74892,7 +74919,7 @@ var require_mobile_explorer = __commonJS({
74892
74919
  type: "function",
74893
74920
  function: {
74894
74921
  name: "mobile_relaunch",
74895
- description: "Terminate and relaunch the app for a clean, hermetic state (sessions are noReset=true, so Home only backgrounds). Use between exploration episodes when you want to start fresh. Re-snapshot afterward.",
74922
+ description: "Terminate and relaunch the app for a clean, hermetic state (sessions are noReset=true, so Home only backgrounds). Only for CLEAR_APP_DATA / cold flows. Blocked when app data is being preserved (PRESERVE_APP_DATA or survey AUTO). Do not use on a working product screen. Re-snapshot afterward.",
74896
74923
  parameters: { type: "object", properties: {} }
74897
74924
  }
74898
74925
  };
@@ -75692,11 +75719,11 @@ Rules:
75692
75719
  **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, observing 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.
75693
75720
  **Phase 3 \u2014 Depth (remaining budget).** Return to each observed tab and explore its sub-screens: open ONE list row per list (rows share a template), open menus/sheets/dialogs, observe detail screens, and mobile_swipe up/down to reveal off-screen content. record_transition whenever you move between screens.
75694
75721
 
75695
- Rules that apply throughout: snapshots and post-action observations capture screen evidence automatically. Use capture_screen sparingly to name/classify important stable destinations (use screenType "LANDING" for welcome/onboarding/intro and "AUTH" for sign-in/sign-up), but never spend turns renaming already-observed structural variants. Record transitions on every move; mobile_back to return; mobile_relaunch only for a clean restart between episodes. When no new screens or safe navigation controls remain, call finish_exploration.
75722
+ Rules that apply throughout: snapshots and post-action observations capture screen evidence automatically. Use capture_screen sparingly to name/classify important stable destinations (use screenType "LANDING" for welcome/onboarding/intro and "AUTH" for sign-in/sign-up), but never spend turns renaming already-observed structural variants. Record transitions on every move; mobile_back to return. Prefer staying on the current app instance \u2014 do not cold-relaunch unless the product UI is truly stuck after a long wait. When no new screens or safe navigation controls remain, call finish_exploration.
75696
75723
 
75697
75724
  ## Boundaries (survey = map, do not mutate)
75698
75725
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
75699
- - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
75726
+ - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back (not a cold relaunch).
75700
75727
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
75701
75728
 
75702
75729
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -75921,6 +75948,34 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
75921
75948
  latest = boundaryResolved;
75922
75949
  lastSnapshotIteration = iterationForObservation;
75923
75950
  if (boundary.kind === "transient_external") {
75951
+ const isHostHomeShell = /springboard|systemui/i.test(boundary.owner);
75952
+ if (isHostHomeShell) {
75953
+ log2(` Host shell detected from ${source}: ${boundary.owner}; activating ${boundary.targetAppId} so exploration can continue.`);
75954
+ try {
75955
+ await driver.launchApp();
75956
+ const snap = await driver.snapshot();
75957
+ const recovered = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
75958
+ if (recovered) {
75959
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
75960
+ if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
75961
+ const enriched = await enrichResolvedSnapshotWithHybridWeb(recovered, driver, credentialValuesForRedaction, log2, platform3, targetAppId, hybridContextCache, true, source !== "seed snapshot" && source !== "mobile_snapshot");
75962
+ latest = enriched;
75963
+ lastSnapshotIteration = iterationForObservation;
75964
+ const screenId = ingestSnapshot(enriched, iterationForObservation);
75965
+ captureScreenshot(enriched.replacementScreenshot ?? snap.screenshot, screenId);
75966
+ return {
75967
+ observation: enriched.observation,
75968
+ screenId,
75969
+ transientExternal: true,
75970
+ externalOwner: boundary.owner
75971
+ };
75972
+ }
75973
+ }
75974
+ } catch (err) {
75975
+ (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
75976
+ log2(` Failed to recover from host shell ${boundary.owner}: ${err instanceof Error ? err.message : String(err)}`);
75977
+ }
75978
+ }
75924
75979
  log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
75925
75980
  return {
75926
75981
  observation: `${boundaryResolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
@@ -76107,6 +76162,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76107
76162
  const kickoff = buildKickoffMessage(mode);
76108
76163
  const recentExchanges = [];
76109
76164
  const transientDirectives = [];
76165
+ const resetPolicyForTools = ctx.target?.resetPolicy ?? "AUTO";
76166
+ const preserveAppDataForTools = resetPolicyForTools === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicyForTools === "AUTO";
76110
76167
  const tools = [
76111
76168
  MOBILE_SNAPSHOT_TOOL,
76112
76169
  MOBILE_TAP_TOOL,
@@ -76116,7 +76173,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76116
76173
  MOBILE_SWIPE_TOOL,
76117
76174
  MOBILE_BACK_TOOL,
76118
76175
  MOBILE_HOME_TOOL,
76119
- MOBILE_RELAUNCH_TOOL,
76176
+ // Omit relaunch under preserve — model otherwise cold-restarts RN into splash.
76177
+ ...preserveAppDataForTools ? [] : [MOBILE_RELAUNCH_TOOL],
76120
76178
  MOBILE_OPEN_DEEPLINK_TOOL,
76121
76179
  CAPTURE_SCREEN_TOOL,
76122
76180
  RECORD_TRANSITION_TOOL,
@@ -76135,6 +76193,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76135
76193
  let summary = "";
76136
76194
  let truncated = false;
76137
76195
  let truncatedReason;
76196
+ const emptyUiTreeStreak = { count: 0 };
76138
76197
  let finishRedirects = 0;
76139
76198
  let lastFinishBreadthRemainingItems = null;
76140
76199
  let lastFinishBreadthProgressMark = null;
@@ -76472,10 +76531,18 @@ Exploration complete: ${summary}`);
76472
76531
  credentials: credentialSecrets,
76473
76532
  redactionCredentials: credentialValuesForRedaction,
76474
76533
  authTracker,
76475
- inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null
76534
+ inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null,
76535
+ allowRelaunch: !preserveAppDataForTools,
76536
+ emptyUiTreeStreak
76476
76537
  });
76477
76538
  pushResult(handled);
76478
76539
  const handledRecord = handled && typeof handled === "object" ? handled : null;
76540
+ if (handledRecord?.permanent === true && handledRecord?.emptyUiTreeStreak) {
76541
+ explorationComplete = true;
76542
+ summary = typeof handledRecord.reason === "string" && handledRecord.reason.trim() ? handledRecord.reason : "Accessibility tree remained empty after repeated Appium observations; exploration stopped early.";
76543
+ log2(`
76544
+ Stopping Phase 1 early: empty UI tree thrash (streak=${handledRecord.emptyUiTreeStreak}).`);
76545
+ }
76479
76546
  const navigationWasAttempted = handledRecord?.ok !== false || Object.prototype.hasOwnProperty.call(handledRecord ?? {}, "screenId") || Object.prototype.hasOwnProperty.call(handledRecord ?? {}, "observation");
76480
76547
  if (DEVICE_MUTATION_BARRIER_TOOLS.has(toolName) && navigationWasAttempted) {
76481
76548
  navigationBarrierTool = toolName;
@@ -76564,14 +76631,24 @@ Exploration complete: ${summary}`);
76564
76631
  }
76565
76632
  return result;
76566
76633
  }
76634
+ var MAX_EMPTY_UI_TREE_STREAK = 3;
76567
76635
  async function dispatchMobileTool(args) {
76568
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, resolveHybridRef, currentBoundary, collectedTransitions, counters, journeys, log: log2, credentials, redactionCredentials, authTracker, inbox } = args;
76636
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, resolveHybridRef, currentBoundary, collectedTransitions, counters, journeys, log: log2, credentials, redactionCredentials, authTracker, inbox, allowRelaunch, emptyUiTreeStreak } = args;
76637
+ const noteEmptyUiTree = () => {
76638
+ emptyUiTreeStreak.count += 1;
76639
+ return emptyUiTreeStreak.count;
76640
+ };
76641
+ const clearEmptyUiTreeStreak = () => {
76642
+ emptyUiTreeStreak.count = 0;
76643
+ };
76569
76644
  const absorbActionResult = (actionResult, source, preferCredentialSecondary = false) => {
76570
76645
  (0, mobile_driver_errors_js_1.throwIfTerminalMobileActionFailure)(actionResult, source);
76571
76646
  const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
76572
76647
  if (!resolved) {
76648
+ noteEmptyUiTree();
76573
76649
  return Promise.resolve({ observation: "(no UI tree returned)", screenId: null });
76574
76650
  }
76651
+ clearEmptyUiTreeStreak();
76575
76652
  return absorbResolvedObservation(resolved, actionResult.screenshot, iteration, source, preferCredentialSecondary);
76576
76653
  };
76577
76654
  const currentAuthSurface = () => {
@@ -76601,8 +76678,17 @@ Exploration complete: ${summary}`);
76601
76678
  const snap = await driver.snapshot();
76602
76679
  const resolved = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
76603
76680
  if (!resolved) {
76604
- return { ok: false, error: "snapshot returned no UI tree" };
76681
+ const streak = noteEmptyUiTree();
76682
+ log2(` Snapshot returned no UI tree (empty streak ${streak}/${MAX_EMPTY_UI_TREE_STREAK}).`);
76683
+ return {
76684
+ ok: false,
76685
+ error: "snapshot returned no UI tree",
76686
+ emptyUiTreeStreak: streak,
76687
+ permanent: streak >= MAX_EMPTY_UI_TREE_STREAK,
76688
+ reason: streak >= MAX_EMPTY_UI_TREE_STREAK ? "Appium repeatedly returned an empty accessibility tree after settle/activate. Stop relaunch thrash \u2014 call finish_exploration with evidence you have, or abort. This is almost always a session/device/app-start failure, not missing product screens." : "No accessibility tree yet. Prefer one mobile_snapshot after a short wait over repeated relaunches."
76689
+ };
76605
76690
  }
76691
+ clearEmptyUiTreeStreak();
76606
76692
  const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_snapshot");
76607
76693
  if (absorbed.screenId) {
76608
76694
  const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
@@ -76968,14 +77054,37 @@ Exploration complete: ${summary}`);
76968
77054
  }
76969
77055
  // ----- Relaunch (hermetic reset) -----
76970
77056
  case "mobile_relaunch": {
77057
+ if (!allowRelaunch) {
77058
+ return {
77059
+ ok: false,
77060
+ error: "mobile_relaunch is blocked while app data is being preserved. Use mobile_back / mobile_snapshot to continue mapping; request CLEAR_APP_DATA for a true cold restart.",
77061
+ note: "Relaunch refused under preserve policy."
77062
+ };
77063
+ }
77064
+ if (emptyUiTreeStreak.count >= MAX_EMPTY_UI_TREE_STREAK) {
77065
+ return {
77066
+ ok: false,
77067
+ error: `mobile_relaunch refused: accessibility tree has been empty for ${emptyUiTreeStreak.count} consecutive observations. Call finish_exploration or stop \u2014 further relaunches will not recover a dead session/app paint.`,
77068
+ permanent: true,
77069
+ emptyUiTreeStreak: emptyUiTreeStreak.count
77070
+ };
77071
+ }
76971
77072
  authTracker?.noteInterveningMutation(iteration);
76972
77073
  await driver.relaunch();
76973
77074
  log2(" Relaunched app for hermetic reset.");
76974
77075
  const snap = await driver.snapshot();
76975
77076
  const resolved = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
76976
77077
  if (!resolved) {
76977
- return { ok: true, note: "relaunched; no UI tree returned \u2014 call mobile_snapshot." };
77078
+ const streak = noteEmptyUiTree();
77079
+ log2(` Relaunch yielded no UI tree (empty streak ${streak}/${MAX_EMPTY_UI_TREE_STREAK}).`);
77080
+ return {
77081
+ ok: true,
77082
+ note: "relaunched; no UI tree returned \u2014 call mobile_snapshot.",
77083
+ emptyUiTreeStreak: streak,
77084
+ permanent: streak >= MAX_EMPTY_UI_TREE_STREAK
77085
+ };
76978
77086
  }
77087
+ clearEmptyUiTreeStreak();
76979
77088
  const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_relaunch");
76980
77089
  return {
76981
77090
  ok: true,
@@ -80207,6 +80316,10 @@ var require_mobile_discovery_prompts = __commonJS({
80207
80316
  "use strict";
80208
80317
  Object.defineProperty(exports, "__esModule", { value: true });
80209
80318
  exports.MOBILE_SCENARIO_SYSTEM_PROMPT = exports.MOBILE_SURVEY_SYSTEM_PROMPT = void 0;
80319
+ exports.isScrubbedPlaceholderName = isScrubbedPlaceholderName;
80320
+ exports.isOpaqueMobileScreenName = isOpaqueMobileScreenName;
80321
+ exports.isPackageLeafAppCatchAll = isPackageLeafAppCatchAll;
80322
+ exports.isLowValueMobileAutoDeepFeature = isLowValueMobileAutoDeepFeature;
80210
80323
  exports.formatMobileEvidenceForPlanner = formatMobileEvidenceForPlanner;
80211
80324
  exports.transitionSourceLabel = transitionSourceLabel;
80212
80325
  exports.buildMobileSiteMap = buildMobileSiteMap;
@@ -80260,7 +80373,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
80260
80373
  5. CRUD screens for the same entity belong together
80261
80374
  6. Do NOT merge screens into one feature just because they serve a similar broader goal. Treat areas as separate features when the evidence shows separate primary navigation destinations (e.g. separate tab-bar items or drawer links), separate screen families for distinct entities, or different CRUD entities/forms. Keep screens together when they share one navigational area and operate on the same entity (e.g. a list screen, its filter/sort sheet, and its detail screen all belong together).
80262
80375
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
80263
- 8. Do NOT create standalone features for generic error / empty / loading / intermediate screens (network-error, permission-denied, blank WebView, spinner-only loading). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description. If a screen has no actionable controls and only says it is empty/loading/intermediate, omit it from featureGroups.routes unless it is the only evidence for a real product feature.
80376
+ 8. Do NOT create standalone features for generic error / empty / loading / intermediate screens (network-error, permission-denied, blank WebView, spinner-only loading). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description. If a screen has no actionable controls and only says it is empty/loading/intermediate, omit it from featureGroups.routes unless it is the only evidence for a real product feature. Never name a feature after a scrub/placeholder token such as "<number>", "<identity>", "<email>", or a bare number \u2014 those are privacy redactions of dynamic content, not product features. Attach those screens to the nearest real product feature instead.
80264
80377
  9. COVER EVERY PRODUCT SCREEN: every explored product screen except generic error / permission / empty / loading / intermediate screens MUST appear in exactly one feature group's routes. Do not leave usable screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous usable screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
80265
80378
  10. LOGGED-OUT vs LOGGED-IN SEPARATION: screens shown BEFORE the user is signed in (splash, landing/welcome, theme or language pickers, walkthroughs, sign-up, sign-in, forgot-password, demo/preview screens) must NEVER share a feature group with the signed-in app. Put sign-in / sign-up / forgot-password in "Authentication" (rule 4) and the remaining pre-login screens in an "Onboarding" group. Features named after signed-in areas (feeds, dashboards, tabs, settings) must contain ONLY screens reached after authentication. A pre-login flow is a linear sequence, not a navigation destination \u2014 do not let it swallow the app's main features.
80266
80379
  11. OUTPUT SIZE: screen-ids are long opaque values. List each one exactly once in the routes array, but do NOT repeat them in pages or flows. Omit pages and flows; the harness deterministically reconstructs those fields from the captured graph. This is mandatory for large maps so the JSON is not truncated.
@@ -80305,6 +80418,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
80305
80418
  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"]\`
80306
80419
  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"]\`
80307
80420
  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.
80421
+ 13b. **Session-mode match \u2014 no guest-wall scenarios when the explore session was authenticated.** If the explorer evidence shows authenticated product screens (requiresAuth / post-login provenance / preserved-session product), do NOT plan scenarios that expect a guest-only gate after a product action (e.g. "Like prompts Sign In", "Add to playlist requires Log In", "Download requires Sign In"). Those gates do not appear for the signed-in session the runner will use to re-execute. Prefer the authenticated outcome actually observed (action succeeds, toggle flips to Unlike, sheet/picker opens, share sheet appears). Guest-wall / sign-in-prompt scenarios are valid ONLY when the explorer evidence for THAT action was recorded while signed out, or the scenario is an Authentication feature flow.
80308
80422
  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.
80309
80423
  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>."
80310
80424
  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.
@@ -80459,10 +80573,26 @@ Each entry is exactly one of:
80459
80573
  function screenDisplayName(state2) {
80460
80574
  return state2.screenName?.trim() || state2.screenId;
80461
80575
  }
80576
+ function isScrubbedPlaceholderName(value) {
80577
+ const trimmed = value.trim();
80578
+ if (!trimmed)
80579
+ return false;
80580
+ if (/^<[^<>\s]+>$/.test(trimmed))
80581
+ return true;
80582
+ if (/^(number|identity|email|id|url|token|value|secret|date|time)$/i.test(trimmed)) {
80583
+ return true;
80584
+ }
80585
+ const stripped = trimmed.replace(/<[^<>\s]+>/g, " ").replace(/[^a-z0-9]+/gi, " ").trim();
80586
+ if (!stripped)
80587
+ return true;
80588
+ return /^(number|identity|email|id|url|token|value|secret|date|time)(\s+(number|identity|email|id|url|token|value|secret|date|time))*$/i.test(stripped);
80589
+ }
80462
80590
  function isOpaqueMobileScreenName(value, appId) {
80463
80591
  const trimmed = value.trim();
80464
80592
  if (!trimmed)
80465
80593
  return false;
80594
+ if (isScrubbedPlaceholderName(trimmed))
80595
+ return true;
80466
80596
  const lower = trimmed.toLowerCase();
80467
80597
  const lowerAppId = appId?.trim().toLowerCase();
80468
80598
  if (lowerAppId && (lower === lowerAppId || lower.startsWith(`${lowerAppId}#`) || lower.startsWith(`${lowerAppId}.`))) {
@@ -80470,6 +80600,32 @@ Each entry is exactly one of:
80470
80600
  }
80471
80601
  return /^[a-z][\w$]*(?:\.[a-z][\w$]*)+(?:#[a-z0-9_. -]+)?$/i.test(trimmed);
80472
80602
  }
80603
+ function isPackageLeafAppCatchAll(name, appId) {
80604
+ const trimmed = name.trim();
80605
+ if (!trimmed || !appId?.trim())
80606
+ return false;
80607
+ const match = trimmed.match(/^(.+?)\s+app$/i);
80608
+ if (!match)
80609
+ return false;
80610
+ const brand = match[1].trim();
80611
+ if (!brand || /\s/.test(brand))
80612
+ return false;
80613
+ const leaf = appId.split(".").map((p) => p.trim()).filter(Boolean).pop();
80614
+ if (!leaf)
80615
+ return false;
80616
+ const normalize = (s) => s.replace(/[^a-z0-9]/gi, "").toLowerCase();
80617
+ return normalize(brand) === normalize(leaf);
80618
+ }
80619
+ function isLowValueMobileAutoDeepFeature(name, appId) {
80620
+ const trimmed = name.trim();
80621
+ if (!trimmed)
80622
+ return true;
80623
+ if (isScrubbedPlaceholderName(trimmed) || isOpaqueMobileScreenName(trimmed, appId))
80624
+ return true;
80625
+ if (isPackageLeafAppCatchAll(trimmed, appId))
80626
+ return true;
80627
+ return false;
80628
+ }
80473
80629
  function humanizeToken(token) {
80474
80630
  const spaced = token.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
80475
80631
  if (!spaced)
@@ -80484,11 +80640,12 @@ Each entry is exactly one of:
80484
80640
  }
80485
80641
  function nonGenericScreenName(state2, appId) {
80486
80642
  const raw = state2.screenName?.trim();
80487
- if (!raw || isOpaqueMobileScreenName(raw, appId))
80643
+ if (!raw || isOpaqueMobileScreenName(raw, appId) || isScrubbedPlaceholderName(raw))
80488
80644
  return null;
80489
80645
  const human = humanizeToken(raw.replace(/(?:Activity|ViewController|Fragment|Screen|Page)$/i, ""));
80490
- if (!human || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human))
80646
+ if (!human || isScrubbedPlaceholderName(human) || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human)) {
80491
80647
  return null;
80648
+ }
80492
80649
  return human;
80493
80650
  }
80494
80651
  function inferMobileFeatureGroupName(states, appId) {
@@ -81217,6 +81374,45 @@ ${constraints.join("\n")}`);
81217
81374
  return finalGroups;
81218
81375
  }
81219
81376
  var WEB_URL_PREFIX_FALLBACK_LOG_RE = /\bFallback:\s*\d+\s+feature groups? from URL-prefix grouping\b/i;
81377
+ function graphHasAuthenticatedProduct(graph) {
81378
+ for (const state2 of graph.screens.values()) {
81379
+ if (state2.requiresAuth === true)
81380
+ return true;
81381
+ if (state2.authProvenance === "post-submit-authenticated" || state2.authProvenance === "preserved-session-product") {
81382
+ return true;
81383
+ }
81384
+ }
81385
+ return false;
81386
+ }
81387
+ function isGuestWallScenario(scenario) {
81388
+ const feature = (scenario.featureGroup ?? "").toLowerCase();
81389
+ if (/auth|sign[\s-]?in|log[\s-]?in|onboarding/.test(feature))
81390
+ return false;
81391
+ const stepText = (scenario.steps ?? []).map((step) => typeof step === "string" ? step : step.action ?? "").join(" ");
81392
+ const outcomeText = (scenario.expectedOutcomes ?? []).map((outcome) => outcome.description ?? "").join(" ");
81393
+ const blob = [scenario.name, scenario.goal, stepText, outcomeText].filter(Boolean).join("\n").toLowerCase();
81394
+ if (/prompt(?:s)?\s+sign[\s-]?in|sign[\s-]?in\s+to\s+\w+|requires?\s+(?:log|sign)[\s-]?in|must\s+(?:log|sign)[\s-]?in|guest\s+(?:only|wall|gate|mode)|log[\s-]?in\s+to\s+(?:continue|like|save|download|comment|follow|share)/i.test(blob)) {
81395
+ return true;
81396
+ }
81397
+ const productAction = /\b(like|unlike|download|playlist|comment|follow|share|clip|save|favorite|bookmark|subscribe|purchase|buy|checkout)\b/i.test(blob);
81398
+ const assertsLoginGate = /\b(?:assert|verify|expect|see|show|display|prompts?)\b[\s\S]{0,40}\b(?:log[\s-]?in|sign[\s-]?in)\b/i.test(blob) || /\b(?:log[\s-]?in|sign[\s-]?in)\b[\s\S]{0,40}\b(?:dialog|sheet|modal|prompt|gate|wall|required)\b/i.test(blob);
81399
+ return productAction && assertsLoginGate;
81400
+ }
81401
+ function filterGuestWallScenariosForAuthSession(scenarios, graph) {
81402
+ if (scenarios.length === 0 || !graphHasAuthenticatedProduct(graph)) {
81403
+ return { scenarios, dropped: 0 };
81404
+ }
81405
+ const kept = [];
81406
+ let dropped = 0;
81407
+ for (const scenario of scenarios) {
81408
+ if (isGuestWallScenario(scenario)) {
81409
+ dropped += 1;
81410
+ continue;
81411
+ }
81412
+ kept.push(scenario);
81413
+ }
81414
+ return { scenarios: kept, dropped };
81415
+ }
81220
81416
  function selectMobilePlannerFeatureGroups(result, graph, appId, mode, existingFeatureNames = []) {
81221
81417
  if (mode !== "survey") {
81222
81418
  return { accepted: result.featureGroups, rejected: [] };
@@ -81330,7 +81526,12 @@ ${constraints.join("\n")}`);
81330
81526
  }
81331
81527
  const plannerFeatureGroups = filterFeatureGroupsToSiteMap(plannerGroupsForFiltering, siteMap, input.graph, input.appId, input.mode === "survey");
81332
81528
  const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
81333
- if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
81529
+ const guestWallFilter = input.mode !== "survey" ? filterGuestWallScenariosForAuthSession(result.scenarios, input.graph) : { scenarios: result.scenarios, dropped: 0 };
81530
+ if (guestWallFilter.dropped > 0) {
81531
+ mobileAdaptationLogs.push(`Dropped ${guestWallFilter.dropped} guest-wall scenario(s) \u2014 explore session was authenticated; re-exec would not see sign-in gates`);
81532
+ }
81533
+ const plannedScenarios = guestWallFilter.scenarios;
81534
+ if (input.mode !== "survey" && plannedScenarios.length === 0 && input.graph.screens.size > 0) {
81334
81535
  const fallbackScenarios = buildMobileFallbackScenarios(input);
81335
81536
  if (fallbackScenarios.length > 0) {
81336
81537
  return {
@@ -81349,6 +81550,7 @@ ${constraints.join("\n")}`);
81349
81550
  if (input.featureName?.trim()) {
81350
81551
  return {
81351
81552
  ...result,
81553
+ scenarios: plannedScenarios,
81352
81554
  featureGroups: mobileFeatureGroups,
81353
81555
  logs: [...result.logs, ...mobileAdaptationLogs],
81354
81556
  warning: [result.warning, `Deep discovery could not plan any scenarios for "${input.featureName}" \u2014 the explorer did not reach that feature's screens this run. If the feature requires a signed-in session, verify TEST_USER_EMAIL / TEST_USER_PASSWORD are valid for this app, then re-run.`].filter(Boolean).join(" | "),
@@ -81385,6 +81587,7 @@ ${constraints.join("\n")}`);
81385
81587
  }
81386
81588
  return {
81387
81589
  ...result,
81590
+ scenarios: plannedScenarios,
81388
81591
  featureGroups: mobileFeatureGroups,
81389
81592
  logs: [...result.logs, ...mobileAdaptationLogs]
81390
81593
  };
@@ -81454,6 +81657,7 @@ var require_mobile_generator = __commonJS({
81454
81657
  exports.coalesceRepeatedTaps = coalesceRepeatedTaps;
81455
81658
  exports.elideNoOpDetours = elideNoOpDetours;
81456
81659
  exports.mobileActionsToPseudoPlaywright = mobileActionsToPseudoPlaywright;
81660
+ exports.isEphemeralContentAssertText = isEphemeralContentAssertText;
81457
81661
  exports.evaluateMobileAssertionStrength = evaluateMobileAssertionStrength;
81458
81662
  exports.prepareMobileScenarioAppState = prepareMobileScenarioAppState;
81459
81663
  exports.runMobileGeneratePhase = runMobileGeneratePhase;
@@ -81914,15 +82118,20 @@ ${hybridSkeleton.join("\n")}`;
81914
82118
  - Every element is addressed by a ref like e1, e2. Refs are ONLY valid for the most recent mobile_snapshot. Re-snapshot after every action that changes the screen, then use the NEW refs.
81915
82119
  - mobile_tap/mobile_type/mobile_clear take a ref; the loop resolves it to coordinates AND records the durable locator for replay \u2014 you never pass coordinates.
81916
82120
 
81917
- ## Your job
82121
+ ## Your job \u2014 feature fidelity (non-negotiable)
82122
+ The scenario name + goal + steps describe ONE product behavior from survey/discovery. The generated test MUST implement that behavior on THIS app \u2014 not a random walk through tabs.
82123
+
81918
82124
  1. mobile_snapshot the current screen.
81919
- 2. Execute the scenario's steps using the real UI: tap buttons, fill fields (mobile_type), submit, navigate.
81920
- 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, change password, edit profile), you MUST commit it \u2014 tap the submit/primary button OR call mobile_submit_keyboard on the last field when no button is visible. Filling fields alone is NOT the behavior under test. Navigating to a form screen without changing its fields is also NOT enough for mutation scenarios (change/create/update/edit). If it navigates, you MUST land on the destination. A "happy path" that fills a form but never commits \u2014 or only opens the form without typing \u2014 is WRONG.
81921
- 4. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
81922
- - mobile_assert_visible({ref}) \u2014 the target screen's stable element is on screen.
81923
- - mobile_assert_text({ref, expected}) \u2014 an element contains expected text (only durable when the element has a stable accessibility-id / resource-id).
81924
- - 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.
81925
- 5. Call finish_generate with verified:true ONLY if you asserted the outcome with an assert tool.
82125
+ 2. Execute the scenario's steps using the real UI: tap the controls that perform the goal, fill fields (mobile_type), submit, navigate to the destination named in the goal.
82126
+ 3. COMPLETE THE ACTION the scenario is about \u2014 do not stop half-way:
82127
+ - Form scenarios (sign in, sign up, search, create, change password, edit profile): you MUST commit \u2014 tap submit/primary OR mobile_submit_keyboard. Filling alone is NOT enough. Opening a form without typing is NOT enough for mutation goals.
82128
+ - Content actions (like, share, follow, bookmark, download, \u2026): you MUST tap the control that performs that action \u2014 tab thrash / swipes alone are WRONG.
82129
+ - Navigation scenarios: you MUST land on the destination and assert something unique to it.
82130
+ 4. PROVE the outcome with an assert tool that matches the scenario \u2014 not ambient chrome:
82131
+ - mobile_assert_visible({ref}) / mobile_assert_text({ref, expected})
82132
+ - After sign-in \u2192 signed-in product signal; after like \u2192 liked/unlike control or durable like state; after open Settings \u2192 a settings-specific control.
82133
+ - Do NOT assert bottom-nav tab labels alone, notification badge counts ("N unread"), or one-time empty-state CTAs as the scenario outcome.
82134
+ 5. Call finish_generate with verified:true ONLY if you completed the goal AND asserted a matching durable outcome. If the app cannot complete the goal, finish with verified:false and explain \u2014 never invent a green path from unrelated screens.
81926
82135
 
81927
82136
  ## Rules
81928
82137
  - Re-snapshot before every assertion so the ref is fresh.
@@ -81973,7 +82182,7 @@ ${stepLines || " (no explicit steps \u2014 explore the goal directly)"}
81973
82182
  Expected outcomes to PROVE with assert tools:
81974
82183
  ${outcomeLines || " - The target screen is reached and a stable element is visible."}
81975
82184
 
81976
- Start by calling mobile_snapshot, then execute the steps above \u2014 the minimum actions needed to reach the goal, without repeating, reverting, or exploring beyond them. Finish with at least one mobile_assert_visible / mobile_assert_text proving the outcome, then finish_generate.`;
82185
+ Start by calling mobile_snapshot, then execute ONLY the steps needed for this goal \u2014 no tab thrash, no unrelated screens. Every recorded action becomes a permanent test step. Finish with at least one assert that proves THIS goal's outcome (from Expected outcomes), then finish_generate. If you cannot perform the goal on this app build, finish_generate with verified:false rather than a fake green path.`;
81977
82186
  }
81978
82187
  function buildStatePacket(input) {
81979
82188
  const { scenario, iteration, maxIterations, latestObservation, assertCount, actionCount } = input;
@@ -82030,7 +82239,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
82030
82239
  }
82031
82240
  }
82032
82241
  async function runScenarioLoop(args) {
82033
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog } = args;
82242
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch } = args;
82034
82243
  const log2 = (line) => emitLog(redactCredentialValues(line, redactionCredentials));
82035
82244
  const actions = [];
82036
82245
  let assertCount = 0;
@@ -82604,6 +82813,7 @@ ${statePacket}`, redactionCredentials) },
82604
82813
  setLatest,
82605
82814
  credentials,
82606
82815
  screenName: () => lastScreenName || "UnknownScreen",
82816
+ allowRelaunch,
82607
82817
  recordAction: (action) => {
82608
82818
  const screenHash = getLatest()?.signal.screenHash;
82609
82819
  const structuralKey = structuralScreenKey(getLatest());
@@ -82638,7 +82848,7 @@ ${statePacket}`, redactionCredentials) },
82638
82848
  return { actions, assertCount, finish, lastSnapshot: lastTargetSnapshot, lastScreenName: lastTargetScreenName };
82639
82849
  }
82640
82850
  async function dispatchGenerateTool(args) {
82641
- const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, recordAction, recordedActions, log: log2 } = args;
82851
+ const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, allowRelaunch, recordAction, recordedActions, log: log2 } = args;
82642
82852
  const unknownRef = (ref) => ({
82643
82853
  ok: false,
82644
82854
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -83028,6 +83238,16 @@ ${statePacket}`, redactionCredentials) },
83028
83238
  return actionPayload(result, absorbed);
83029
83239
  }
83030
83240
  case "mobile_relaunch": {
83241
+ if (!allowRelaunch) {
83242
+ log2(" relaunch refused under PRESERVE_APP_DATA \u2014 staying on current app instance.");
83243
+ const snap2 = await driver.snapshot();
83244
+ const absorbed2 = await absorbObservation(snap2.xml, snap2.screenSignal, snap2.windowSize, snap2.screenshot, "mobile_relaunch_blocked");
83245
+ return {
83246
+ ok: false,
83247
+ error: "mobile_relaunch is blocked while app data is preserved. Continue from the current screen.",
83248
+ observation: absorbed2.observation
83249
+ };
83250
+ }
83031
83251
  await driver.relaunch();
83032
83252
  const snap = await driver.snapshot();
83033
83253
  const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
@@ -83246,14 +83466,48 @@ ${statePacket}`, redactionCredentials) },
83246
83466
  });
83247
83467
  return { ok: true, label: metadata.targetLabel ?? accessibilityId ?? text ?? "element" };
83248
83468
  }
83469
+ var AMBIENT_CHROME_LABEL_RE = /^(your\s*profile|you|home|search|profile|more|settings|notifications?|tab\s*\d+(\s*of\s*\d+)?|you,\s*tab.*)$/i;
83470
+ function isDisplayableLandingCandidate(el, windowSize) {
83471
+ if (el.visible === false)
83472
+ return false;
83473
+ const { x, y, width, height } = el.bounds;
83474
+ if (!(width > 0 && height > 0))
83475
+ return false;
83476
+ if (width < 8 || height < 8)
83477
+ return false;
83478
+ if (windowSize && windowSize.width > 0 && windowSize.height > 0) {
83479
+ const centerX = x + width / 2;
83480
+ const centerY = y + height / 2;
83481
+ if (centerX < 0 || centerY < 0 || centerX > windowSize.width || centerY > windowSize.height) {
83482
+ return false;
83483
+ }
83484
+ if (width >= windowSize.width * 0.92 && height >= windowSize.height * 0.55) {
83485
+ return false;
83486
+ }
83487
+ }
83488
+ return true;
83489
+ }
83490
+ function landingAnchorSortKey(el) {
83491
+ const label = (nonEmpty3(el.label) || nonEmpty3(el.text) || "").trim();
83492
+ const ambient = AMBIENT_CHROME_LABEL_RE.test(label) ? 1 : 0;
83493
+ const labelled = nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83494
+ const area = Math.max(1, el.bounds.width * el.bounds.height);
83495
+ const areaBucket = area < 8e4 ? 0 : area < 25e4 ? 1 : 2;
83496
+ return [ambient, labelled, areaBucket];
83497
+ }
83249
83498
  function pickLandingAnchor(snapshot) {
83250
83499
  if (!snapshot || snapshot.screen.elements.length === 0)
83251
83500
  return null;
83252
83501
  const elements = snapshot.screen.elements;
83253
- const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
83254
- stableAnchors.sort((a, b) => {
83255
- const labelled2 = (el) => nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83256
- return labelled2(a) - labelled2(b);
83502
+ const windowSize = snapshot.screen.windowSize ?? null;
83503
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)).sort((a, b) => {
83504
+ const ka = landingAnchorSortKey(a);
83505
+ const kb = landingAnchorSortKey(b);
83506
+ for (let i = 0; i < ka.length; i += 1) {
83507
+ if (ka[i] !== kb[i])
83508
+ return ka[i] - kb[i];
83509
+ }
83510
+ return 0;
83257
83511
  });
83258
83512
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83259
83513
  if (contentfulNonButton)
@@ -83263,8 +83517,10 @@ ${statePacket}`, redactionCredentials) },
83263
83517
  return nonButton;
83264
83518
  if (stableAnchors.length > 0)
83265
83519
  return stableAnchors[0];
83266
- const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83267
- return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
83520
+ const labelled = elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83521
+ if (labelled)
83522
+ return labelled;
83523
+ return elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)) ?? null;
83268
83524
  }
83269
83525
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
83270
83526
  var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
@@ -83362,10 +83618,19 @@ ${statePacket}`, redactionCredentials) },
83362
83618
  const anchor = pickLandingAnchor(recording.lastSnapshot);
83363
83619
  if (!anchor)
83364
83620
  return false;
83621
+ const modelAssertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83622
+ const anchorLabel = (nonEmpty3(anchor.label) || nonEmpty3(anchor.text) || "").trim();
83623
+ if (modelAssertCount > 0 && AMBIENT_CHROME_LABEL_RE.test(anchorLabel)) {
83624
+ return false;
83625
+ }
83626
+ const anchorSelector = (0, mobile_source_parser_js_2.preferredLocator)(anchor);
83627
+ const alreadyAsserted = actions.some((a) => (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT") && typeof a.selector === "string" && a.selector === anchorSelector);
83628
+ if (alreadyAsserted)
83629
+ return false;
83365
83630
  const screen = recording.lastScreenName || "LandingScreen";
83366
83631
  actions.push(finalizeAction({
83367
83632
  type: "ASSERT_VISIBLE",
83368
- selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83633
+ selector: anchorSelector,
83369
83634
  metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83370
83635
  }));
83371
83636
  return true;
@@ -83384,6 +83649,84 @@ ${statePacket}`, redactionCredentials) },
83384
83649
  "element-hidden",
83385
83650
  "screen-visible"
83386
83651
  ]);
83652
+ var CONTENT_ACTION_VERB_RE = /\b(like|unlike|favorite|favourite|unfavorite|unfavourite|heart|unheart|follow|unfollow|share|repost|retweet|bookmark|unbookmark|save\s+to|download|subscribe|unsubscribe|block|report|unpin|pin\s+(?:post|item|message|tweet)|mute|unmute|react|upvote|downvote|clap|boost)\b/i;
83653
+ var NAV_CHROME_TAP_RE = /\b(home|you|profile|search|settings|notifications?|inbox|messages?|explore|library|more|menu|tab|bottom\s*nav|videos?|create|feed)\b/i;
83654
+ var VOLATILE_BADGE_ASSERT_RE = /\b\d+\s*(unread|notifications?|messages?|likes?|followers?|comments?|views?)\b|\bunread\b.*\b\d+\b/i;
83655
+ function isContentActionScenario(scenario) {
83656
+ const text = scenarioText(scenario);
83657
+ if (!CONTENT_ACTION_VERB_RE.test(text))
83658
+ return false;
83659
+ if (/\b(view|see|show|open|browse|list|count)\s+(the\s+)?(likes?|followers?|shares?|bookmarks?)\b/i.test(text)) {
83660
+ return false;
83661
+ }
83662
+ return true;
83663
+ }
83664
+ function isContentActionTap(action) {
83665
+ if (action.type !== "TAP")
83666
+ return false;
83667
+ const textForAction = actionText(action);
83668
+ if (NAV_CHROME_TAP_RE.test(textForAction) && !CONTENT_ACTION_VERB_RE.test(textForAction)) {
83669
+ return false;
83670
+ }
83671
+ return CONTENT_ACTION_VERB_RE.test(textForAction);
83672
+ }
83673
+ function isProductiveScenarioAction(action, scenarioTextLower = "") {
83674
+ if (action.type === "TYPE" || action.type === "CLEAR")
83675
+ return true;
83676
+ if (action.type === "TAP") {
83677
+ const t = actionText(action);
83678
+ if (CONTENT_ACTION_VERB_RE.test(t) || isFormCommitAction(action) || isCredentialSubmitTap(action)) {
83679
+ return true;
83680
+ }
83681
+ if (NAV_CHROME_TAP_RE.test(t)) {
83682
+ if (!scenarioTextLower)
83683
+ return false;
83684
+ const chromeWords = t.match(/\b(home|you|profile|search|settings|notifications?|inbox|messages?|explore|library|more|menu|videos?|create|feed)\b/gi) ?? [];
83685
+ return chromeWords.some((w) => scenarioTextLower.includes(w.toLowerCase()));
83686
+ }
83687
+ return true;
83688
+ }
83689
+ return false;
83690
+ }
83691
+ function isNavChromeThrashTrace(actions) {
83692
+ const meaningful = actions.filter((a) => a.type !== "WAIT_FOR_ELEMENT");
83693
+ if (meaningful.length === 0)
83694
+ return false;
83695
+ const hasNavOrGesture = meaningful.some((a) => a.type === "TAP" || a.type === "SWIPE" || a.type === "BACK" || a.type === "HOME");
83696
+ if (!hasNavOrGesture)
83697
+ return false;
83698
+ return meaningful.every((a) => {
83699
+ if (a.type === "SWIPE" || a.type === "BACK" || a.type === "HOME")
83700
+ return true;
83701
+ if (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT") {
83702
+ const t = actionText(a);
83703
+ return NAV_CHROME_TAP_RE.test(t) || VOLATILE_BADGE_ASSERT_RE.test(t) || isStructuralOnlyAssert(a);
83704
+ }
83705
+ if (a.type === "TAP") {
83706
+ return NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a));
83707
+ }
83708
+ return false;
83709
+ });
83710
+ }
83711
+ function isHarnessOrVisibilityScenario(scenario) {
83712
+ const text = scenarioText(scenario);
83713
+ return /\b(avoid|recover|guard|remain|stay\s+in|does\s+not\s+record|external|outside\s+the\s+app|inspect|visibility\s+proof|landing\s+screen|smoke|structural\s+assert|skip\s+link|global\s+navigation\s+anchors?)\b/i.test(text);
83714
+ }
83715
+ function requiresFeatureProgress(scenario) {
83716
+ if (isNavigationFocusedScenario(scenario))
83717
+ return false;
83718
+ if (isCancelDiscardScenario(scenario))
83719
+ return false;
83720
+ if (isContentActionScenario(scenario))
83721
+ return true;
83722
+ if (isHarnessOrVisibilityScenario(scenario))
83723
+ return false;
83724
+ const text = scenarioText(scenario);
83725
+ const behaviorClaim = scenario.type === "HAPPY_PATH" || scenario.type === "USER_JOURNEY" || /\b(happy\s*path|successful|success|complete|user\s+can|should\s+be\s+able)\b/i.test(text);
83726
+ if (!behaviorClaim)
83727
+ return false;
83728
+ return /\b(like|unlike|share|follow|bookmark|download|subscribe|create|save|send|submit|edit|delete|remove|add|play|search|type|fill|login|sign\s*in|log\s*in|register|checkout|update|change|open|view|browse|filter|sort|upload|invite|join)\b/i.test(text);
83729
+ }
83387
83730
  function isInspectionOnlyScenario(scenario, actions) {
83388
83731
  if (actions.length === 0 || scenario.expectedOutcomes.length === 0)
83389
83732
  return false;
@@ -83393,16 +83736,25 @@ ${statePacket}`, redactionCredentials) },
83393
83736
  if (actions.some((a) => a.type === "TYPE" || a.type === "CLEAR")) {
83394
83737
  return false;
83395
83738
  }
83739
+ if (isContentActionScenario(scenario)) {
83740
+ return false;
83741
+ }
83396
83742
  const text = scenarioText(scenario);
83397
- const visibilityOnlyGoal = (/\b(entry\s+points?|providers?|options?|provider\s+buttons?)\b/.test(text) || /\b(show|display)\s+(all|the)?\s*(provider|option|button|link|entry)/.test(text) || /\b(all\s+(three\s+)?providers?)\b/.test(text)) && !/\b(change|update|create|save|send|register|edit|with\s+valid|successful\s+(sign|log)|complete\s+(the\s+)?(login|sign[- ]?in)|submit\s+(the\s+)?(form|credentials)|enter\s+(an?\s+)?(email|password))\b/.test(text);
83743
+ const visibilityOnlyGoal = (/\b(entry\s+points?|providers?|provider\s+buttons?|sign[- ]?in\s+options|auth(?:entication)?\s+options|available\s+options)\b/.test(text) || /\b(show|display)\s+(all\s+)?(the\s+)?(providers?|options|buttons?|links?|entry\s+points?)\b/.test(text) || /\b(all\s+(three\s+)?providers?)\b/.test(text)) && !/\b(change|update|create|save|send|register|edit|with\s+valid|successful\s+(sign|log)|complete\s+(the\s+)?(login|sign[- ]?in)|submit\s+(the\s+)?(form|credentials)|enter\s+(an?\s+)?(email|password))\b/.test(text);
83398
83744
  if (visibilityOnlyGoal) {
83399
83745
  const assertCount2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83400
83746
  return assertCount2 > 0;
83401
83747
  }
83402
- const completionIntent = scenario.type === "HAPPY_PATH" || /\b(happy\s*path|successful|with\s+valid|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout|purchase))\b/.test(text);
83748
+ const completionIntent = scenario.type === "HAPPY_PATH" || scenario.type === "USER_JOURNEY" || /\b(happy\s*path|successful|with\s+valid|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout|purchase)|user\s+can|should\s+be\s+able)\b/.test(text);
83403
83749
  const formIntent = /\b(sign\s*in|log\s*in|login|authenticate|register|sign\s*up|checkout|create|save|submit|send|change|update|edit)\b/.test(text);
83404
83750
  if (completionIntent && formIntent)
83405
83751
  return false;
83752
+ if (completionIntent && /\b(like|unlike|share|follow|bookmark|download|subscribe|create|save|send|post|delete|remove|add|play|pause)\b/i.test(text)) {
83753
+ return false;
83754
+ }
83755
+ if (completionIntent) {
83756
+ return false;
83757
+ }
83406
83758
  const assertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83407
83759
  return assertCount > 0;
83408
83760
  }
@@ -83412,7 +83764,18 @@ ${statePacket}`, redactionCredentials) },
83412
83764
  const completesFlow = /\b(happy\s*path|successful|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout)|with\s+valid)\b/.test(text);
83413
83765
  return navigates && !completesFlow;
83414
83766
  }
83767
+ function isCancelDiscardScenario(scenario) {
83768
+ const text = scenarioText(scenario);
83769
+ if (/\bcancel\s+(?:the|my|a|an|this|your|their)?\s*(subscription|order|booking|trip|reservation|membership|plan|purchase|payment|appointment|ticket|flight|ride)\b/i.test(text)) {
83770
+ return false;
83771
+ }
83772
+ const cancelEditPhrase = /\b(cancel|discard|revert|undo)\s+(?:(?:the|my|a|an|this|your|their|all)\s+)?(?:unsaved\s+)?(change|changes|edit|edits|update|updates|draft|input|inputs)\b/i.test(text) || /\b(change|changes|edit|edits|update|updates|draft)\s+(?:are\s+)?(cancelled|canceled|discarded|reverted|undone)\b/i.test(text) || /\breject\s+(?:(?:the|my|a|an|this|your|their|all)\s+)?(change|changes|edit|edits|update|updates|draft)\b/i.test(text);
83773
+ const discardWithoutSave = /\b(without\s+saving|do\s+not\s+save|don't\s+save)\b/i.test(text) && /\b(cancel|discard|revert|undo|leave|exit|back|dismiss)\b/i.test(text);
83774
+ return cancelEditPhrase || discardWithoutSave;
83775
+ }
83415
83776
  function isFormValidationScenario(scenario) {
83777
+ if (isCancelDiscardScenario(scenario))
83778
+ return false;
83416
83779
  if (scenario.variant === "validation")
83417
83780
  return true;
83418
83781
  if (scenario.type !== "EDGE_CASE")
@@ -83423,6 +83786,8 @@ ${statePacket}`, redactionCredentials) },
83423
83786
  function isFormMutationHappyPath(scenario) {
83424
83787
  if (isNavigationFocusedScenario(scenario))
83425
83788
  return false;
83789
+ if (isCancelDiscardScenario(scenario))
83790
+ return false;
83426
83791
  if (isFormValidationScenario(scenario))
83427
83792
  return false;
83428
83793
  const text = scenarioText(scenario);
@@ -83435,9 +83800,14 @@ ${statePacket}`, redactionCredentials) },
83435
83800
  if (/\b(show|display|visible|options?|providers?|links?)\b/.test(text) && !/\b(change|update|create|save|send|register|edit|post|write|compose|delete|add|set)\b/.test(text)) {
83436
83801
  return false;
83437
83802
  }
83438
- const mutates = /\b(change|update|create|save|send|register|sign\s*up|checkout|edit|post|write|compose|set|add|delete|remove)\b/.test(text);
83439
- const hasObject = /\b(password|email|profile|message|post|item|record|form|settings|account|name|title|description|comment|bio|phone|address|payment|card|user|note|task|entry)\b/.test(text);
83440
- return mutates && hasObject;
83803
+ if (/\b(play|pause|resume|playback|seek|volume|mute|unmute|shuffle|repeat|now\s+playing|mini\s*player)\b/.test(text) && !/\b(playlist\s+name|edit\s+(track|song|clip)|upload|caption|comment|description|save\s+to|add\s+to\s+(a\s+)?playlist|create\s+(a\s+)?playlist|rename)\b/.test(text)) {
83804
+ return false;
83805
+ }
83806
+ const formObject = "password|email|profile|message|post|item|record|form|settings|account|name|title|description|comment|bio|phone|address|payment|card|user|note|task|entry";
83807
+ const mutationVerb = "change|update|create|save|send|register|sign\\s*up|checkout|edit|post|write|compose|set|add|delete|remove";
83808
+ const determiner = "(?:(?:the|a|an|my|your|their|this|that)\\s+)?(?:new\\s+)?";
83809
+ const mutatesFormObject = new RegExp(`\\b(?:${mutationVerb})\\s+${determiner}(?:${formObject})\\b`).test(text) || new RegExp(`\\b(?:${formObject})\\s+(?:${mutationVerb})\\b`).test(text);
83810
+ return mutatesFormObject;
83441
83811
  }
83442
83812
  function isNavigationChromeTap(action) {
83443
83813
  if (action.type !== "TAP")
@@ -83526,12 +83896,46 @@ ${statePacket}`, redactionCredentials) },
83526
83896
  }
83527
83897
  function scenarioIntentMismatchReason(scenario, actions) {
83528
83898
  const text = scenarioText(scenario);
83899
+ if (isContentActionScenario(scenario)) {
83900
+ const hasActionTap = actions.some((a) => isContentActionTap(a));
83901
+ if (!hasActionTap) {
83902
+ return "content-action scenario (like/share/follow/\u2026) did not record a tap on a matching control";
83903
+ }
83904
+ if (isNavChromeThrashTrace(actions)) {
83905
+ return "content-action scenario only recorded tab thrash / swipes \u2014 no product action";
83906
+ }
83907
+ const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83908
+ if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a)))) {
83909
+ return "content-action scenario only asserted tab chrome or a volatile badge count \u2014 assert the action outcome instead";
83910
+ }
83911
+ return null;
83912
+ }
83529
83913
  if (isInspectionOnlyScenario(scenario, actions)) {
83530
83914
  return null;
83531
83915
  }
83532
83916
  if (isNavigationFocusedScenario(scenario)) {
83533
83917
  return null;
83534
83918
  }
83919
+ if (isCancelDiscardScenario(scenario)) {
83920
+ const hasCancelTap = actions.some((a) => {
83921
+ if (a.type !== "TAP")
83922
+ return false;
83923
+ return /\b(cancel|discard|close|dismiss|revert|undo|not\s+now)\b/.test(actionText(a));
83924
+ });
83925
+ const lastNavIdx = (() => {
83926
+ for (let i = actions.length - 1; i >= 0; i--) {
83927
+ const t = actions[i].type;
83928
+ if (t === "TAP" || t === "BACK" || t === "TYPE" || t === "CLEAR")
83929
+ return i;
83930
+ }
83931
+ return -1;
83932
+ })();
83933
+ const backAsDismiss = lastNavIdx >= 0 && actions[lastNavIdx].type === "BACK" && /\b(back|leave|exit|dismiss)\b/i.test(text) && /\b(without\s+saving|do\s+not\s+save|don't\s+save|discard|cancel)\b/i.test(text);
83934
+ if (!hasCancelTap && !backAsDismiss) {
83935
+ return "cancel/discard scenario did not record a cancel/dismiss action";
83936
+ }
83937
+ return null;
83938
+ }
83535
83939
  if (isFormValidationScenario(scenario)) {
83536
83940
  const typedFields = actions.some((a) => a.type === "TYPE");
83537
83941
  const hasCommit = actions.some((a) => isFormCommitAction(a));
@@ -83625,30 +84029,42 @@ ${statePacket}`, redactionCredentials) },
83625
84029
  }
83626
84030
  }
83627
84031
  const isHappyPath = /\b(happy\s*path|successful|success|create|save|submit|complete|checkout|send|post)\b/.test(text) || scenario.type === "HAPPY_PATH";
83628
- if (!isHappyPath)
83629
- return null;
83630
- let lastTypeIndex = -1;
83631
- let lastAssertIndex = -1;
83632
- actions.forEach((action, index) => {
83633
- if (action.type === "TYPE")
83634
- lastTypeIndex = index;
83635
- if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
83636
- lastAssertIndex = index;
83637
- });
83638
- if (lastTypeIndex < 0 || lastAssertIndex <= lastTypeIndex)
83639
- return null;
83640
- const typeScreen = actions[lastTypeIndex]?.metadata?.screenName?.trim() ?? "";
83641
- const assertScreen = actions[lastAssertIndex]?.metadata?.screenName?.trim() ?? "";
83642
- const screensDiffer = typeScreen !== "" && assertScreen !== "" && typeScreen !== assertScreen;
83643
- const lastTypeAction = actions[lastTypeIndex];
83644
- const keyboardCommitOnLastType = lastTypeAction != null && isKeyboardFormCommit(lastTypeAction);
83645
- const commitAfterType = keyboardCommitOnLastType || actions.some((action, index) => {
83646
- if (index <= lastTypeIndex || index >= lastAssertIndex)
83647
- return false;
83648
- return isFormCommitAction(action);
83649
- });
83650
- if (screensDiffer && !commitAfterType) {
83651
- return "form fields were filled then a success assert was recorded without a commit/submit tap between them";
84032
+ if (isHappyPath) {
84033
+ let lastTypeIndex = -1;
84034
+ let lastAssertIndex = -1;
84035
+ actions.forEach((action, index) => {
84036
+ if (action.type === "TYPE")
84037
+ lastTypeIndex = index;
84038
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
84039
+ lastAssertIndex = index;
84040
+ });
84041
+ if (lastTypeIndex >= 0 && lastAssertIndex > lastTypeIndex) {
84042
+ const typeScreen = actions[lastTypeIndex]?.metadata?.screenName?.trim() ?? "";
84043
+ const assertScreen = actions[lastAssertIndex]?.metadata?.screenName?.trim() ?? "";
84044
+ const screensDiffer = typeScreen !== "" && assertScreen !== "" && typeScreen !== assertScreen;
84045
+ const lastTypeAction = actions[lastTypeIndex];
84046
+ const keyboardCommitOnLastType = lastTypeAction != null && isKeyboardFormCommit(lastTypeAction);
84047
+ const commitAfterType = keyboardCommitOnLastType || actions.some((action, index) => {
84048
+ if (index <= lastTypeIndex || index >= lastAssertIndex)
84049
+ return false;
84050
+ return isFormCommitAction(action);
84051
+ });
84052
+ if (screensDiffer && !commitAfterType) {
84053
+ return "form fields were filled then a success assert was recorded without a commit/submit tap between them";
84054
+ }
84055
+ }
84056
+ }
84057
+ if (isNavChromeThrashTrace(actions) && !isNavigationFocusedScenario(scenario) && !isFormMutationHappyPath(scenario) && !isFormValidationScenario(scenario) && !isCredentialSignIn && !isAuthLikeScenario && !isHarnessOrVisibilityScenario(scenario)) {
84058
+ return "recording is only bottom-nav thrash and swipes \u2014 does not execute the scenario goal";
84059
+ }
84060
+ if (requiresFeatureProgress(scenario) && !isFormValidationScenario(scenario) && !isCredentialSignIn && !isAuthLikeScenario) {
84061
+ if (!actions.some((a) => isProductiveScenarioAction(a, text))) {
84062
+ return "happy-path / user-journey scenario did not record a productive product action (only chrome, gestures, or empty work)";
84063
+ }
84064
+ const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84065
+ if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a))) {
84066
+ return "happy-path / user-journey scenario only asserted volatile badges or structural roots \u2014 assert a durable feature outcome";
84067
+ }
83652
84068
  }
83653
84069
  return null;
83654
84070
  }
@@ -83903,6 +84319,29 @@ ${statePacket}`, redactionCredentials) },
83903
84319
  lines.push("});");
83904
84320
  return lines.join("\n");
83905
84321
  }
84322
+ function isEphemeralContentAssertText(text) {
84323
+ const t = text.trim();
84324
+ if (!t)
84325
+ return false;
84326
+ if (/\b\d+\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|wk|wks|week|weeks|mo|mos|month|months|y|yr|yrs|year|years)\s+ago\b/i.test(t)) {
84327
+ return true;
84328
+ }
84329
+ if (/\b(just\s+now|moments?\s+ago|yesterday)\b/i.test(t))
84330
+ return true;
84331
+ if (/\btoday\s+at\s+\d{1,2}:\d{2}\b/i.test(t))
84332
+ return true;
84333
+ const feedish = /\b(followed|liked|commented|replied|mentioned|shared|posted|unread|notification|message from|new follower)\b/i.test(t) || /\b\d+\s*(s|m|h|d|w)\s+ago\b/i.test(t);
84334
+ if (feedish && t.length >= 70 && /[.!?].+\s/.test(t) && t.split(/\s+/).length >= 10) {
84335
+ return true;
84336
+ }
84337
+ if (t.length >= 40 && /\b(error\s+\d{3,}|generation failed|rate limit|quota exceeded)\b/i.test(t)) {
84338
+ return true;
84339
+ }
84340
+ return false;
84341
+ }
84342
+ function assertIdentityText(action) {
84343
+ return [action.selector, action.value, action.innerText, action.metadata?.targetLabel].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
84344
+ }
83906
84345
  function evaluateMobileAssertionStrength(actions) {
83907
84346
  const modelAsserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83908
84347
  if (modelAsserts.length === 0) {
@@ -83938,6 +84377,24 @@ ${statePacket}`, redactionCredentials) },
83938
84377
  }
83939
84378
  };
83940
84379
  }
84380
+ const ephemeral = modelAsserts.filter((a) => isEphemeralContentAssertText(assertIdentityText(a)));
84381
+ const durable = modelAsserts.filter((a) => !isEphemeralContentAssertText(assertIdentityText(a)));
84382
+ if (ephemeral.length > 0 && durable.length === 0) {
84383
+ return {
84384
+ ok: false,
84385
+ severity: "block",
84386
+ reason: "only ephemeral content assertions (relative times, long feed/notification copy, or backend error strings) \u2014 assert durable chrome or run-scoped data instead",
84387
+ signals: {
84388
+ apiTest: false,
84389
+ interacted: true,
84390
+ mutated: actions.some((a) => a.type === "TYPE"),
84391
+ navOnly: false,
84392
+ hasOutcomeProof: false,
84393
+ errorBanner: false,
84394
+ terminal: null
84395
+ }
84396
+ };
84397
+ }
83941
84398
  const code = mobileActionsToPseudoPlaywright(actions);
83942
84399
  const interacted = actions.some((a) => a.type === "TAP" || a.type === "TYPE" || a.type === "CLEAR");
83943
84400
  return (0, shared_1.validateAssertionStrength)(code, { interactionPerformed: interacted });
@@ -84241,7 +84698,8 @@ ${statePacket}`, redactionCredentials) },
84241
84698
  targetAppId,
84242
84699
  credentials: credentialSecrets,
84243
84700
  redactionCredentials: credentialValuesForRedaction,
84244
- log: log2
84701
+ log: log2,
84702
+ allowRelaunch: (ctx.target?.resetPolicy ?? "AUTO") !== "PRESERVE_APP_DATA"
84245
84703
  });
84246
84704
  consecutiveInfraFailures = 0;
84247
84705
  const test = assembleTest({
@@ -84812,6 +85270,7 @@ Reason over steps + error + tree + registry and return working healed steps. Do
84812
85270
  For a fix, return the COMPLETE steps array (not a diff).`;
84813
85271
  var DEFAULT_SCRIPT_MAX_ATTEMPTS = 6;
84814
85272
  var RECOVERABLE_MAX = 3;
85273
+ var MAX_CONSECUTIVE_INVALID_VERDICTS = 3;
84815
85274
  var BONUS_ATTEMPTS_CEIL = 4;
84816
85275
  var DEFAULT_BONUS_ATTEMPTS = 2;
84817
85276
  var MAX_LOG_CHARS = 4096;
@@ -85417,6 +85876,7 @@ ${args.directive}`);
85417
85876
  const triedLocators = [];
85418
85877
  let sameErrorCount = 0;
85419
85878
  let lastErrorSignature = null;
85879
+ let consecutiveInvalidVerdicts = 0;
85420
85880
  const markRecoverableTurn = () => {
85421
85881
  if (recoverableUsed < RECOVERABLE_MAX) {
85422
85882
  attemptCap += 1;
@@ -85490,11 +85950,22 @@ ${args.directive}`);
85490
85950
  aiResponseCount += 1;
85491
85951
  const parsed = parseHealVerdict(content);
85492
85952
  if (!parsed) {
85493
- options.onProgress?.(`Phase 1 attempt ${attempt}: response was not a valid verdict (${content.length} chars, finish=${finishReason ?? "unknown"}) \u2014 granting a recovery turn`);
85953
+ consecutiveInvalidVerdicts += 1;
85954
+ options.onProgress?.(`Phase 1 attempt ${attempt}: response was not a valid verdict (${content.length} chars, finish=${finishReason ?? "unknown"}) \u2014 granting a recovery turn (${consecutiveInvalidVerdicts}/${MAX_CONSECUTIVE_INVALID_VERDICTS} consecutive)`);
85955
+ if (consecutiveInvalidVerdicts >= MAX_CONSECUTIVE_INVALID_VERDICTS) {
85956
+ options.onProgress?.(`Phase 1 aborted: ${consecutiveInvalidVerdicts} consecutive invalid verdicts \u2014 freeing device`);
85957
+ return {
85958
+ passed: false,
85959
+ verdict: "config_issue",
85960
+ healReason: `heal model returned ${consecutiveInvalidVerdicts} consecutive non-verdict responses (last ${content.length} chars, finish=${finishReason ?? "unknown"}) \u2014 aborting to free the device`,
85961
+ attemptsUsed: attempt
85962
+ };
85963
+ }
85494
85964
  markRecoverableTurn();
85495
- pendingDirective = "Your last response was not valid JSON matching the verdict schema. Return exactly one JSON verdict object.";
85965
+ pendingDirective = "Your last response was not valid JSON matching the verdict schema. Return exactly one JSON verdict object \u2014 no prose, no markdown fences.";
85496
85966
  continue;
85497
85967
  }
85968
+ consecutiveInvalidVerdicts = 0;
85498
85969
  if (TERMINAL_VERDICTS.has(parsed.verdict)) {
85499
85970
  return { passed: false, verdict: parsed.verdict, healReason: parsed.reason, attemptsUsed: attempt };
85500
85971
  }
@@ -337174,10 +337645,11 @@ Fatal error: ${msg}`);
337174
337645
  const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
337175
337646
  if (shouldPreserveAppData) {
337176
337647
  try {
337177
- await driver.relaunch();
337648
+ await driver.launchApp();
337649
+ log2("App activated with preserved data (no terminate/clear).");
337178
337650
  } catch (err) {
337179
337651
  (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
337180
- log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337652
+ log2(`App activate failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337181
337653
  }
337182
337654
  return false;
337183
337655
  }
@@ -352880,9 +353352,9 @@ var require_dist2 = __commonJS({
352880
353352
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p);
352881
353353
  };
352882
353354
  Object.defineProperty(exports, "__esModule", { value: true });
352883
- exports.resolveRefFromSnapshot = exports.replayAuthRecipeIntoBrowser = exports.runRegisterCaptureRun = exports.runLoginCaptureRun = exports.filterLoginCaptureCredentials = exports.buildLoginCaptureCredentials = exports.isReservedRunnerEnvVar = exports.RESERVED_RUNNER_ENV_VARS = exports.AUTH_MARKER_VERSION = exports.buildHealAuthStamp = exports.extractAuthRegion = exports.upsertAuthBlock = exports.buildAuthRecipe = exports.commandToReplayStep = exports.buildReplayPlan = exports.absoluteRoute = exports.renderReplaySettleArrow = exports.renderReplayClickArrow = exports.renderAuthFn = exports.filterFeatureGroupsToPathScope = exports.isAuthFlowRoute = exports.deriveSurveyPathScope = exports.canonicalizeRoute = exports.codeUsesInboxHelper = exports.codeUsesTestInboxIdentity = exports.codeSelfProvisionsRegistration = exports.executeGrokPerBatchHeal = exports.executeGrokPerTestHeal = exports.headerVal = exports.classifyAuthType = exports.isTextExcerptable = exports.isStaticAssetByExt = exports.isApiCall = exports.buildApiCallSummary = exports.computeMobileHealOutcome = exports.detectMobileStepSecretLeak = exports.redactMobileSecretsFromText = exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports.executeMobileLiveHeal = exports.executeMobileTestWithHealing = exports.executeMobileScriptDrivenHeal = exports.prepareMobileScenarioAppState = exports.runMobileGeneratePhase = exports.runMobileAIPlanner = exports.buildMobileSiteMap = exports.formatMobileEvidenceForPlanner = exports.MOBILE_SCENARIO_SYSTEM_PROMPT = exports.MOBILE_SURVEY_SYSTEM_PROMPT = exports.runMobileExplorePhase = void 0;
352884
- exports.MAX_MANIFEST_FILES = exports.toDiffSummary = exports.isLikelyTestFile = exports.sanitizeRepoRelPath = exports.sha256Hex = exports.hashFiles = exports.enumerateTestFiles = exports.buildNextManifest = exports.detectRepoSwitch = exports.classifyImportDiff = exports.parseImportManifest = exports.validateAuthoredMobileTest = exports.buildMobileRepoAuthorSystemRules = exports.buildMobileRepoAuthorKickoff = exports.buildMobileRepoAuthorContractMd = exports.REPO_ANALYSIS_SCHEMA_VERSION = exports.validateSourceEndpoints = exports.validateAuthoredTest = exports.validateRepoMapFile = exports.validateRepoMemoryFile = exports.buildTestingStandardsSection = exports.buildRepoAuthorSystemRules = exports.buildRepoAuthorKickoff = exports.buildRepoAuthorContractMd = exports.buildRepoMapSystemRules = exports.buildRepoMapKickoff = exports.buildRepoMapContractMd = exports.buildRepoMemorySystemRules = exports.buildRepoMemoryKickoff = exports.buildRepoMemoryContractMd = exports.runRepoAnalysisOnRunner = exports.MOBILE_IMPORT_SCHEMA_VERSION = exports.validateImportedMobileTest = exports.buildMobileImportSystemRules = exports.buildMobileImportKickoff = exports.buildMobileImportContractMd = exports.IMPORT_SCHEMA_VERSION = exports.validateImportResultFile = exports.validateImportedTest = exports.buildImportSystemRules = exports.buildImportKickoff = exports.buildImportContractMd = exports.MAX_ARCHIVE_BYTES = exports.isUnsafeEntryName = exports.extractZipSafely = exports.downloadArchiveCapped = exports.detectArchiveKind = exports.acquireRepoForImport = exports.runFrameworkImportOnRunner = exports.getAgenticEngine = void 0;
352885
- exports.BrokeredInboxReader = exports.applyPlusTag = exports.deriveProjectInboxTag = exports.deriveInboxTag = exports.loadVerificationInboxConfig = exports.VerificationInboxReader = exports.UNSUPPORTED_RENDERER_PREFIX = exports.UnsupportedRendererError = exports.buildCanvasModeAuthoringAddendum = exports.buildUnsupportedRendererMessage = exports.modelSupportsVision = exports.decideCanvasStrategy = exports.decideAppRenderType = exports.detectCanvasApp = exports.detectRateLimitBlock = exports.isSafeCanonicalRedirect = exports.resolveBaseUrlPreflight = exports.checkBaseUrlRedirect = exports.truncateAtLine = exports.neutralizeUntrustedBlock = exports.renderUntrustedKnowledgeBlock = exports.stripJsonFences = exports.scrubCredentials = exports.withAIRetry = exports.snapshotMatchingChildren = exports.findNewChromiumChildPid = exports.killProcessTree = exports.gracefulCloseWithTreeKill = exports.liveBrowserRegistry = exports.REPO_SWITCH_REMOVED_FRACTION = void 0;
353355
+ exports.filterLoginCaptureCredentials = exports.buildLoginCaptureCredentials = exports.isReservedRunnerEnvVar = exports.RESERVED_RUNNER_ENV_VARS = exports.AUTH_MARKER_VERSION = exports.buildHealAuthStamp = exports.extractAuthRegion = exports.upsertAuthBlock = exports.buildAuthRecipe = exports.commandToReplayStep = exports.buildReplayPlan = exports.absoluteRoute = exports.renderReplaySettleArrow = exports.renderReplayClickArrow = exports.renderAuthFn = exports.filterFeatureGroupsToPathScope = exports.isAuthFlowRoute = exports.deriveSurveyPathScope = exports.canonicalizeRoute = exports.codeUsesInboxHelper = exports.codeUsesTestInboxIdentity = exports.codeSelfProvisionsRegistration = exports.executeGrokPerBatchHeal = exports.executeGrokPerTestHeal = exports.headerVal = exports.classifyAuthType = exports.isTextExcerptable = exports.isStaticAssetByExt = exports.isApiCall = exports.buildApiCallSummary = exports.computeMobileHealOutcome = exports.detectMobileStepSecretLeak = exports.redactMobileSecretsFromText = exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports.executeMobileLiveHeal = exports.executeMobileTestWithHealing = exports.executeMobileScriptDrivenHeal = exports.prepareMobileScenarioAppState = exports.runMobileGeneratePhase = exports.isLowValueMobileAutoDeepFeature = exports.isPackageLeafAppCatchAll = exports.isOpaqueMobileScreenName = exports.isScrubbedPlaceholderName = exports.runMobileAIPlanner = exports.buildMobileSiteMap = exports.formatMobileEvidenceForPlanner = exports.MOBILE_SCENARIO_SYSTEM_PROMPT = exports.MOBILE_SURVEY_SYSTEM_PROMPT = exports.runMobileExplorePhase = void 0;
353356
+ exports.sha256Hex = exports.hashFiles = exports.enumerateTestFiles = exports.buildNextManifest = exports.detectRepoSwitch = exports.classifyImportDiff = exports.parseImportManifest = exports.validateAuthoredMobileTest = exports.buildMobileRepoAuthorSystemRules = exports.buildMobileRepoAuthorKickoff = exports.buildMobileRepoAuthorContractMd = exports.REPO_ANALYSIS_SCHEMA_VERSION = exports.validateSourceEndpoints = exports.validateAuthoredTest = exports.validateRepoMapFile = exports.validateRepoMemoryFile = exports.buildTestingStandardsSection = exports.buildRepoAuthorSystemRules = exports.buildRepoAuthorKickoff = exports.buildRepoAuthorContractMd = exports.buildRepoMapSystemRules = exports.buildRepoMapKickoff = exports.buildRepoMapContractMd = exports.buildRepoMemorySystemRules = exports.buildRepoMemoryKickoff = exports.buildRepoMemoryContractMd = exports.runRepoAnalysisOnRunner = exports.MOBILE_IMPORT_SCHEMA_VERSION = exports.validateImportedMobileTest = exports.buildMobileImportSystemRules = exports.buildMobileImportKickoff = exports.buildMobileImportContractMd = exports.IMPORT_SCHEMA_VERSION = exports.validateImportResultFile = exports.validateImportedTest = exports.buildImportSystemRules = exports.buildImportKickoff = exports.buildImportContractMd = exports.MAX_ARCHIVE_BYTES = exports.isUnsafeEntryName = exports.extractZipSafely = exports.downloadArchiveCapped = exports.detectArchiveKind = exports.acquireRepoForImport = exports.runFrameworkImportOnRunner = exports.getAgenticEngine = exports.resolveRefFromSnapshot = exports.replayAuthRecipeIntoBrowser = exports.runRegisterCaptureRun = exports.runLoginCaptureRun = void 0;
353357
+ exports.BrokeredInboxReader = exports.applyPlusTag = exports.deriveProjectInboxTag = exports.deriveInboxTag = exports.loadVerificationInboxConfig = exports.VerificationInboxReader = exports.UNSUPPORTED_RENDERER_PREFIX = exports.UnsupportedRendererError = exports.buildCanvasModeAuthoringAddendum = exports.buildUnsupportedRendererMessage = exports.modelSupportsVision = exports.decideCanvasStrategy = exports.decideAppRenderType = exports.detectCanvasApp = exports.detectRateLimitBlock = exports.isSafeCanonicalRedirect = exports.resolveBaseUrlPreflight = exports.checkBaseUrlRedirect = exports.truncateAtLine = exports.neutralizeUntrustedBlock = exports.renderUntrustedKnowledgeBlock = exports.stripJsonFences = exports.scrubCredentials = exports.withAIRetry = exports.snapshotMatchingChildren = exports.findNewChromiumChildPid = exports.killProcessTree = exports.gracefulCloseWithTreeKill = exports.liveBrowserRegistry = exports.REPO_SWITCH_REMOVED_FRACTION = exports.MAX_MANIFEST_FILES = exports.toDiffSummary = exports.isLikelyTestFile = exports.sanitizeRepoRelPath = void 0;
352886
353358
  __exportStar(require_ai_provider(), exports);
352887
353359
  __exportStar(require_mobile_types(), exports);
352888
353360
  __exportStar(require_mobile_source_parser(), exports);
@@ -352907,6 +353379,18 @@ var require_dist2 = __commonJS({
352907
353379
  Object.defineProperty(exports, "runMobileAIPlanner", { enumerable: true, get: function() {
352908
353380
  return mobile_discovery_prompts_js_1.runMobileAIPlanner;
352909
353381
  } });
353382
+ Object.defineProperty(exports, "isScrubbedPlaceholderName", { enumerable: true, get: function() {
353383
+ return mobile_discovery_prompts_js_1.isScrubbedPlaceholderName;
353384
+ } });
353385
+ Object.defineProperty(exports, "isOpaqueMobileScreenName", { enumerable: true, get: function() {
353386
+ return mobile_discovery_prompts_js_1.isOpaqueMobileScreenName;
353387
+ } });
353388
+ Object.defineProperty(exports, "isPackageLeafAppCatchAll", { enumerable: true, get: function() {
353389
+ return mobile_discovery_prompts_js_1.isPackageLeafAppCatchAll;
353390
+ } });
353391
+ Object.defineProperty(exports, "isLowValueMobileAutoDeepFeature", { enumerable: true, get: function() {
353392
+ return mobile_discovery_prompts_js_1.isLowValueMobileAutoDeepFeature;
353393
+ } });
352910
353394
  var mobile_generator_js_1 = require_mobile_generator();
352911
353395
  Object.defineProperty(exports, "runMobileGeneratePhase", { enumerable: true, get: function() {
352912
353396
  return mobile_generator_js_1.runMobileGeneratePhase;
@@ -353342,7 +353826,7 @@ var require_package4 = __commonJS({
353342
353826
  "package.json"(exports, module) {
353343
353827
  module.exports = {
353344
353828
  name: "@validate.qa/runner",
353345
- version: "1.0.84",
353829
+ version: "1.0.86",
353346
353830
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353347
353831
  bin: {
353348
353832
  "validate-runner": "dist/cli.js"
@@ -354208,9 +354692,9 @@ async function waitForColdBootSettle(sessionPath) {
354208
354692
  timeoutMs: MOBILE_COLD_BOOT_SETTLE_MS,
354209
354693
  minStableFrames: 2,
354210
354694
  pollMs: 400,
354211
- // WebView native shell alone is ~5–8 nodes; real first screens flatten to
354212
- // dozens. Require enough nodes that the DOM has painted (mirrors executor).
354213
- minElements: 12
354695
+ // Native splash / WebView shell is ~5–8 nodes; RN product screens and rich
354696
+ // onboarding typically flatten to 15+ once JS has painted.
354697
+ minElements: 15
354214
354698
  });
354215
354699
  }
354216
354700
  async function getWindowSize(sessionPath) {
@@ -354359,20 +354843,33 @@ async function sendKeysToFocusedInput(sessionPath, value) {
354359
354843
  }
354360
354844
  async function typeText(sessionPath, value, bounds) {
354361
354845
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
354846
+ if (!elementId && bounds === void 0) {
354847
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
354848
+ }
354362
354849
  if (elementId) {
354363
354850
  try {
354364
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354851
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
354365
354852
  method: "POST",
354366
- body: JSON.stringify({ text: value })
354853
+ body: JSON.stringify({})
354367
354854
  });
354368
- return;
354369
354855
  } catch (err) {
354370
354856
  if (isAppiumDriverDeadError(err)) throw err;
354371
354857
  }
354372
- } else if (bounds === void 0) {
354373
- throw new Error("No focused element available for TYPE. Tap an input field first.");
354374
354858
  }
354375
- await sendKeysToFocusedInput(sessionPath, value);
354859
+ try {
354860
+ await sendKeysToFocusedInput(sessionPath, value);
354861
+ return;
354862
+ } catch (err) {
354863
+ if (isAppiumDriverDeadError(err)) throw err;
354864
+ }
354865
+ if (elementId) {
354866
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354867
+ method: "POST",
354868
+ body: JSON.stringify({ text: value })
354869
+ });
354870
+ return;
354871
+ }
354872
+ throw new Error("TYPE failed: could not send keys to the focused input.");
354376
354873
  }
354377
354874
  async function submitKeyboard(sessionPath, bounds) {
354378
354875
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
@@ -355241,7 +355738,7 @@ async function pressHome(sessionPath, platform3) {
355241
355738
  // src/services/device-quarantine.ts
355242
355739
  var QUARANTINE_MAX_MS = (() => {
355243
355740
  const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
355244
- return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
355741
+ return Number.isFinite(raw) && raw > 0 ? raw : 2 * 60 * 1e3;
355245
355742
  })();
355246
355743
  var quarantinedAt = /* @__PURE__ */ new Map();
355247
355744
  var nextQuarantineToken = 1;
@@ -356741,6 +357238,12 @@ async function createSession(target) {
356741
357238
  // Scoped to simulator UDIDs (a physical iOS device has no such step);
356742
357239
  // override with APPIUM_IOS_HEADLESS=false to force a visible Simulator run.
356743
357240
  ...isIosSimulatorUdid(target.deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
357241
+ // WDA cold-start is flaky under dual-sim load (xcodebuild 65/75, SIGTERM).
357242
+ // Retry startup instead of failing the whole discovery session once.
357243
+ "appium:wdaStartupRetries": 4,
357244
+ "appium:wdaStartupRetryInterval": 2e3,
357245
+ "appium:wdaLaunchTimeout": 12e4,
357246
+ "appium:wdaConnectionTimeout": 12e4,
356744
357247
  ...parallelCaps,
356745
357248
  ...realCaps
356746
357249
  } : {
@@ -356752,6 +357255,12 @@ async function createSession(target) {
356752
357255
  "appium:noReset": true,
356753
357256
  // See the iOS branch: the 60s default kills AI-driven discovery sessions.
356754
357257
  "appium:newCommandTimeout": MOBILE_NEW_COMMAND_TIMEOUT_SECONDS,
357258
+ // Default UIA2 server launch is 30s — under dual-emu / post-quarantine
357259
+ // stress instrumentation often needs longer or the session never binds
357260
+ // ("instrumentation process cannot be initialized within 30000ms").
357261
+ "appium:uiautomator2ServerLaunchTimeout": 12e4,
357262
+ "appium:uiautomator2ServerInstallTimeout": 12e4,
357263
+ "appium:adbExecTimeout": 12e4,
356755
357264
  "appium:chromedriverExecutableDir": ensureWritableChromedriverExecutableDir(),
356756
357265
  ...parallelCaps,
356757
357266
  ...realCaps
@@ -359520,6 +360029,9 @@ async function startSession(validated, res) {
359520
360029
  ...deviceId ? { "appium:udid": deviceId } : {},
359521
360030
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
359522
360031
  "appium:noReset": true,
360032
+ // Keep pace with executor createSession: Expo "Open in App?" sheets and
360033
+ // permission dialogs otherwise freeze WDA for recording/mirror sessions.
360034
+ ...process.env.APPIUM_IOS_AUTO_ACCEPT_ALERTS !== "false" ? { "appium:autoAcceptAlerts": true } : {},
359523
360035
  // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
359524
360036
  // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
359525
360037
  // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
@@ -359527,6 +360039,12 @@ async function startSession(validated, res) {
359527
360039
  // overrides. Verified live: without this, recorder/mirror sessions on iOS
359528
360040
  // die with "Simulator.app does not exist" while executor sessions work.
359529
360041
  ...isIosSimulatorUdid(deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
360042
+ // Match executor createSession: tolerate WDA cold-start flakes.
360043
+ "appium:newCommandTimeout": Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT) > 0 ? Math.floor(Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT)) : 600,
360044
+ "appium:wdaStartupRetries": 4,
360045
+ "appium:wdaStartupRetryInterval": 2e3,
360046
+ "appium:wdaLaunchTimeout": 12e4,
360047
+ "appium:wdaConnectionTimeout": 12e4,
359530
360048
  ...realCaps
359531
360049
  } : {
359532
360050
  platformName: "Android",
@@ -359535,6 +360053,10 @@ async function startSession(validated, res) {
359535
360053
  ...deviceId ? { "appium:udid": deviceId } : {},
359536
360054
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
359537
360055
  "appium:noReset": true,
360056
+ "appium:newCommandTimeout": Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT) > 0 ? Math.floor(Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT)) : 600,
360057
+ "appium:uiautomator2ServerLaunchTimeout": 12e4,
360058
+ "appium:uiautomator2ServerInstallTimeout": 12e4,
360059
+ "appium:adbExecTimeout": 12e4,
359538
360060
  "appium:chromedriverExecutableDir": ensureWritableChromedriverExecutableDir(),
359539
360061
  ...realCaps
359540
360062
  };
@@ -360626,11 +361148,33 @@ var MobileBridgeDriver = class {
360626
361148
  */
360627
361149
  async snapshot() {
360628
361150
  return this.withSessionRecovery("snapshot", async () => {
360629
- const [xml, screenshot, windowSize] = await Promise.all([
360630
- getPageSource(this.sessionPath),
360631
- getScreenshot(this.sessionPath),
360632
- getWindowSize(this.sessionPath)
360633
- ]);
361151
+ let xml = await getPageSource(this.sessionPath);
361152
+ let screenshot = await getScreenshot(this.sessionPath);
361153
+ let windowSize = await getWindowSize(this.sessionPath);
361154
+ if (!xml) {
361155
+ try {
361156
+ await waitForUiSettle(this.sessionPath, {
361157
+ timeoutMs: 8e3,
361158
+ minStableFrames: 2,
361159
+ pollMs: 300,
361160
+ minElements: 1
361161
+ });
361162
+ } catch (err) {
361163
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
361164
+ }
361165
+ xml = await getPageSource(this.sessionPath);
361166
+ }
361167
+ if (!xml) {
361168
+ try {
361169
+ await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
361170
+ await waitForColdBootSettle(this.sessionPath);
361171
+ } catch (err) {
361172
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
361173
+ }
361174
+ xml = await getPageSource(this.sessionPath);
361175
+ if (!screenshot) screenshot = await getScreenshot(this.sessionPath);
361176
+ if (!windowSize) windowSize = await getWindowSize(this.sessionPath);
361177
+ }
360634
361178
  if (!xml && !screenshot && !windowSize) {
360635
361179
  await this.assertSessionReachable();
360636
361180
  }
@@ -363570,14 +364114,17 @@ ${finalVerifyResult.logs ?? ""}`;
363570
364114
  // Android default) — a personal physical device must never have
363571
364115
  // its app data wiped.
363572
364116
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
363573
- maxSessionRecoveries: 1,
364117
+ // Dual-device discovery + long AI turns can lose WDA/UIA2 more than
364118
+ // once per run. One recovery was too tight — the next snapshot
364119
+ // failed as "no UI tree" after a single WDA death.
364120
+ maxSessionRecoveries: 3,
363574
364121
  recoverSession: async (cause) => {
363575
364122
  if (discoveryTeardownStarted) {
363576
364123
  throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
363577
364124
  }
363578
364125
  const lost = session;
363579
364126
  mobileOnLog(
363580
- `[appium] discovery session lost (${cause.message}); recreating once on the leased device`
364127
+ `[appium] discovery session lost (${cause.message}); recreating on the leased device`
363581
364128
  );
363582
364129
  if (lost) {
363583
364130
  detachMirrorSession(lost.deviceId);