@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.js CHANGED
@@ -975,10 +975,11 @@ var require_mobile_source_parser = __commonJS({
975
975
  function extractIOS(attrs) {
976
976
  const role = nonEmpty3(attrs.type) ?? "XCUIElementTypeOther";
977
977
  const name = nonEmpty3(attrs.name);
978
+ const identifier = nonEmpty3(attrs.identifier) ?? nonEmpty3(attrs.accessibilityIdentifier);
978
979
  const labelAttr = nonEmpty3(attrs.label);
979
980
  const value = nonEmpty3(attrs.value);
980
981
  const label = name ?? labelAttr ?? value;
981
- const accessibilityId = name;
982
+ const accessibilityId = identifier ?? name;
982
983
  const text = labelAttr ?? (isStateValueRole(role) ? void 0 : value);
983
984
  const bounds = parseIOSBounds(attrs);
984
985
  const enabled = attrBoolDefaultTrue(attrs.enabled);
@@ -1019,7 +1020,8 @@ var require_mobile_source_parser = __commonJS({
1019
1020
  const isGenericView = /android\.view\.View$/i.test(role);
1020
1021
  const moderateHitTarget = hasArea && bounds.width >= 24 && bounds.height >= 24 && bounds.width <= 2e3 && bounds.height <= 1800;
1021
1022
  const webHybridLabelledControl = isGenericView && hasHumanIdentity && moderateHitTarget && (focusable || clickable || longClickable || Boolean(contentDesc));
1022
- const actionable = enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1023
+ 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);
1024
+ const actionable = !isAnrChrome && enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1023
1025
  return {
1024
1026
  role,
1025
1027
  label,
@@ -1197,6 +1199,7 @@ var require_mobile_source_parser = __commonJS({
1197
1199
  element.secure = fields.secure;
1198
1200
  if (fields.enabled !== void 0)
1199
1201
  element.enabled = fields.enabled;
1202
+ element.visible = fields.visible;
1200
1203
  if (isCredibleStructuralNavigationTarget(element) && chainHasNativeNavigationSemantics(chain, platform3)) {
1201
1204
  markNavigation(element, "native-navigation-semantics");
1202
1205
  }
@@ -1571,6 +1574,8 @@ var require_mobile_boundary = __commonJS({
1571
1574
  "../runner-core/dist/services/mobile/mobile-boundary.js"(exports2) {
1572
1575
  "use strict";
1573
1576
  Object.defineProperty(exports2, "__esModule", { value: true });
1577
+ exports2.isAndroidAnrSystemResourceId = isAndroidAnrSystemResourceId;
1578
+ exports2.looksLikeAndroidAnrUi = looksLikeAndroidAnrUi;
1574
1579
  exports2.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1575
1580
  exports2.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1576
1581
  exports2.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
@@ -1588,6 +1593,28 @@ var require_mobile_boundary = __commonJS({
1588
1593
  "com.miui.securitycenter",
1589
1594
  "com.samsung.android.permissioncontroller"
1590
1595
  ]);
1596
+ 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;
1597
+ var ANDROID_ANR_TEXT_RE = /isn['’]?t responding|isn['’]?t responding\.|anr|close app|wait/i;
1598
+ function isAndroidAnrSystemResourceId(resourceId) {
1599
+ const rid = resourceId?.trim();
1600
+ if (!rid)
1601
+ return false;
1602
+ if (/(?:android:id\/)?aerr_/i.test(rid))
1603
+ return true;
1604
+ return ANDROID_ANR_RESOURCE_ID_RE.test(rid) && /aerr_/i.test(rid);
1605
+ }
1606
+ function looksLikeAndroidAnrUi(input) {
1607
+ if (isAndroidAnrSystemResourceId(input.resourceId))
1608
+ return true;
1609
+ const rid = input.resourceId?.trim() ?? "";
1610
+ const blob = [input.text, input.label, input.contentDesc].filter(Boolean).join(" ");
1611
+ if (/(?:android:id\/)?(?:alertTitle|aerr_)/i.test(rid) && ANDROID_ANR_TEXT_RE.test(blob)) {
1612
+ return true;
1613
+ }
1614
+ if (/isn['’]?t responding/i.test(blob))
1615
+ return true;
1616
+ return false;
1617
+ }
1591
1618
  var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1592
1619
  "com.apple.springboard",
1593
1620
  "com.apple.authenticationservicesui",
@@ -1745,7 +1772,7 @@ var init_constants = __esm({
1745
1772
  MOBILE_APP_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?$/;
1746
1773
  MOBILE_FOCUS_SETTLE_MS = 150;
1747
1774
  MOBILE_POST_ACTION_SETTLE_MS = 1800;
1748
- MOBILE_COLD_BOOT_SETTLE_MS = 12e3;
1775
+ MOBILE_COLD_BOOT_SETTLE_MS = 25e3;
1749
1776
  MOBILE_DEFAULT_SWIPE_DISTANCE = 500;
1750
1777
  MOBILE_MAX_SWIPE_DISTANCE = 2e3;
1751
1778
  SELECTOR_PRIORITIES = [
@@ -74887,7 +74914,7 @@ var require_mobile_explorer = __commonJS({
74887
74914
  type: "function",
74888
74915
  function: {
74889
74916
  name: "mobile_relaunch",
74890
- 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.",
74917
+ 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.",
74891
74918
  parameters: { type: "object", properties: {} }
74892
74919
  }
74893
74920
  };
@@ -75687,11 +75714,11 @@ Rules:
75687
75714
  **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.
75688
75715
  **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.
75689
75716
 
75690
- 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.
75717
+ 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.
75691
75718
 
75692
75719
  ## Boundaries (survey = map, do not mutate)
75693
75720
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
75694
- - 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.
75721
+ - 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).
75695
75722
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
75696
75723
 
75697
75724
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -75916,6 +75943,34 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
75916
75943
  latest = boundaryResolved;
75917
75944
  lastSnapshotIteration = iterationForObservation;
75918
75945
  if (boundary.kind === "transient_external") {
75946
+ const isHostHomeShell = /springboard|systemui/i.test(boundary.owner);
75947
+ if (isHostHomeShell) {
75948
+ log2(` Host shell detected from ${source}: ${boundary.owner}; activating ${boundary.targetAppId} so exploration can continue.`);
75949
+ try {
75950
+ await driver.launchApp();
75951
+ const snap = await driver.snapshot();
75952
+ const recovered = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
75953
+ if (recovered) {
75954
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
75955
+ if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
75956
+ const enriched = await enrichResolvedSnapshotWithHybridWeb(recovered, driver, credentialValuesForRedaction, log2, platform3, targetAppId, hybridContextCache, true, source !== "seed snapshot" && source !== "mobile_snapshot");
75957
+ latest = enriched;
75958
+ lastSnapshotIteration = iterationForObservation;
75959
+ const screenId = ingestSnapshot(enriched, iterationForObservation);
75960
+ captureScreenshot(enriched.replacementScreenshot ?? snap.screenshot, screenId);
75961
+ return {
75962
+ observation: enriched.observation,
75963
+ screenId,
75964
+ transientExternal: true,
75965
+ externalOwner: boundary.owner
75966
+ };
75967
+ }
75968
+ }
75969
+ } catch (err) {
75970
+ (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
75971
+ log2(` Failed to recover from host shell ${boundary.owner}: ${err instanceof Error ? err.message : String(err)}`);
75972
+ }
75973
+ }
75919
75974
  log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
75920
75975
  return {
75921
75976
  observation: `${boundaryResolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
@@ -76102,6 +76157,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76102
76157
  const kickoff = buildKickoffMessage(mode);
76103
76158
  const recentExchanges = [];
76104
76159
  const transientDirectives = [];
76160
+ const resetPolicyForTools = ctx.target?.resetPolicy ?? "AUTO";
76161
+ const preserveAppDataForTools = resetPolicyForTools === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicyForTools === "AUTO";
76105
76162
  const tools = [
76106
76163
  MOBILE_SNAPSHOT_TOOL,
76107
76164
  MOBILE_TAP_TOOL,
@@ -76111,7 +76168,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76111
76168
  MOBILE_SWIPE_TOOL,
76112
76169
  MOBILE_BACK_TOOL,
76113
76170
  MOBILE_HOME_TOOL,
76114
- MOBILE_RELAUNCH_TOOL,
76171
+ // Omit relaunch under preserve — model otherwise cold-restarts RN into splash.
76172
+ ...preserveAppDataForTools ? [] : [MOBILE_RELAUNCH_TOOL],
76115
76173
  MOBILE_OPEN_DEEPLINK_TOOL,
76116
76174
  CAPTURE_SCREEN_TOOL,
76117
76175
  RECORD_TRANSITION_TOOL,
@@ -76130,6 +76188,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76130
76188
  let summary = "";
76131
76189
  let truncated = false;
76132
76190
  let truncatedReason;
76191
+ const emptyUiTreeStreak = { count: 0 };
76133
76192
  let finishRedirects = 0;
76134
76193
  let lastFinishBreadthRemainingItems = null;
76135
76194
  let lastFinishBreadthProgressMark = null;
@@ -76467,10 +76526,18 @@ Exploration complete: ${summary}`);
76467
76526
  credentials: credentialSecrets,
76468
76527
  redactionCredentials: credentialValuesForRedaction,
76469
76528
  authTracker,
76470
- inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null
76529
+ inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null,
76530
+ allowRelaunch: !preserveAppDataForTools,
76531
+ emptyUiTreeStreak
76471
76532
  });
76472
76533
  pushResult(handled);
76473
76534
  const handledRecord = handled && typeof handled === "object" ? handled : null;
76535
+ if (handledRecord?.permanent === true && handledRecord?.emptyUiTreeStreak) {
76536
+ explorationComplete = true;
76537
+ summary = typeof handledRecord.reason === "string" && handledRecord.reason.trim() ? handledRecord.reason : "Accessibility tree remained empty after repeated Appium observations; exploration stopped early.";
76538
+ log2(`
76539
+ Stopping Phase 1 early: empty UI tree thrash (streak=${handledRecord.emptyUiTreeStreak}).`);
76540
+ }
76474
76541
  const navigationWasAttempted = handledRecord?.ok !== false || Object.prototype.hasOwnProperty.call(handledRecord ?? {}, "screenId") || Object.prototype.hasOwnProperty.call(handledRecord ?? {}, "observation");
76475
76542
  if (DEVICE_MUTATION_BARRIER_TOOLS.has(toolName) && navigationWasAttempted) {
76476
76543
  navigationBarrierTool = toolName;
@@ -76559,14 +76626,24 @@ Exploration complete: ${summary}`);
76559
76626
  }
76560
76627
  return result;
76561
76628
  }
76629
+ var MAX_EMPTY_UI_TREE_STREAK = 3;
76562
76630
  async function dispatchMobileTool(args) {
76563
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, resolveHybridRef, currentBoundary, collectedTransitions, counters, journeys, log: log2, credentials, redactionCredentials, authTracker, inbox } = args;
76631
+ 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;
76632
+ const noteEmptyUiTree = () => {
76633
+ emptyUiTreeStreak.count += 1;
76634
+ return emptyUiTreeStreak.count;
76635
+ };
76636
+ const clearEmptyUiTreeStreak = () => {
76637
+ emptyUiTreeStreak.count = 0;
76638
+ };
76564
76639
  const absorbActionResult = (actionResult, source, preferCredentialSecondary = false) => {
76565
76640
  (0, mobile_driver_errors_js_1.throwIfTerminalMobileActionFailure)(actionResult, source);
76566
76641
  const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
76567
76642
  if (!resolved) {
76643
+ noteEmptyUiTree();
76568
76644
  return Promise.resolve({ observation: "(no UI tree returned)", screenId: null });
76569
76645
  }
76646
+ clearEmptyUiTreeStreak();
76570
76647
  return absorbResolvedObservation(resolved, actionResult.screenshot, iteration, source, preferCredentialSecondary);
76571
76648
  };
76572
76649
  const currentAuthSurface = () => {
@@ -76596,8 +76673,17 @@ Exploration complete: ${summary}`);
76596
76673
  const snap = await driver.snapshot();
76597
76674
  const resolved = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
76598
76675
  if (!resolved) {
76599
- return { ok: false, error: "snapshot returned no UI tree" };
76676
+ const streak = noteEmptyUiTree();
76677
+ log2(` Snapshot returned no UI tree (empty streak ${streak}/${MAX_EMPTY_UI_TREE_STREAK}).`);
76678
+ return {
76679
+ ok: false,
76680
+ error: "snapshot returned no UI tree",
76681
+ emptyUiTreeStreak: streak,
76682
+ permanent: streak >= MAX_EMPTY_UI_TREE_STREAK,
76683
+ 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."
76684
+ };
76600
76685
  }
76686
+ clearEmptyUiTreeStreak();
76601
76687
  const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_snapshot");
76602
76688
  if (absorbed.screenId) {
76603
76689
  const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
@@ -76963,14 +77049,37 @@ Exploration complete: ${summary}`);
76963
77049
  }
76964
77050
  // ----- Relaunch (hermetic reset) -----
76965
77051
  case "mobile_relaunch": {
77052
+ if (!allowRelaunch) {
77053
+ return {
77054
+ ok: false,
77055
+ 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.",
77056
+ note: "Relaunch refused under preserve policy."
77057
+ };
77058
+ }
77059
+ if (emptyUiTreeStreak.count >= MAX_EMPTY_UI_TREE_STREAK) {
77060
+ return {
77061
+ ok: false,
77062
+ 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.`,
77063
+ permanent: true,
77064
+ emptyUiTreeStreak: emptyUiTreeStreak.count
77065
+ };
77066
+ }
76966
77067
  authTracker?.noteInterveningMutation(iteration);
76967
77068
  await driver.relaunch();
76968
77069
  log2(" Relaunched app for hermetic reset.");
76969
77070
  const snap = await driver.snapshot();
76970
77071
  const resolved = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
76971
77072
  if (!resolved) {
76972
- return { ok: true, note: "relaunched; no UI tree returned \u2014 call mobile_snapshot." };
77073
+ const streak = noteEmptyUiTree();
77074
+ log2(` Relaunch yielded no UI tree (empty streak ${streak}/${MAX_EMPTY_UI_TREE_STREAK}).`);
77075
+ return {
77076
+ ok: true,
77077
+ note: "relaunched; no UI tree returned \u2014 call mobile_snapshot.",
77078
+ emptyUiTreeStreak: streak,
77079
+ permanent: streak >= MAX_EMPTY_UI_TREE_STREAK
77080
+ };
76973
77081
  }
77082
+ clearEmptyUiTreeStreak();
76974
77083
  const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_relaunch");
76975
77084
  return {
76976
77085
  ok: true,
@@ -80202,6 +80311,10 @@ var require_mobile_discovery_prompts = __commonJS({
80202
80311
  "use strict";
80203
80312
  Object.defineProperty(exports2, "__esModule", { value: true });
80204
80313
  exports2.MOBILE_SCENARIO_SYSTEM_PROMPT = exports2.MOBILE_SURVEY_SYSTEM_PROMPT = void 0;
80314
+ exports2.isScrubbedPlaceholderName = isScrubbedPlaceholderName;
80315
+ exports2.isOpaqueMobileScreenName = isOpaqueMobileScreenName;
80316
+ exports2.isPackageLeafAppCatchAll = isPackageLeafAppCatchAll;
80317
+ exports2.isLowValueMobileAutoDeepFeature = isLowValueMobileAutoDeepFeature;
80205
80318
  exports2.formatMobileEvidenceForPlanner = formatMobileEvidenceForPlanner;
80206
80319
  exports2.transitionSourceLabel = transitionSourceLabel;
80207
80320
  exports2.buildMobileSiteMap = buildMobileSiteMap;
@@ -80255,7 +80368,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
80255
80368
  5. CRUD screens for the same entity belong together
80256
80369
  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).
80257
80370
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
80258
- 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.
80371
+ 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.
80259
80372
  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.
80260
80373
  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.
80261
80374
  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.
@@ -80300,6 +80413,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
80300
80413
  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"]\`
80301
80414
  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"]\`
80302
80415
  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.
80416
+ 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.
80303
80417
  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.
80304
80418
  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>."
80305
80419
  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.
@@ -80454,10 +80568,26 @@ Each entry is exactly one of:
80454
80568
  function screenDisplayName(state2) {
80455
80569
  return state2.screenName?.trim() || state2.screenId;
80456
80570
  }
80571
+ function isScrubbedPlaceholderName(value) {
80572
+ const trimmed = value.trim();
80573
+ if (!trimmed)
80574
+ return false;
80575
+ if (/^<[^<>\s]+>$/.test(trimmed))
80576
+ return true;
80577
+ if (/^(number|identity|email|id|url|token|value|secret|date|time)$/i.test(trimmed)) {
80578
+ return true;
80579
+ }
80580
+ const stripped = trimmed.replace(/<[^<>\s]+>/g, " ").replace(/[^a-z0-9]+/gi, " ").trim();
80581
+ if (!stripped)
80582
+ return true;
80583
+ 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);
80584
+ }
80457
80585
  function isOpaqueMobileScreenName(value, appId) {
80458
80586
  const trimmed = value.trim();
80459
80587
  if (!trimmed)
80460
80588
  return false;
80589
+ if (isScrubbedPlaceholderName(trimmed))
80590
+ return true;
80461
80591
  const lower = trimmed.toLowerCase();
80462
80592
  const lowerAppId = appId?.trim().toLowerCase();
80463
80593
  if (lowerAppId && (lower === lowerAppId || lower.startsWith(`${lowerAppId}#`) || lower.startsWith(`${lowerAppId}.`))) {
@@ -80465,6 +80595,32 @@ Each entry is exactly one of:
80465
80595
  }
80466
80596
  return /^[a-z][\w$]*(?:\.[a-z][\w$]*)+(?:#[a-z0-9_. -]+)?$/i.test(trimmed);
80467
80597
  }
80598
+ function isPackageLeafAppCatchAll(name, appId) {
80599
+ const trimmed = name.trim();
80600
+ if (!trimmed || !appId?.trim())
80601
+ return false;
80602
+ const match = trimmed.match(/^(.+?)\s+app$/i);
80603
+ if (!match)
80604
+ return false;
80605
+ const brand = match[1].trim();
80606
+ if (!brand || /\s/.test(brand))
80607
+ return false;
80608
+ const leaf = appId.split(".").map((p) => p.trim()).filter(Boolean).pop();
80609
+ if (!leaf)
80610
+ return false;
80611
+ const normalize = (s) => s.replace(/[^a-z0-9]/gi, "").toLowerCase();
80612
+ return normalize(brand) === normalize(leaf);
80613
+ }
80614
+ function isLowValueMobileAutoDeepFeature(name, appId) {
80615
+ const trimmed = name.trim();
80616
+ if (!trimmed)
80617
+ return true;
80618
+ if (isScrubbedPlaceholderName(trimmed) || isOpaqueMobileScreenName(trimmed, appId))
80619
+ return true;
80620
+ if (isPackageLeafAppCatchAll(trimmed, appId))
80621
+ return true;
80622
+ return false;
80623
+ }
80468
80624
  function humanizeToken(token) {
80469
80625
  const spaced = token.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
80470
80626
  if (!spaced)
@@ -80479,11 +80635,12 @@ Each entry is exactly one of:
80479
80635
  }
80480
80636
  function nonGenericScreenName(state2, appId) {
80481
80637
  const raw = state2.screenName?.trim();
80482
- if (!raw || isOpaqueMobileScreenName(raw, appId))
80638
+ if (!raw || isOpaqueMobileScreenName(raw, appId) || isScrubbedPlaceholderName(raw))
80483
80639
  return null;
80484
80640
  const human = humanizeToken(raw.replace(/(?:Activity|ViewController|Fragment|Screen|Page)$/i, ""));
80485
- if (!human || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human))
80641
+ if (!human || isScrubbedPlaceholderName(human) || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human)) {
80486
80642
  return null;
80643
+ }
80487
80644
  return human;
80488
80645
  }
80489
80646
  function inferMobileFeatureGroupName(states, appId) {
@@ -81212,6 +81369,45 @@ ${constraints.join("\n")}`);
81212
81369
  return finalGroups;
81213
81370
  }
81214
81371
  var WEB_URL_PREFIX_FALLBACK_LOG_RE = /\bFallback:\s*\d+\s+feature groups? from URL-prefix grouping\b/i;
81372
+ function graphHasAuthenticatedProduct(graph) {
81373
+ for (const state2 of graph.screens.values()) {
81374
+ if (state2.requiresAuth === true)
81375
+ return true;
81376
+ if (state2.authProvenance === "post-submit-authenticated" || state2.authProvenance === "preserved-session-product") {
81377
+ return true;
81378
+ }
81379
+ }
81380
+ return false;
81381
+ }
81382
+ function isGuestWallScenario(scenario) {
81383
+ const feature = (scenario.featureGroup ?? "").toLowerCase();
81384
+ if (/auth|sign[\s-]?in|log[\s-]?in|onboarding/.test(feature))
81385
+ return false;
81386
+ const stepText = (scenario.steps ?? []).map((step) => typeof step === "string" ? step : step.action ?? "").join(" ");
81387
+ const outcomeText = (scenario.expectedOutcomes ?? []).map((outcome) => outcome.description ?? "").join(" ");
81388
+ const blob = [scenario.name, scenario.goal, stepText, outcomeText].filter(Boolean).join("\n").toLowerCase();
81389
+ 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)) {
81390
+ return true;
81391
+ }
81392
+ const productAction = /\b(like|unlike|download|playlist|comment|follow|share|clip|save|favorite|bookmark|subscribe|purchase|buy|checkout)\b/i.test(blob);
81393
+ 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);
81394
+ return productAction && assertsLoginGate;
81395
+ }
81396
+ function filterGuestWallScenariosForAuthSession(scenarios, graph) {
81397
+ if (scenarios.length === 0 || !graphHasAuthenticatedProduct(graph)) {
81398
+ return { scenarios, dropped: 0 };
81399
+ }
81400
+ const kept = [];
81401
+ let dropped = 0;
81402
+ for (const scenario of scenarios) {
81403
+ if (isGuestWallScenario(scenario)) {
81404
+ dropped += 1;
81405
+ continue;
81406
+ }
81407
+ kept.push(scenario);
81408
+ }
81409
+ return { scenarios: kept, dropped };
81410
+ }
81215
81411
  function selectMobilePlannerFeatureGroups(result, graph, appId, mode, existingFeatureNames = []) {
81216
81412
  if (mode !== "survey") {
81217
81413
  return { accepted: result.featureGroups, rejected: [] };
@@ -81325,7 +81521,12 @@ ${constraints.join("\n")}`);
81325
81521
  }
81326
81522
  const plannerFeatureGroups = filterFeatureGroupsToSiteMap(plannerGroupsForFiltering, siteMap, input.graph, input.appId, input.mode === "survey");
81327
81523
  const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
81328
- if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
81524
+ const guestWallFilter = input.mode !== "survey" ? filterGuestWallScenariosForAuthSession(result.scenarios, input.graph) : { scenarios: result.scenarios, dropped: 0 };
81525
+ if (guestWallFilter.dropped > 0) {
81526
+ mobileAdaptationLogs.push(`Dropped ${guestWallFilter.dropped} guest-wall scenario(s) \u2014 explore session was authenticated; re-exec would not see sign-in gates`);
81527
+ }
81528
+ const plannedScenarios = guestWallFilter.scenarios;
81529
+ if (input.mode !== "survey" && plannedScenarios.length === 0 && input.graph.screens.size > 0) {
81329
81530
  const fallbackScenarios = buildMobileFallbackScenarios(input);
81330
81531
  if (fallbackScenarios.length > 0) {
81331
81532
  return {
@@ -81344,6 +81545,7 @@ ${constraints.join("\n")}`);
81344
81545
  if (input.featureName?.trim()) {
81345
81546
  return {
81346
81547
  ...result,
81548
+ scenarios: plannedScenarios,
81347
81549
  featureGroups: mobileFeatureGroups,
81348
81550
  logs: [...result.logs, ...mobileAdaptationLogs],
81349
81551
  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(" | "),
@@ -81380,6 +81582,7 @@ ${constraints.join("\n")}`);
81380
81582
  }
81381
81583
  return {
81382
81584
  ...result,
81585
+ scenarios: plannedScenarios,
81383
81586
  featureGroups: mobileFeatureGroups,
81384
81587
  logs: [...result.logs, ...mobileAdaptationLogs]
81385
81588
  };
@@ -81449,6 +81652,7 @@ var require_mobile_generator = __commonJS({
81449
81652
  exports2.coalesceRepeatedTaps = coalesceRepeatedTaps;
81450
81653
  exports2.elideNoOpDetours = elideNoOpDetours;
81451
81654
  exports2.mobileActionsToPseudoPlaywright = mobileActionsToPseudoPlaywright;
81655
+ exports2.isEphemeralContentAssertText = isEphemeralContentAssertText;
81452
81656
  exports2.evaluateMobileAssertionStrength = evaluateMobileAssertionStrength;
81453
81657
  exports2.prepareMobileScenarioAppState = prepareMobileScenarioAppState;
81454
81658
  exports2.runMobileGeneratePhase = runMobileGeneratePhase;
@@ -81909,15 +82113,20 @@ ${hybridSkeleton.join("\n")}`;
81909
82113
  - 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.
81910
82114
  - 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.
81911
82115
 
81912
- ## Your job
82116
+ ## Your job \u2014 feature fidelity (non-negotiable)
82117
+ 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.
82118
+
81913
82119
  1. mobile_snapshot the current screen.
81914
- 2. Execute the scenario's steps using the real UI: tap buttons, fill fields (mobile_type), submit, navigate.
81915
- 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.
81916
- 4. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
81917
- - mobile_assert_visible({ref}) \u2014 the target screen's stable element is on screen.
81918
- - mobile_assert_text({ref, expected}) \u2014 an element contains expected text (only durable when the element has a stable accessibility-id / resource-id).
81919
- - 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.
81920
- 5. Call finish_generate with verified:true ONLY if you asserted the outcome with an assert tool.
82120
+ 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.
82121
+ 3. COMPLETE THE ACTION the scenario is about \u2014 do not stop half-way:
82122
+ - 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.
82123
+ - Content actions (like, share, follow, bookmark, download, \u2026): you MUST tap the control that performs that action \u2014 tab thrash / swipes alone are WRONG.
82124
+ - Navigation scenarios: you MUST land on the destination and assert something unique to it.
82125
+ 4. PROVE the outcome with an assert tool that matches the scenario \u2014 not ambient chrome:
82126
+ - mobile_assert_visible({ref}) / mobile_assert_text({ref, expected})
82127
+ - 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.
82128
+ - Do NOT assert bottom-nav tab labels alone, notification badge counts ("N unread"), or one-time empty-state CTAs as the scenario outcome.
82129
+ 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.
81921
82130
 
81922
82131
  ## Rules
81923
82132
  - Re-snapshot before every assertion so the ref is fresh.
@@ -81968,7 +82177,7 @@ ${stepLines || " (no explicit steps \u2014 explore the goal directly)"}
81968
82177
  Expected outcomes to PROVE with assert tools:
81969
82178
  ${outcomeLines || " - The target screen is reached and a stable element is visible."}
81970
82179
 
81971
- 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.`;
82180
+ 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.`;
81972
82181
  }
81973
82182
  function buildStatePacket(input) {
81974
82183
  const { scenario, iteration, maxIterations, latestObservation, assertCount, actionCount } = input;
@@ -82025,7 +82234,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
82025
82234
  }
82026
82235
  }
82027
82236
  async function runScenarioLoop(args) {
82028
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog } = args;
82237
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch } = args;
82029
82238
  const log2 = (line) => emitLog(redactCredentialValues(line, redactionCredentials));
82030
82239
  const actions = [];
82031
82240
  let assertCount = 0;
@@ -82599,6 +82808,7 @@ ${statePacket}`, redactionCredentials) },
82599
82808
  setLatest,
82600
82809
  credentials,
82601
82810
  screenName: () => lastScreenName || "UnknownScreen",
82811
+ allowRelaunch,
82602
82812
  recordAction: (action) => {
82603
82813
  const screenHash = getLatest()?.signal.screenHash;
82604
82814
  const structuralKey = structuralScreenKey(getLatest());
@@ -82633,7 +82843,7 @@ ${statePacket}`, redactionCredentials) },
82633
82843
  return { actions, assertCount, finish, lastSnapshot: lastTargetSnapshot, lastScreenName: lastTargetScreenName };
82634
82844
  }
82635
82845
  async function dispatchGenerateTool(args) {
82636
- const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, recordAction, recordedActions, log: log2 } = args;
82846
+ const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, allowRelaunch, recordAction, recordedActions, log: log2 } = args;
82637
82847
  const unknownRef = (ref) => ({
82638
82848
  ok: false,
82639
82849
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -83023,6 +83233,16 @@ ${statePacket}`, redactionCredentials) },
83023
83233
  return actionPayload(result, absorbed);
83024
83234
  }
83025
83235
  case "mobile_relaunch": {
83236
+ if (!allowRelaunch) {
83237
+ log2(" relaunch refused under PRESERVE_APP_DATA \u2014 staying on current app instance.");
83238
+ const snap2 = await driver.snapshot();
83239
+ const absorbed2 = await absorbObservation(snap2.xml, snap2.screenSignal, snap2.windowSize, snap2.screenshot, "mobile_relaunch_blocked");
83240
+ return {
83241
+ ok: false,
83242
+ error: "mobile_relaunch is blocked while app data is preserved. Continue from the current screen.",
83243
+ observation: absorbed2.observation
83244
+ };
83245
+ }
83026
83246
  await driver.relaunch();
83027
83247
  const snap = await driver.snapshot();
83028
83248
  const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
@@ -83241,14 +83461,48 @@ ${statePacket}`, redactionCredentials) },
83241
83461
  });
83242
83462
  return { ok: true, label: metadata.targetLabel ?? accessibilityId ?? text ?? "element" };
83243
83463
  }
83464
+ 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;
83465
+ function isDisplayableLandingCandidate(el, windowSize) {
83466
+ if (el.visible === false)
83467
+ return false;
83468
+ const { x, y, width, height } = el.bounds;
83469
+ if (!(width > 0 && height > 0))
83470
+ return false;
83471
+ if (width < 8 || height < 8)
83472
+ return false;
83473
+ if (windowSize && windowSize.width > 0 && windowSize.height > 0) {
83474
+ const centerX = x + width / 2;
83475
+ const centerY = y + height / 2;
83476
+ if (centerX < 0 || centerY < 0 || centerX > windowSize.width || centerY > windowSize.height) {
83477
+ return false;
83478
+ }
83479
+ if (width >= windowSize.width * 0.92 && height >= windowSize.height * 0.55) {
83480
+ return false;
83481
+ }
83482
+ }
83483
+ return true;
83484
+ }
83485
+ function landingAnchorSortKey(el) {
83486
+ const label = (nonEmpty3(el.label) || nonEmpty3(el.text) || "").trim();
83487
+ const ambient = AMBIENT_CHROME_LABEL_RE.test(label) ? 1 : 0;
83488
+ const labelled = nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83489
+ const area = Math.max(1, el.bounds.width * el.bounds.height);
83490
+ const areaBucket = area < 8e4 ? 0 : area < 25e4 ? 1 : 2;
83491
+ return [ambient, labelled, areaBucket];
83492
+ }
83244
83493
  function pickLandingAnchor(snapshot) {
83245
83494
  if (!snapshot || snapshot.screen.elements.length === 0)
83246
83495
  return null;
83247
83496
  const elements = snapshot.screen.elements;
83248
- const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
83249
- stableAnchors.sort((a, b) => {
83250
- const labelled2 = (el) => nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83251
- return labelled2(a) - labelled2(b);
83497
+ const windowSize = snapshot.screen.windowSize ?? null;
83498
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)).sort((a, b) => {
83499
+ const ka = landingAnchorSortKey(a);
83500
+ const kb = landingAnchorSortKey(b);
83501
+ for (let i = 0; i < ka.length; i += 1) {
83502
+ if (ka[i] !== kb[i])
83503
+ return ka[i] - kb[i];
83504
+ }
83505
+ return 0;
83252
83506
  });
83253
83507
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83254
83508
  if (contentfulNonButton)
@@ -83258,8 +83512,10 @@ ${statePacket}`, redactionCredentials) },
83258
83512
  return nonButton;
83259
83513
  if (stableAnchors.length > 0)
83260
83514
  return stableAnchors[0];
83261
- const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83262
- return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
83515
+ const labelled = elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83516
+ if (labelled)
83517
+ return labelled;
83518
+ return elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)) ?? null;
83263
83519
  }
83264
83520
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
83265
83521
  var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
@@ -83357,10 +83613,19 @@ ${statePacket}`, redactionCredentials) },
83357
83613
  const anchor = pickLandingAnchor(recording.lastSnapshot);
83358
83614
  if (!anchor)
83359
83615
  return false;
83616
+ const modelAssertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83617
+ const anchorLabel = (nonEmpty3(anchor.label) || nonEmpty3(anchor.text) || "").trim();
83618
+ if (modelAssertCount > 0 && AMBIENT_CHROME_LABEL_RE.test(anchorLabel)) {
83619
+ return false;
83620
+ }
83621
+ const anchorSelector = (0, mobile_source_parser_js_2.preferredLocator)(anchor);
83622
+ const alreadyAsserted = actions.some((a) => (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT") && typeof a.selector === "string" && a.selector === anchorSelector);
83623
+ if (alreadyAsserted)
83624
+ return false;
83360
83625
  const screen = recording.lastScreenName || "LandingScreen";
83361
83626
  actions.push(finalizeAction({
83362
83627
  type: "ASSERT_VISIBLE",
83363
- selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83628
+ selector: anchorSelector,
83364
83629
  metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83365
83630
  }));
83366
83631
  return true;
@@ -83379,6 +83644,84 @@ ${statePacket}`, redactionCredentials) },
83379
83644
  "element-hidden",
83380
83645
  "screen-visible"
83381
83646
  ]);
83647
+ 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;
83648
+ 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;
83649
+ var VOLATILE_BADGE_ASSERT_RE = /\b\d+\s*(unread|notifications?|messages?|likes?|followers?|comments?|views?)\b|\bunread\b.*\b\d+\b/i;
83650
+ function isContentActionScenario(scenario) {
83651
+ const text = scenarioText(scenario);
83652
+ if (!CONTENT_ACTION_VERB_RE.test(text))
83653
+ return false;
83654
+ if (/\b(view|see|show|open|browse|list|count)\s+(the\s+)?(likes?|followers?|shares?|bookmarks?)\b/i.test(text)) {
83655
+ return false;
83656
+ }
83657
+ return true;
83658
+ }
83659
+ function isContentActionTap(action) {
83660
+ if (action.type !== "TAP")
83661
+ return false;
83662
+ const textForAction = actionText(action);
83663
+ if (NAV_CHROME_TAP_RE.test(textForAction) && !CONTENT_ACTION_VERB_RE.test(textForAction)) {
83664
+ return false;
83665
+ }
83666
+ return CONTENT_ACTION_VERB_RE.test(textForAction);
83667
+ }
83668
+ function isProductiveScenarioAction(action, scenarioTextLower = "") {
83669
+ if (action.type === "TYPE" || action.type === "CLEAR")
83670
+ return true;
83671
+ if (action.type === "TAP") {
83672
+ const t = actionText(action);
83673
+ if (CONTENT_ACTION_VERB_RE.test(t) || isFormCommitAction(action) || isCredentialSubmitTap(action)) {
83674
+ return true;
83675
+ }
83676
+ if (NAV_CHROME_TAP_RE.test(t)) {
83677
+ if (!scenarioTextLower)
83678
+ return false;
83679
+ const chromeWords = t.match(/\b(home|you|profile|search|settings|notifications?|inbox|messages?|explore|library|more|menu|videos?|create|feed)\b/gi) ?? [];
83680
+ return chromeWords.some((w) => scenarioTextLower.includes(w.toLowerCase()));
83681
+ }
83682
+ return true;
83683
+ }
83684
+ return false;
83685
+ }
83686
+ function isNavChromeThrashTrace(actions) {
83687
+ const meaningful = actions.filter((a) => a.type !== "WAIT_FOR_ELEMENT");
83688
+ if (meaningful.length === 0)
83689
+ return false;
83690
+ const hasNavOrGesture = meaningful.some((a) => a.type === "TAP" || a.type === "SWIPE" || a.type === "BACK" || a.type === "HOME");
83691
+ if (!hasNavOrGesture)
83692
+ return false;
83693
+ return meaningful.every((a) => {
83694
+ if (a.type === "SWIPE" || a.type === "BACK" || a.type === "HOME")
83695
+ return true;
83696
+ if (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT") {
83697
+ const t = actionText(a);
83698
+ return NAV_CHROME_TAP_RE.test(t) || VOLATILE_BADGE_ASSERT_RE.test(t) || isStructuralOnlyAssert(a);
83699
+ }
83700
+ if (a.type === "TAP") {
83701
+ return NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a));
83702
+ }
83703
+ return false;
83704
+ });
83705
+ }
83706
+ function isHarnessOrVisibilityScenario(scenario) {
83707
+ const text = scenarioText(scenario);
83708
+ 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);
83709
+ }
83710
+ function requiresFeatureProgress(scenario) {
83711
+ if (isNavigationFocusedScenario(scenario))
83712
+ return false;
83713
+ if (isCancelDiscardScenario(scenario))
83714
+ return false;
83715
+ if (isContentActionScenario(scenario))
83716
+ return true;
83717
+ if (isHarnessOrVisibilityScenario(scenario))
83718
+ return false;
83719
+ const text = scenarioText(scenario);
83720
+ 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);
83721
+ if (!behaviorClaim)
83722
+ return false;
83723
+ 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);
83724
+ }
83382
83725
  function isInspectionOnlyScenario(scenario, actions) {
83383
83726
  if (actions.length === 0 || scenario.expectedOutcomes.length === 0)
83384
83727
  return false;
@@ -83388,16 +83731,25 @@ ${statePacket}`, redactionCredentials) },
83388
83731
  if (actions.some((a) => a.type === "TYPE" || a.type === "CLEAR")) {
83389
83732
  return false;
83390
83733
  }
83734
+ if (isContentActionScenario(scenario)) {
83735
+ return false;
83736
+ }
83391
83737
  const text = scenarioText(scenario);
83392
- 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);
83738
+ 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);
83393
83739
  if (visibilityOnlyGoal) {
83394
83740
  const assertCount2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83395
83741
  return assertCount2 > 0;
83396
83742
  }
83397
- 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);
83743
+ 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);
83398
83744
  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);
83399
83745
  if (completionIntent && formIntent)
83400
83746
  return false;
83747
+ if (completionIntent && /\b(like|unlike|share|follow|bookmark|download|subscribe|create|save|send|post|delete|remove|add|play|pause)\b/i.test(text)) {
83748
+ return false;
83749
+ }
83750
+ if (completionIntent) {
83751
+ return false;
83752
+ }
83401
83753
  const assertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83402
83754
  return assertCount > 0;
83403
83755
  }
@@ -83407,7 +83759,18 @@ ${statePacket}`, redactionCredentials) },
83407
83759
  const completesFlow = /\b(happy\s*path|successful|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout)|with\s+valid)\b/.test(text);
83408
83760
  return navigates && !completesFlow;
83409
83761
  }
83762
+ function isCancelDiscardScenario(scenario) {
83763
+ const text = scenarioText(scenario);
83764
+ 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)) {
83765
+ return false;
83766
+ }
83767
+ 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);
83768
+ 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);
83769
+ return cancelEditPhrase || discardWithoutSave;
83770
+ }
83410
83771
  function isFormValidationScenario(scenario) {
83772
+ if (isCancelDiscardScenario(scenario))
83773
+ return false;
83411
83774
  if (scenario.variant === "validation")
83412
83775
  return true;
83413
83776
  if (scenario.type !== "EDGE_CASE")
@@ -83418,6 +83781,8 @@ ${statePacket}`, redactionCredentials) },
83418
83781
  function isFormMutationHappyPath(scenario) {
83419
83782
  if (isNavigationFocusedScenario(scenario))
83420
83783
  return false;
83784
+ if (isCancelDiscardScenario(scenario))
83785
+ return false;
83421
83786
  if (isFormValidationScenario(scenario))
83422
83787
  return false;
83423
83788
  const text = scenarioText(scenario);
@@ -83430,9 +83795,14 @@ ${statePacket}`, redactionCredentials) },
83430
83795
  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)) {
83431
83796
  return false;
83432
83797
  }
83433
- const mutates = /\b(change|update|create|save|send|register|sign\s*up|checkout|edit|post|write|compose|set|add|delete|remove)\b/.test(text);
83434
- 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);
83435
- return mutates && hasObject;
83798
+ 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)) {
83799
+ return false;
83800
+ }
83801
+ 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";
83802
+ const mutationVerb = "change|update|create|save|send|register|sign\\s*up|checkout|edit|post|write|compose|set|add|delete|remove";
83803
+ const determiner = "(?:(?:the|a|an|my|your|their|this|that)\\s+)?(?:new\\s+)?";
83804
+ const mutatesFormObject = new RegExp(`\\b(?:${mutationVerb})\\s+${determiner}(?:${formObject})\\b`).test(text) || new RegExp(`\\b(?:${formObject})\\s+(?:${mutationVerb})\\b`).test(text);
83805
+ return mutatesFormObject;
83436
83806
  }
83437
83807
  function isNavigationChromeTap(action) {
83438
83808
  if (action.type !== "TAP")
@@ -83521,12 +83891,46 @@ ${statePacket}`, redactionCredentials) },
83521
83891
  }
83522
83892
  function scenarioIntentMismatchReason(scenario, actions) {
83523
83893
  const text = scenarioText(scenario);
83894
+ if (isContentActionScenario(scenario)) {
83895
+ const hasActionTap = actions.some((a) => isContentActionTap(a));
83896
+ if (!hasActionTap) {
83897
+ return "content-action scenario (like/share/follow/\u2026) did not record a tap on a matching control";
83898
+ }
83899
+ if (isNavChromeThrashTrace(actions)) {
83900
+ return "content-action scenario only recorded tab thrash / swipes \u2014 no product action";
83901
+ }
83902
+ const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83903
+ 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)))) {
83904
+ return "content-action scenario only asserted tab chrome or a volatile badge count \u2014 assert the action outcome instead";
83905
+ }
83906
+ return null;
83907
+ }
83524
83908
  if (isInspectionOnlyScenario(scenario, actions)) {
83525
83909
  return null;
83526
83910
  }
83527
83911
  if (isNavigationFocusedScenario(scenario)) {
83528
83912
  return null;
83529
83913
  }
83914
+ if (isCancelDiscardScenario(scenario)) {
83915
+ const hasCancelTap = actions.some((a) => {
83916
+ if (a.type !== "TAP")
83917
+ return false;
83918
+ return /\b(cancel|discard|close|dismiss|revert|undo|not\s+now)\b/.test(actionText(a));
83919
+ });
83920
+ const lastNavIdx = (() => {
83921
+ for (let i = actions.length - 1; i >= 0; i--) {
83922
+ const t = actions[i].type;
83923
+ if (t === "TAP" || t === "BACK" || t === "TYPE" || t === "CLEAR")
83924
+ return i;
83925
+ }
83926
+ return -1;
83927
+ })();
83928
+ 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);
83929
+ if (!hasCancelTap && !backAsDismiss) {
83930
+ return "cancel/discard scenario did not record a cancel/dismiss action";
83931
+ }
83932
+ return null;
83933
+ }
83530
83934
  if (isFormValidationScenario(scenario)) {
83531
83935
  const typedFields = actions.some((a) => a.type === "TYPE");
83532
83936
  const hasCommit = actions.some((a) => isFormCommitAction(a));
@@ -83620,30 +84024,42 @@ ${statePacket}`, redactionCredentials) },
83620
84024
  }
83621
84025
  }
83622
84026
  const isHappyPath = /\b(happy\s*path|successful|success|create|save|submit|complete|checkout|send|post)\b/.test(text) || scenario.type === "HAPPY_PATH";
83623
- if (!isHappyPath)
83624
- return null;
83625
- let lastTypeIndex = -1;
83626
- let lastAssertIndex = -1;
83627
- actions.forEach((action, index) => {
83628
- if (action.type === "TYPE")
83629
- lastTypeIndex = index;
83630
- if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
83631
- lastAssertIndex = index;
83632
- });
83633
- if (lastTypeIndex < 0 || lastAssertIndex <= lastTypeIndex)
83634
- return null;
83635
- const typeScreen = actions[lastTypeIndex]?.metadata?.screenName?.trim() ?? "";
83636
- const assertScreen = actions[lastAssertIndex]?.metadata?.screenName?.trim() ?? "";
83637
- const screensDiffer = typeScreen !== "" && assertScreen !== "" && typeScreen !== assertScreen;
83638
- const lastTypeAction = actions[lastTypeIndex];
83639
- const keyboardCommitOnLastType = lastTypeAction != null && isKeyboardFormCommit(lastTypeAction);
83640
- const commitAfterType = keyboardCommitOnLastType || actions.some((action, index) => {
83641
- if (index <= lastTypeIndex || index >= lastAssertIndex)
83642
- return false;
83643
- return isFormCommitAction(action);
83644
- });
83645
- if (screensDiffer && !commitAfterType) {
83646
- return "form fields were filled then a success assert was recorded without a commit/submit tap between them";
84027
+ if (isHappyPath) {
84028
+ let lastTypeIndex = -1;
84029
+ let lastAssertIndex = -1;
84030
+ actions.forEach((action, index) => {
84031
+ if (action.type === "TYPE")
84032
+ lastTypeIndex = index;
84033
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
84034
+ lastAssertIndex = index;
84035
+ });
84036
+ if (lastTypeIndex >= 0 && lastAssertIndex > lastTypeIndex) {
84037
+ const typeScreen = actions[lastTypeIndex]?.metadata?.screenName?.trim() ?? "";
84038
+ const assertScreen = actions[lastAssertIndex]?.metadata?.screenName?.trim() ?? "";
84039
+ const screensDiffer = typeScreen !== "" && assertScreen !== "" && typeScreen !== assertScreen;
84040
+ const lastTypeAction = actions[lastTypeIndex];
84041
+ const keyboardCommitOnLastType = lastTypeAction != null && isKeyboardFormCommit(lastTypeAction);
84042
+ const commitAfterType = keyboardCommitOnLastType || actions.some((action, index) => {
84043
+ if (index <= lastTypeIndex || index >= lastAssertIndex)
84044
+ return false;
84045
+ return isFormCommitAction(action);
84046
+ });
84047
+ if (screensDiffer && !commitAfterType) {
84048
+ return "form fields were filled then a success assert was recorded without a commit/submit tap between them";
84049
+ }
84050
+ }
84051
+ }
84052
+ if (isNavChromeThrashTrace(actions) && !isNavigationFocusedScenario(scenario) && !isFormMutationHappyPath(scenario) && !isFormValidationScenario(scenario) && !isCredentialSignIn && !isAuthLikeScenario && !isHarnessOrVisibilityScenario(scenario)) {
84053
+ return "recording is only bottom-nav thrash and swipes \u2014 does not execute the scenario goal";
84054
+ }
84055
+ if (requiresFeatureProgress(scenario) && !isFormValidationScenario(scenario) && !isCredentialSignIn && !isAuthLikeScenario) {
84056
+ if (!actions.some((a) => isProductiveScenarioAction(a, text))) {
84057
+ return "happy-path / user-journey scenario did not record a productive product action (only chrome, gestures, or empty work)";
84058
+ }
84059
+ const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84060
+ if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a))) {
84061
+ return "happy-path / user-journey scenario only asserted volatile badges or structural roots \u2014 assert a durable feature outcome";
84062
+ }
83647
84063
  }
83648
84064
  return null;
83649
84065
  }
@@ -83898,6 +84314,29 @@ ${statePacket}`, redactionCredentials) },
83898
84314
  lines.push("});");
83899
84315
  return lines.join("\n");
83900
84316
  }
84317
+ function isEphemeralContentAssertText(text) {
84318
+ const t = text.trim();
84319
+ if (!t)
84320
+ return false;
84321
+ 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)) {
84322
+ return true;
84323
+ }
84324
+ if (/\b(just\s+now|moments?\s+ago|yesterday)\b/i.test(t))
84325
+ return true;
84326
+ if (/\btoday\s+at\s+\d{1,2}:\d{2}\b/i.test(t))
84327
+ return true;
84328
+ 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);
84329
+ if (feedish && t.length >= 70 && /[.!?].+\s/.test(t) && t.split(/\s+/).length >= 10) {
84330
+ return true;
84331
+ }
84332
+ if (t.length >= 40 && /\b(error\s+\d{3,}|generation failed|rate limit|quota exceeded)\b/i.test(t)) {
84333
+ return true;
84334
+ }
84335
+ return false;
84336
+ }
84337
+ function assertIdentityText(action) {
84338
+ return [action.selector, action.value, action.innerText, action.metadata?.targetLabel].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
84339
+ }
83901
84340
  function evaluateMobileAssertionStrength(actions) {
83902
84341
  const modelAsserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83903
84342
  if (modelAsserts.length === 0) {
@@ -83933,6 +84372,24 @@ ${statePacket}`, redactionCredentials) },
83933
84372
  }
83934
84373
  };
83935
84374
  }
84375
+ const ephemeral = modelAsserts.filter((a) => isEphemeralContentAssertText(assertIdentityText(a)));
84376
+ const durable = modelAsserts.filter((a) => !isEphemeralContentAssertText(assertIdentityText(a)));
84377
+ if (ephemeral.length > 0 && durable.length === 0) {
84378
+ return {
84379
+ ok: false,
84380
+ severity: "block",
84381
+ reason: "only ephemeral content assertions (relative times, long feed/notification copy, or backend error strings) \u2014 assert durable chrome or run-scoped data instead",
84382
+ signals: {
84383
+ apiTest: false,
84384
+ interacted: true,
84385
+ mutated: actions.some((a) => a.type === "TYPE"),
84386
+ navOnly: false,
84387
+ hasOutcomeProof: false,
84388
+ errorBanner: false,
84389
+ terminal: null
84390
+ }
84391
+ };
84392
+ }
83936
84393
  const code = mobileActionsToPseudoPlaywright(actions);
83937
84394
  const interacted = actions.some((a) => a.type === "TAP" || a.type === "TYPE" || a.type === "CLEAR");
83938
84395
  return (0, shared_1.validateAssertionStrength)(code, { interactionPerformed: interacted });
@@ -84236,7 +84693,8 @@ ${statePacket}`, redactionCredentials) },
84236
84693
  targetAppId,
84237
84694
  credentials: credentialSecrets,
84238
84695
  redactionCredentials: credentialValuesForRedaction,
84239
- log: log2
84696
+ log: log2,
84697
+ allowRelaunch: (ctx.target?.resetPolicy ?? "AUTO") !== "PRESERVE_APP_DATA"
84240
84698
  });
84241
84699
  consecutiveInfraFailures = 0;
84242
84700
  const test = assembleTest({
@@ -84807,6 +85265,7 @@ Reason over steps + error + tree + registry and return working healed steps. Do
84807
85265
  For a fix, return the COMPLETE steps array (not a diff).`;
84808
85266
  var DEFAULT_SCRIPT_MAX_ATTEMPTS = 6;
84809
85267
  var RECOVERABLE_MAX = 3;
85268
+ var MAX_CONSECUTIVE_INVALID_VERDICTS = 3;
84810
85269
  var BONUS_ATTEMPTS_CEIL = 4;
84811
85270
  var DEFAULT_BONUS_ATTEMPTS = 2;
84812
85271
  var MAX_LOG_CHARS = 4096;
@@ -85412,6 +85871,7 @@ ${args.directive}`);
85412
85871
  const triedLocators = [];
85413
85872
  let sameErrorCount = 0;
85414
85873
  let lastErrorSignature = null;
85874
+ let consecutiveInvalidVerdicts = 0;
85415
85875
  const markRecoverableTurn = () => {
85416
85876
  if (recoverableUsed < RECOVERABLE_MAX) {
85417
85877
  attemptCap += 1;
@@ -85485,11 +85945,22 @@ ${args.directive}`);
85485
85945
  aiResponseCount += 1;
85486
85946
  const parsed = parseHealVerdict(content);
85487
85947
  if (!parsed) {
85488
- options.onProgress?.(`Phase 1 attempt ${attempt}: response was not a valid verdict (${content.length} chars, finish=${finishReason ?? "unknown"}) \u2014 granting a recovery turn`);
85948
+ consecutiveInvalidVerdicts += 1;
85949
+ 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)`);
85950
+ if (consecutiveInvalidVerdicts >= MAX_CONSECUTIVE_INVALID_VERDICTS) {
85951
+ options.onProgress?.(`Phase 1 aborted: ${consecutiveInvalidVerdicts} consecutive invalid verdicts \u2014 freeing device`);
85952
+ return {
85953
+ passed: false,
85954
+ verdict: "config_issue",
85955
+ healReason: `heal model returned ${consecutiveInvalidVerdicts} consecutive non-verdict responses (last ${content.length} chars, finish=${finishReason ?? "unknown"}) \u2014 aborting to free the device`,
85956
+ attemptsUsed: attempt
85957
+ };
85958
+ }
85489
85959
  markRecoverableTurn();
85490
- pendingDirective = "Your last response was not valid JSON matching the verdict schema. Return exactly one JSON verdict object.";
85960
+ pendingDirective = "Your last response was not valid JSON matching the verdict schema. Return exactly one JSON verdict object \u2014 no prose, no markdown fences.";
85491
85961
  continue;
85492
85962
  }
85963
+ consecutiveInvalidVerdicts = 0;
85493
85964
  if (TERMINAL_VERDICTS.has(parsed.verdict)) {
85494
85965
  return { passed: false, verdict: parsed.verdict, healReason: parsed.reason, attemptsUsed: attempt };
85495
85966
  }
@@ -337169,10 +337640,11 @@ Fatal error: ${msg}`);
337169
337640
  const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
337170
337641
  if (shouldPreserveAppData) {
337171
337642
  try {
337172
- await driver.relaunch();
337643
+ await driver.launchApp();
337644
+ log2("App activated with preserved data (no terminate/clear).");
337173
337645
  } catch (err) {
337174
337646
  (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
337175
- log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337647
+ log2(`App activate failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337176
337648
  }
337177
337649
  return false;
337178
337650
  }
@@ -352875,9 +353347,9 @@ var require_dist2 = __commonJS({
352875
353347
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
352876
353348
  };
352877
353349
  Object.defineProperty(exports2, "__esModule", { value: true });
352878
- exports2.resolveRefFromSnapshot = exports2.replayAuthRecipeIntoBrowser = exports2.runRegisterCaptureRun = exports2.runLoginCaptureRun = exports2.filterLoginCaptureCredentials = exports2.buildLoginCaptureCredentials = exports2.isReservedRunnerEnvVar = exports2.RESERVED_RUNNER_ENV_VARS = exports2.AUTH_MARKER_VERSION = exports2.buildHealAuthStamp = exports2.extractAuthRegion = exports2.upsertAuthBlock = exports2.buildAuthRecipe = exports2.commandToReplayStep = exports2.buildReplayPlan = exports2.absoluteRoute = exports2.renderReplaySettleArrow = exports2.renderReplayClickArrow = exports2.renderAuthFn = exports2.filterFeatureGroupsToPathScope = exports2.isAuthFlowRoute = exports2.deriveSurveyPathScope = exports2.canonicalizeRoute = exports2.codeUsesInboxHelper = exports2.codeUsesTestInboxIdentity = exports2.codeSelfProvisionsRegistration = exports2.executeGrokPerBatchHeal = exports2.executeGrokPerTestHeal = exports2.headerVal = exports2.classifyAuthType = exports2.isTextExcerptable = exports2.isStaticAssetByExt = exports2.isApiCall = exports2.buildApiCallSummary = exports2.computeMobileHealOutcome = exports2.detectMobileStepSecretLeak = exports2.redactMobileSecretsFromText = exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports2.executeMobileLiveHeal = exports2.executeMobileTestWithHealing = exports2.executeMobileScriptDrivenHeal = exports2.prepareMobileScenarioAppState = exports2.runMobileGeneratePhase = exports2.runMobileAIPlanner = exports2.buildMobileSiteMap = exports2.formatMobileEvidenceForPlanner = exports2.MOBILE_SCENARIO_SYSTEM_PROMPT = exports2.MOBILE_SURVEY_SYSTEM_PROMPT = exports2.runMobileExplorePhase = void 0;
352879
- exports2.MAX_MANIFEST_FILES = exports2.toDiffSummary = exports2.isLikelyTestFile = exports2.sanitizeRepoRelPath = exports2.sha256Hex = exports2.hashFiles = exports2.enumerateTestFiles = exports2.buildNextManifest = exports2.detectRepoSwitch = exports2.classifyImportDiff = exports2.parseImportManifest = exports2.validateAuthoredMobileTest = exports2.buildMobileRepoAuthorSystemRules = exports2.buildMobileRepoAuthorKickoff = exports2.buildMobileRepoAuthorContractMd = exports2.REPO_ANALYSIS_SCHEMA_VERSION = exports2.validateSourceEndpoints = exports2.validateAuthoredTest = exports2.validateRepoMapFile = exports2.validateRepoMemoryFile = exports2.buildTestingStandardsSection = exports2.buildRepoAuthorSystemRules = exports2.buildRepoAuthorKickoff = exports2.buildRepoAuthorContractMd = exports2.buildRepoMapSystemRules = exports2.buildRepoMapKickoff = exports2.buildRepoMapContractMd = exports2.buildRepoMemorySystemRules = exports2.buildRepoMemoryKickoff = exports2.buildRepoMemoryContractMd = exports2.runRepoAnalysisOnRunner = exports2.MOBILE_IMPORT_SCHEMA_VERSION = exports2.validateImportedMobileTest = exports2.buildMobileImportSystemRules = exports2.buildMobileImportKickoff = exports2.buildMobileImportContractMd = exports2.IMPORT_SCHEMA_VERSION = exports2.validateImportResultFile = exports2.validateImportedTest = exports2.buildImportSystemRules = exports2.buildImportKickoff = exports2.buildImportContractMd = exports2.MAX_ARCHIVE_BYTES = exports2.isUnsafeEntryName = exports2.extractZipSafely = exports2.downloadArchiveCapped = exports2.detectArchiveKind = exports2.acquireRepoForImport = exports2.runFrameworkImportOnRunner = exports2.getAgenticEngine = void 0;
352880
- exports2.BrokeredInboxReader = exports2.applyPlusTag = exports2.deriveProjectInboxTag = exports2.deriveInboxTag = exports2.loadVerificationInboxConfig = exports2.VerificationInboxReader = exports2.UNSUPPORTED_RENDERER_PREFIX = exports2.UnsupportedRendererError = exports2.buildCanvasModeAuthoringAddendum = exports2.buildUnsupportedRendererMessage = exports2.modelSupportsVision = exports2.decideCanvasStrategy = exports2.decideAppRenderType = exports2.detectCanvasApp = exports2.detectRateLimitBlock = exports2.isSafeCanonicalRedirect = exports2.resolveBaseUrlPreflight = exports2.checkBaseUrlRedirect = exports2.truncateAtLine = exports2.neutralizeUntrustedBlock = exports2.renderUntrustedKnowledgeBlock = exports2.stripJsonFences = exports2.scrubCredentials = exports2.withAIRetry = exports2.snapshotMatchingChildren = exports2.findNewChromiumChildPid = exports2.killProcessTree = exports2.gracefulCloseWithTreeKill = exports2.liveBrowserRegistry = exports2.REPO_SWITCH_REMOVED_FRACTION = void 0;
353350
+ exports2.filterLoginCaptureCredentials = exports2.buildLoginCaptureCredentials = exports2.isReservedRunnerEnvVar = exports2.RESERVED_RUNNER_ENV_VARS = exports2.AUTH_MARKER_VERSION = exports2.buildHealAuthStamp = exports2.extractAuthRegion = exports2.upsertAuthBlock = exports2.buildAuthRecipe = exports2.commandToReplayStep = exports2.buildReplayPlan = exports2.absoluteRoute = exports2.renderReplaySettleArrow = exports2.renderReplayClickArrow = exports2.renderAuthFn = exports2.filterFeatureGroupsToPathScope = exports2.isAuthFlowRoute = exports2.deriveSurveyPathScope = exports2.canonicalizeRoute = exports2.codeUsesInboxHelper = exports2.codeUsesTestInboxIdentity = exports2.codeSelfProvisionsRegistration = exports2.executeGrokPerBatchHeal = exports2.executeGrokPerTestHeal = exports2.headerVal = exports2.classifyAuthType = exports2.isTextExcerptable = exports2.isStaticAssetByExt = exports2.isApiCall = exports2.buildApiCallSummary = exports2.computeMobileHealOutcome = exports2.detectMobileStepSecretLeak = exports2.redactMobileSecretsFromText = exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports2.executeMobileLiveHeal = exports2.executeMobileTestWithHealing = exports2.executeMobileScriptDrivenHeal = exports2.prepareMobileScenarioAppState = exports2.runMobileGeneratePhase = exports2.isLowValueMobileAutoDeepFeature = exports2.isPackageLeafAppCatchAll = exports2.isOpaqueMobileScreenName = exports2.isScrubbedPlaceholderName = exports2.runMobileAIPlanner = exports2.buildMobileSiteMap = exports2.formatMobileEvidenceForPlanner = exports2.MOBILE_SCENARIO_SYSTEM_PROMPT = exports2.MOBILE_SURVEY_SYSTEM_PROMPT = exports2.runMobileExplorePhase = void 0;
353351
+ exports2.sha256Hex = exports2.hashFiles = exports2.enumerateTestFiles = exports2.buildNextManifest = exports2.detectRepoSwitch = exports2.classifyImportDiff = exports2.parseImportManifest = exports2.validateAuthoredMobileTest = exports2.buildMobileRepoAuthorSystemRules = exports2.buildMobileRepoAuthorKickoff = exports2.buildMobileRepoAuthorContractMd = exports2.REPO_ANALYSIS_SCHEMA_VERSION = exports2.validateSourceEndpoints = exports2.validateAuthoredTest = exports2.validateRepoMapFile = exports2.validateRepoMemoryFile = exports2.buildTestingStandardsSection = exports2.buildRepoAuthorSystemRules = exports2.buildRepoAuthorKickoff = exports2.buildRepoAuthorContractMd = exports2.buildRepoMapSystemRules = exports2.buildRepoMapKickoff = exports2.buildRepoMapContractMd = exports2.buildRepoMemorySystemRules = exports2.buildRepoMemoryKickoff = exports2.buildRepoMemoryContractMd = exports2.runRepoAnalysisOnRunner = exports2.MOBILE_IMPORT_SCHEMA_VERSION = exports2.validateImportedMobileTest = exports2.buildMobileImportSystemRules = exports2.buildMobileImportKickoff = exports2.buildMobileImportContractMd = exports2.IMPORT_SCHEMA_VERSION = exports2.validateImportResultFile = exports2.validateImportedTest = exports2.buildImportSystemRules = exports2.buildImportKickoff = exports2.buildImportContractMd = exports2.MAX_ARCHIVE_BYTES = exports2.isUnsafeEntryName = exports2.extractZipSafely = exports2.downloadArchiveCapped = exports2.detectArchiveKind = exports2.acquireRepoForImport = exports2.runFrameworkImportOnRunner = exports2.getAgenticEngine = exports2.resolveRefFromSnapshot = exports2.replayAuthRecipeIntoBrowser = exports2.runRegisterCaptureRun = exports2.runLoginCaptureRun = void 0;
353352
+ exports2.BrokeredInboxReader = exports2.applyPlusTag = exports2.deriveProjectInboxTag = exports2.deriveInboxTag = exports2.loadVerificationInboxConfig = exports2.VerificationInboxReader = exports2.UNSUPPORTED_RENDERER_PREFIX = exports2.UnsupportedRendererError = exports2.buildCanvasModeAuthoringAddendum = exports2.buildUnsupportedRendererMessage = exports2.modelSupportsVision = exports2.decideCanvasStrategy = exports2.decideAppRenderType = exports2.detectCanvasApp = exports2.detectRateLimitBlock = exports2.isSafeCanonicalRedirect = exports2.resolveBaseUrlPreflight = exports2.checkBaseUrlRedirect = exports2.truncateAtLine = exports2.neutralizeUntrustedBlock = exports2.renderUntrustedKnowledgeBlock = exports2.stripJsonFences = exports2.scrubCredentials = exports2.withAIRetry = exports2.snapshotMatchingChildren = exports2.findNewChromiumChildPid = exports2.killProcessTree = exports2.gracefulCloseWithTreeKill = exports2.liveBrowserRegistry = exports2.REPO_SWITCH_REMOVED_FRACTION = exports2.MAX_MANIFEST_FILES = exports2.toDiffSummary = exports2.isLikelyTestFile = exports2.sanitizeRepoRelPath = void 0;
352881
353353
  __exportStar(require_ai_provider(), exports2);
352882
353354
  __exportStar(require_mobile_types(), exports2);
352883
353355
  __exportStar(require_mobile_source_parser(), exports2);
@@ -352902,6 +353374,18 @@ var require_dist2 = __commonJS({
352902
353374
  Object.defineProperty(exports2, "runMobileAIPlanner", { enumerable: true, get: function() {
352903
353375
  return mobile_discovery_prompts_js_1.runMobileAIPlanner;
352904
353376
  } });
353377
+ Object.defineProperty(exports2, "isScrubbedPlaceholderName", { enumerable: true, get: function() {
353378
+ return mobile_discovery_prompts_js_1.isScrubbedPlaceholderName;
353379
+ } });
353380
+ Object.defineProperty(exports2, "isOpaqueMobileScreenName", { enumerable: true, get: function() {
353381
+ return mobile_discovery_prompts_js_1.isOpaqueMobileScreenName;
353382
+ } });
353383
+ Object.defineProperty(exports2, "isPackageLeafAppCatchAll", { enumerable: true, get: function() {
353384
+ return mobile_discovery_prompts_js_1.isPackageLeafAppCatchAll;
353385
+ } });
353386
+ Object.defineProperty(exports2, "isLowValueMobileAutoDeepFeature", { enumerable: true, get: function() {
353387
+ return mobile_discovery_prompts_js_1.isLowValueMobileAutoDeepFeature;
353388
+ } });
352905
353389
  var mobile_generator_js_1 = require_mobile_generator();
352906
353390
  Object.defineProperty(exports2, "runMobileGeneratePhase", { enumerable: true, get: function() {
352907
353391
  return mobile_generator_js_1.runMobileGeneratePhase;
@@ -353337,7 +353821,7 @@ var require_package4 = __commonJS({
353337
353821
  "package.json"(exports2, module2) {
353338
353822
  module2.exports = {
353339
353823
  name: "@validate.qa/runner",
353340
- version: "1.0.84",
353824
+ version: "1.0.86",
353341
353825
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353342
353826
  bin: {
353343
353827
  "validate-runner": "dist/cli.js"
@@ -354203,9 +354687,9 @@ async function waitForColdBootSettle(sessionPath) {
354203
354687
  timeoutMs: MOBILE_COLD_BOOT_SETTLE_MS,
354204
354688
  minStableFrames: 2,
354205
354689
  pollMs: 400,
354206
- // WebView native shell alone is ~5–8 nodes; real first screens flatten to
354207
- // dozens. Require enough nodes that the DOM has painted (mirrors executor).
354208
- minElements: 12
354690
+ // Native splash / WebView shell is ~5–8 nodes; RN product screens and rich
354691
+ // onboarding typically flatten to 15+ once JS has painted.
354692
+ minElements: 15
354209
354693
  });
354210
354694
  }
354211
354695
  async function getWindowSize(sessionPath) {
@@ -354354,20 +354838,33 @@ async function sendKeysToFocusedInput(sessionPath, value) {
354354
354838
  }
354355
354839
  async function typeText(sessionPath, value, bounds) {
354356
354840
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
354841
+ if (!elementId && bounds === void 0) {
354842
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
354843
+ }
354357
354844
  if (elementId) {
354358
354845
  try {
354359
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354846
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
354360
354847
  method: "POST",
354361
- body: JSON.stringify({ text: value })
354848
+ body: JSON.stringify({})
354362
354849
  });
354363
- return;
354364
354850
  } catch (err) {
354365
354851
  if (isAppiumDriverDeadError(err)) throw err;
354366
354852
  }
354367
- } else if (bounds === void 0) {
354368
- throw new Error("No focused element available for TYPE. Tap an input field first.");
354369
354853
  }
354370
- await sendKeysToFocusedInput(sessionPath, value);
354854
+ try {
354855
+ await sendKeysToFocusedInput(sessionPath, value);
354856
+ return;
354857
+ } catch (err) {
354858
+ if (isAppiumDriverDeadError(err)) throw err;
354859
+ }
354860
+ if (elementId) {
354861
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354862
+ method: "POST",
354863
+ body: JSON.stringify({ text: value })
354864
+ });
354865
+ return;
354866
+ }
354867
+ throw new Error("TYPE failed: could not send keys to the focused input.");
354371
354868
  }
354372
354869
  async function submitKeyboard(sessionPath, bounds) {
354373
354870
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
@@ -355236,7 +355733,7 @@ async function pressHome(sessionPath, platform3) {
355236
355733
  // src/services/device-quarantine.ts
355237
355734
  var QUARANTINE_MAX_MS = (() => {
355238
355735
  const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
355239
- return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
355736
+ return Number.isFinite(raw) && raw > 0 ? raw : 2 * 60 * 1e3;
355240
355737
  })();
355241
355738
  var quarantinedAt = /* @__PURE__ */ new Map();
355242
355739
  var nextQuarantineToken = 1;
@@ -356733,6 +357230,12 @@ async function createSession(target) {
356733
357230
  // Scoped to simulator UDIDs (a physical iOS device has no such step);
356734
357231
  // override with APPIUM_IOS_HEADLESS=false to force a visible Simulator run.
356735
357232
  ...isIosSimulatorUdid(target.deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
357233
+ // WDA cold-start is flaky under dual-sim load (xcodebuild 65/75, SIGTERM).
357234
+ // Retry startup instead of failing the whole discovery session once.
357235
+ "appium:wdaStartupRetries": 4,
357236
+ "appium:wdaStartupRetryInterval": 2e3,
357237
+ "appium:wdaLaunchTimeout": 12e4,
357238
+ "appium:wdaConnectionTimeout": 12e4,
356736
357239
  ...parallelCaps,
356737
357240
  ...realCaps
356738
357241
  } : {
@@ -356744,6 +357247,12 @@ async function createSession(target) {
356744
357247
  "appium:noReset": true,
356745
357248
  // See the iOS branch: the 60s default kills AI-driven discovery sessions.
356746
357249
  "appium:newCommandTimeout": MOBILE_NEW_COMMAND_TIMEOUT_SECONDS,
357250
+ // Default UIA2 server launch is 30s — under dual-emu / post-quarantine
357251
+ // stress instrumentation often needs longer or the session never binds
357252
+ // ("instrumentation process cannot be initialized within 30000ms").
357253
+ "appium:uiautomator2ServerLaunchTimeout": 12e4,
357254
+ "appium:uiautomator2ServerInstallTimeout": 12e4,
357255
+ "appium:adbExecTimeout": 12e4,
356747
357256
  "appium:chromedriverExecutableDir": ensureWritableChromedriverExecutableDir(),
356748
357257
  ...parallelCaps,
356749
357258
  ...realCaps
@@ -359512,6 +360021,9 @@ async function startSession(validated, res) {
359512
360021
  ...deviceId ? { "appium:udid": deviceId } : {},
359513
360022
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
359514
360023
  "appium:noReset": true,
360024
+ // Keep pace with executor createSession: Expo "Open in App?" sheets and
360025
+ // permission dialogs otherwise freeze WDA for recording/mirror sessions.
360026
+ ...process.env.APPIUM_IOS_AUTO_ACCEPT_ALERTS !== "false" ? { "appium:autoAcceptAlerts": true } : {},
359515
360027
  // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
359516
360028
  // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
359517
360029
  // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
@@ -359519,6 +360031,12 @@ async function startSession(validated, res) {
359519
360031
  // overrides. Verified live: without this, recorder/mirror sessions on iOS
359520
360032
  // die with "Simulator.app does not exist" while executor sessions work.
359521
360033
  ...isIosSimulatorUdid(deviceId) && process.env.APPIUM_IOS_HEADLESS !== "false" ? { "appium:isHeadless": true } : {},
360034
+ // Match executor createSession: tolerate WDA cold-start flakes.
360035
+ "appium:newCommandTimeout": Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT) > 0 ? Math.floor(Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT)) : 600,
360036
+ "appium:wdaStartupRetries": 4,
360037
+ "appium:wdaStartupRetryInterval": 2e3,
360038
+ "appium:wdaLaunchTimeout": 12e4,
360039
+ "appium:wdaConnectionTimeout": 12e4,
359522
360040
  ...realCaps
359523
360041
  } : {
359524
360042
  platformName: "Android",
@@ -359527,6 +360045,10 @@ async function startSession(validated, res) {
359527
360045
  ...deviceId ? { "appium:udid": deviceId } : {},
359528
360046
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
359529
360047
  "appium:noReset": true,
360048
+ "appium:newCommandTimeout": Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT) > 0 ? Math.floor(Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT)) : 600,
360049
+ "appium:uiautomator2ServerLaunchTimeout": 12e4,
360050
+ "appium:uiautomator2ServerInstallTimeout": 12e4,
360051
+ "appium:adbExecTimeout": 12e4,
359530
360052
  "appium:chromedriverExecutableDir": ensureWritableChromedriverExecutableDir(),
359531
360053
  ...realCaps
359532
360054
  };
@@ -360618,11 +361140,33 @@ var MobileBridgeDriver = class {
360618
361140
  */
360619
361141
  async snapshot() {
360620
361142
  return this.withSessionRecovery("snapshot", async () => {
360621
- const [xml, screenshot, windowSize] = await Promise.all([
360622
- getPageSource(this.sessionPath),
360623
- getScreenshot(this.sessionPath),
360624
- getWindowSize(this.sessionPath)
360625
- ]);
361143
+ let xml = await getPageSource(this.sessionPath);
361144
+ let screenshot = await getScreenshot(this.sessionPath);
361145
+ let windowSize = await getWindowSize(this.sessionPath);
361146
+ if (!xml) {
361147
+ try {
361148
+ await waitForUiSettle(this.sessionPath, {
361149
+ timeoutMs: 8e3,
361150
+ minStableFrames: 2,
361151
+ pollMs: 300,
361152
+ minElements: 1
361153
+ });
361154
+ } catch (err) {
361155
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
361156
+ }
361157
+ xml = await getPageSource(this.sessionPath);
361158
+ }
361159
+ if (!xml) {
361160
+ try {
361161
+ await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
361162
+ await waitForColdBootSettle(this.sessionPath);
361163
+ } catch (err) {
361164
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
361165
+ }
361166
+ xml = await getPageSource(this.sessionPath);
361167
+ if (!screenshot) screenshot = await getScreenshot(this.sessionPath);
361168
+ if (!windowSize) windowSize = await getWindowSize(this.sessionPath);
361169
+ }
360626
361170
  if (!xml && !screenshot && !windowSize) {
360627
361171
  await this.assertSessionReachable();
360628
361172
  }
@@ -363562,14 +364106,17 @@ ${finalVerifyResult.logs ?? ""}`;
363562
364106
  // Android default) — a personal physical device must never have
363563
364107
  // its app data wiped.
363564
364108
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
363565
- maxSessionRecoveries: 1,
364109
+ // Dual-device discovery + long AI turns can lose WDA/UIA2 more than
364110
+ // once per run. One recovery was too tight — the next snapshot
364111
+ // failed as "no UI tree" after a single WDA death.
364112
+ maxSessionRecoveries: 3,
363566
364113
  recoverSession: async (cause) => {
363567
364114
  if (discoveryTeardownStarted) {
363568
364115
  throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
363569
364116
  }
363570
364117
  const lost = session;
363571
364118
  mobileOnLog(
363572
- `[appium] discovery session lost (${cause.message}); recreating once on the leased device`
364119
+ `[appium] discovery session lost (${cause.message}); recreating on the leased device`
363573
364120
  );
363574
364121
  if (lost) {
363575
364122
  detachMirrorSession(lost.deviceId);