@validate.qa/runner 1.0.90 → 1.0.92

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 +990 -148
  2. package/dist/cli.mjs +990 -148
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2038,7 +2038,8 @@ var init_project_key = __esm({
2038
2038
  "reviews",
2039
2039
  "admin",
2040
2040
  "login",
2041
- "admin-view"
2041
+ "admin-view",
2042
+ "mcp"
2042
2043
  ]);
2043
2044
  PROJECT_KEY_MIN_LENGTH = 2;
2044
2045
  PROJECT_KEY_MAX_LENGTH = 12;
@@ -3129,22 +3130,23 @@ function labelForTarget(target) {
3129
3130
  return nonEmpty2(target?.locatorBundle?.accessibilityId) ?? nonEmpty2(target?.locatorBundle?.text) ?? nonEmpty2(target?.locatorBundle?.contentDesc) ?? nonEmpty2(target?.primary);
3130
3131
  }
3131
3132
  function semanticTarget(label, platform3, base2) {
3132
- const primary = mobileTextXPath(label, platform3);
3133
+ const textXPath = mobileTextXPath(label, platform3);
3134
+ const primary = nonEmpty2(base2?.primary) ?? textXPath;
3133
3135
  return {
3134
3136
  primary,
3135
3137
  fallbacks: uniq([
3138
+ ...base2?.fallbacks ?? [],
3139
+ textXPath,
3136
3140
  `~${label}`,
3137
- label,
3138
- base2?.primary,
3139
- ...base2?.fallbacks ?? []
3141
+ label
3140
3142
  ]).filter((value) => value !== primary),
3141
3143
  screenName: base2?.screenName ?? "HealedAssertion",
3142
3144
  locatorBundle: {
3143
- accessibilityId: label,
3144
- xpath: primary,
3145
- className: "",
3146
- text: label,
3147
- contentDesc: label,
3145
+ accessibilityId: nonEmpty2(base2?.locatorBundle?.accessibilityId) ?? label,
3146
+ xpath: nonEmpty2(base2?.locatorBundle?.xpath) ?? textXPath,
3147
+ className: base2?.locatorBundle?.className ?? "",
3148
+ text: nonEmpty2(base2?.locatorBundle?.text) ?? label,
3149
+ contentDesc: nonEmpty2(base2?.locatorBundle?.contentDesc) ?? label,
3148
3150
  ...base2?.locatorBundle?.resourceId ? { resourceId: base2.locatorBundle.resourceId } : {}
3149
3151
  }
3150
3152
  };
@@ -3322,6 +3324,26 @@ var init_mobile_step_healer = __esm({
3322
3324
  });
3323
3325
 
3324
3326
  // ../shared/dist/mobile-cross-platform.js
3327
+ function androidActivityPackageOf(owner) {
3328
+ const lastDot = owner.lastIndexOf(".");
3329
+ if (lastDot <= 0)
3330
+ return null;
3331
+ return /^[A-Z]/.test(owner.slice(lastDot + 1)) ? owner.slice(0, lastDot) : null;
3332
+ }
3333
+ function mobileScreenOwnerBelongsToApp(owner, targetAppId) {
3334
+ const ownerId = owner.trim();
3335
+ const appId = targetAppId.trim();
3336
+ if (!ownerId || !appId)
3337
+ return false;
3338
+ if (ownerId === appId)
3339
+ return true;
3340
+ if (ownerId.startsWith(`${appId}.`))
3341
+ return true;
3342
+ const activityPackage = androidActivityPackageOf(ownerId);
3343
+ if (activityPackage && appId.startsWith(`${activityPackage}.`))
3344
+ return true;
3345
+ return false;
3346
+ }
3325
3347
  function isMobilePlatform(value) {
3326
3348
  return value === "IOS" || value === "ANDROID";
3327
3349
  }
@@ -6574,6 +6596,7 @@ __export(dist_exports, {
6574
6596
  mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
6575
6597
  mobileLocatorBundleHasIdentity: () => mobileLocatorBundleHasIdentity,
6576
6598
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
6599
+ mobileScreenOwnerBelongsToApp: () => mobileScreenOwnerBelongsToApp,
6577
6600
  mobileWanderTraceEnabled: () => mobileWanderTraceEnabled,
6578
6601
  normalizeControlName: () => normalizeControlName,
6579
6602
  normalizeTtsModel: () => normalizeTtsModel,
@@ -8583,7 +8606,7 @@ var require_credential_tools = __commonJS({
8583
8606
  type: "function",
8584
8607
  function: {
8585
8608
  name: "set_credential",
8586
- description: 'Persist a NEW value for a stored credential after a test has successfully rotated it (e.g. "change password" flow). Call this AFTER you have verified the change succeeded (e.g. the new password actually works for login, or the success toast is visible). The runner updates the project test variable, fires a CREDENTIAL_UPDATED notification, and the new value is used by all subsequent tests. Never call this on a negative test (one that verifies wrong-credential is rejected) and never call it speculatively before submit+verify. The new value you supply MUST be the value you typed into the page \u2014 the runner does not re-read it from the DOM. Only call for keys already present in the Test Credentials section.',
8609
+ description: 'Persist a NEW value for a stored credential after a test has successfully rotated it. REQUIRED after any successful password change OR forgot-password/reset of the STORED test user (the account whose email is TEST_USER_EMAIL) so Test Data stays in sync and later tests do not fail with stale credentials. Also use after in-session "change password" from settings. Call this AFTER you have verified the change succeeded (success toast, re-login with the new value, or confirmed post-reset state). The runner updates the project test variable, fires a CREDENTIAL_UPDATED notification, and the new value is used by all subsequent tests. Never call this on a negative test (one that verifies wrong-credential is rejected) and never call it speculatively before submit+verify. The new value you supply MUST be the value you typed into the page \u2014 the runner does not re-read it from the DOM. Only call for keys already present in the Test Credentials section.',
8587
8610
  parameters: {
8588
8611
  type: "object",
8589
8612
  properties: {
@@ -8597,7 +8620,7 @@ var require_credential_tools = __commonJS({
8597
8620
  },
8598
8621
  reason: {
8599
8622
  type: "string",
8600
- description: 'Short human-readable explanation of why the credential changed (e.g. "User changed password from settings page"). Surfaced in the notification.'
8623
+ description: 'Short human-readable explanation of why the credential changed (e.g. "User changed password from settings page" or "Password reset completed for stored test user"). Surfaced in the notification.'
8601
8624
  }
8602
8625
  },
8603
8626
  required: ["credentialKey", "newValue"]
@@ -8659,12 +8682,13 @@ SECURITY RULES:
8659
8682
  - NEVER include credential values in \`capture_page\` observations, \`record_journey\` steps, \`record_transition\` triggerLabel, or any summary text. (You can't \u2014 they're not in your context \u2014 but do not invent placeholder values either; describe the action as "Fill email with test credentials".)
8660
8683
  - Registration flows that need a UNIQUE email (e.g. \`testuser-\${Date.now()}@example.com\`) should still use \`browser_type\` with a synthetic value \u2014 \`fill_credential\` is only for stored login credentials.
8661
8684
  ${opts.canRotate ? `
8662
- CREDENTIAL ROTATION (rare \u2014 read this carefully):
8663
- - The ONLY time to call \`set_credential\` is when the test:
8664
- (1) logs in as the STORED test user (the one whose credential is in the list above \u2014 e.g. logged in via \`fill_credential\` with TEST_USER_EMAIL / TEST_USER_PASSWORD), AND
8665
- (2) successfully changes THAT SAME user's credential (password change from their settings page, admin resetting their own token, etc.), AND
8666
- (3) you have proof the change took effect (success toast visible, re-login with the new value works, etc.).
8667
- - DO NOT call \`set_credential\` if the test registered a fresh user (e.g. \`testuser-\${Date.now()}@example.com\`) and changed THAT user's password \u2014 the fresh user is throwaway and not tracked anywhere. The stored credential is unchanged in that case.
8685
+ CREDENTIAL ROTATION (required when the STORED user's secret changes \u2014 read carefully):
8686
+ - You MUST call \`set_credential\` when the test successfully changes a secret belonging to the STORED test user (the account whose keys are listed above \u2014 typically TEST_USER_EMAIL / TEST_USER_PASSWORD). Covered flows:
8687
+ (1) **In-session change password** \u2014 log in as the stored user, open settings \u2192 Change Password, submit a new password, verify success; OR
8688
+ (2) **Forgot-password / password-reset of the stored user** \u2014 request reset for the stored email (fill_credential with TEST_USER_EMAIL on the forgot-password form), complete the emailed link/code, set a NEW password on the reset form, and verify success. You do NOT need to log in with the old password first \u2014 a successful reset of the stored account still MUST write the new password back.
8689
+ - After either flow succeeds, call \`set_credential\` with credentialKey \`TEST_USER_PASSWORD\` (or the matching stored password key) and newValue = the new password you typed. This updates the Test Data page so every later test uses the correct credentials.
8690
+ - Proof required before calling: success toast / confirmed reset state / re-login with the new value works.
8691
+ - DO NOT call \`set_credential\` if the test registered a fresh throwaway user (e.g. \`testuser-\${Date.now()}@example.com\` or a provision_identity inbox used only for a one-off signup) and changed THAT user's password \u2014 the stored credential is untouched in that case.
8668
8692
  - DO NOT call \`set_credential\` on a negative test (one that verifies a bad/mismatched password is rejected, or a permission-denied case).
8669
8693
  - DO NOT call it before submit + verify \u2014 a partial failure would corrupt the stored credential.
8670
8694
  - Call it AT MOST ONCE per test, AFTER verification. The \`newValue\` is the value you just typed into the new-password / new-token field; the runner persists it server-side and never echoes it back.
@@ -75739,7 +75763,7 @@ Rules:
75739
75763
  - Register ONCE per run. If signup says the account ALREADY exists, log in with that email + the \`${opts.passwordKey}\` password instead of registering again.
75740
75764
  - Only call the verification tools AFTER submitting the signup form; pass the app as \`senderMatch\` (the inbox is shared).`;
75741
75765
  }
75742
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens, incremental) {
75766
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens, incremental, knowledgeContext) {
75743
75767
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
75744
75768
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
75745
75769
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
@@ -75784,12 +75808,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
75784
75808
  ## Application Brief
75785
75809
  ${appBrief}
75786
75810
  Use this to prioritize which screens and flows to cover.` : "";
75811
+ const facts = knowledgeContext?.trim();
75812
+ const knowledgeBlock = facts ? `
75813
+
75814
+ ## Known Facts (project documents \u2014 optional)
75815
+ ${facts.slice(0, 4e3)}
75816
+ Prioritize exploring surfaces related to these facts when visible. If absent, explore solely from live UI. Never invent screens from facts alone.` : "";
75787
75817
  const existingScreensBlock = mode === "survey" && incremental && existingScreens && existingScreens.length > 0 ? `
75788
75818
 
75789
75819
  ## ALREADY DISCOVERED SCREENS (skip these; find new)
75790
75820
  These screens were mapped in a previous survey. Do NOT re-map them \u2014 use your budget to find NEW screens and flows not listed here. If you land on one of these, move on to something new.
75791
75821
  ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
75792
- return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
75822
+ return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${knowledgeBlock}${existingScreensBlock}`;
75793
75823
  }
75794
75824
  function buildKickoffMessage(mode) {
75795
75825
  return mode === "survey" ? "Start with mobile_snapshot on the launch screen. Driver observations are saved automatically; use capture_screen only to give important stable destinations useful names. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen and capture_screen it. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Capture each distinct screen and record journeys after meaningful flows.";
@@ -76189,7 +76219,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76189
76219
  if (authTracker.required) {
76190
76220
  log2(`Authenticated survey completion gate enabled (${ctx.target?.resetPolicy ?? "AUTO"}): the run must reach a structurally observed product surface before planning.`);
76191
76221
  }
76192
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens, ctx.incremental) + (!registerRequested ? buildMobileCredentialPromptBlock(credentialSecrets) : "") + (inboxEnabled && inboxState ? buildMobileInboxPromptBlock({
76222
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens, ctx.incremental, ctx.knowledgeContext) + (!registerRequested ? buildMobileCredentialPromptBlock(credentialSecrets) : "") + (inboxEnabled && inboxState ? buildMobileInboxPromptBlock({
76193
76223
  email: inboxState.email,
76194
76224
  passwordKey: inboxState.passwordKey ?? "TEST_USER_PASSWORD",
76195
76225
  context: mode
@@ -80357,8 +80387,10 @@ var require_scenario_taxonomy = __commonJS({
80357
80387
  exports2.inferScenarioKind = inferScenarioKind;
80358
80388
  exports2.inferScenarioKinds = inferScenarioKinds;
80359
80389
  exports2.isStrongOracleKind = isStrongOracleKind;
80390
+ exports2.kindRequiresStateDelta = kindRequiresStateDelta;
80360
80391
  exports2.formatRequiredScenarioKindsPrompt = formatRequiredScenarioKindsPrompt;
80361
80392
  exports2.formatCoverageGapLog = formatCoverageGapLog;
80393
+ exports2.synthesizeScenariosForMissingKinds = synthesizeScenariosForMissingKinds;
80362
80394
  exports2.ALL_SCENARIO_KINDS = [
80363
80395
  "smoke_nav",
80364
80396
  "happy_submit",
@@ -80369,7 +80401,9 @@ var require_scenario_taxonomy = __commonJS({
80369
80401
  "auth_session",
80370
80402
  "navigation_integrity",
80371
80403
  "permission_system",
80372
- "double_submit"
80404
+ "double_submit",
80405
+ "search_filter",
80406
+ "list_refresh"
80373
80407
  ];
80374
80408
  var DELETE_RE = /\b(delete|remove|archive|trash)\b/i;
80375
80409
  var EMAIL_RE = /\b(e-?mail|email address)\b/i;
@@ -80381,7 +80415,12 @@ var require_scenario_taxonomy = __commonJS({
80381
80415
  var BADGE_LABEL_RE = /\b(badge|count|open invoices|unread|notifications?)\b/i;
80382
80416
  var PERMISSION_RE = /\b(camera|microphone|location|photos?|permission|allow access|not now)\b/i;
80383
80417
  var SETTINGS_RE = /\b(settings|preferences|profile|account)\b/i;
80384
- var TAB_RE = /\b(tab|home|catalog|forms|edges|inbox|feed|dashboard)\b/i;
80418
+ var TAB_RE = /\b(tab|home|inbox|feed|dashboard)\b/i;
80419
+ var SEARCH_RE = /\b(search|filter|find|query)\b/i;
80420
+ var EMPTY_STATE_RE = /\b(no items|nothing here|empty|get started|add first|create first|no results)\b/i;
80421
+ var EMPTY_CTA_RE = /\b(add first|create first|get started|add item|new item)\b/i;
80422
+ var REFRESH_RE = /\b(pull to refresh|pull-to-refresh|refresh|updated:)\b/i;
80423
+ var FAVORITE_RE = /\b(favorite|favourite|heart|bookmark|star|like)\b/i;
80385
80424
  function elementText(el) {
80386
80425
  return [
80387
80426
  el.label ?? "",
@@ -80419,6 +80458,10 @@ var require_scenario_taxonomy = __commonJS({
80419
80458
  let hasPrimarySubmit = false;
80420
80459
  let hasTabOrDrawerNav = false;
80421
80460
  let hasPermissionCopy = false;
80461
+ let hasSearchField = false;
80462
+ let hasEmptyStateCta = false;
80463
+ let hasPullToRefresh = false;
80464
+ let hasFavoriteToggle = false;
80422
80465
  for (const state2 of screens) {
80423
80466
  const labels = state2.elements.map(elementText);
80424
80467
  const blob = [state2.screenName ?? "", state2.screenType ?? "", ...state2.observations ?? [], ...labels].join(" ");
@@ -80450,6 +80493,17 @@ var require_scenario_taxonomy = __commonJS({
80450
80493
  }
80451
80494
  if (PERMISSION_RE.test(blob) || state2.screenType === "PERMISSION")
80452
80495
  hasPermissionCopy = true;
80496
+ if (SEARCH_RE.test(blob) || state2.elements.some((el) => isEditable(el) && SEARCH_RE.test(elementText(el))) || state2.elements.some((el) => el.role === "searchbox" || /SearchView|SearchBar/i.test(el.role ?? ""))) {
80497
+ hasSearchField = true;
80498
+ }
80499
+ if (EMPTY_STATE_RE.test(blob) && (EMPTY_CTA_RE.test(blob) || labels.some((t) => EMPTY_CTA_RE.test(t)))) {
80500
+ hasEmptyStateCta = true;
80501
+ }
80502
+ if (REFRESH_RE.test(blob))
80503
+ hasPullToRefresh = true;
80504
+ if (FAVORITE_RE.test(blob) || state2.elements.some((el) => FAVORITE_RE.test(elementText(el)) && el.actionable !== false)) {
80505
+ hasFavoriteToggle = true;
80506
+ }
80453
80507
  }
80454
80508
  return {
80455
80509
  hasListWithRowActions,
@@ -80461,7 +80515,11 @@ var require_scenario_taxonomy = __commonJS({
80461
80515
  hasDestructiveConfirm,
80462
80516
  hasPrimarySubmit,
80463
80517
  hasTabOrDrawerNav,
80464
- hasPermissionCopy
80518
+ hasPermissionCopy,
80519
+ hasSearchField,
80520
+ hasEmptyStateCta,
80521
+ hasPullToRefresh,
80522
+ hasFavoriteToggle
80465
80523
  };
80466
80524
  }
80467
80525
  function requiredScenarioKinds(signals) {
@@ -80478,13 +80536,13 @@ var require_scenario_taxonomy = __commonJS({
80478
80536
  if (signals.hasNumericBadge) {
80479
80537
  kinds.add("count_delta");
80480
80538
  }
80481
- if (signals.hasSettingsToggles) {
80539
+ if (signals.hasSettingsToggles || signals.hasFavoriteToggle) {
80482
80540
  kinds.add("persistence_roundtrip");
80483
80541
  }
80484
80542
  if (signals.hasSignOut) {
80485
80543
  kinds.add("auth_session");
80486
80544
  }
80487
- if (signals.hasTabOrDrawerNav) {
80545
+ if (signals.hasTabOrDrawerNav || signals.hasEmptyStateCta) {
80488
80546
  kinds.add("navigation_integrity");
80489
80547
  }
80490
80548
  if (signals.hasPermissionCopy) {
@@ -80493,6 +80551,12 @@ var require_scenario_taxonomy = __commonJS({
80493
80551
  if (signals.hasPrimarySubmit && signals.hasFormFields) {
80494
80552
  kinds.add("double_submit");
80495
80553
  }
80554
+ if (signals.hasSearchField) {
80555
+ kinds.add("search_filter");
80556
+ }
80557
+ if (signals.hasPullToRefresh) {
80558
+ kinds.add("list_refresh");
80559
+ }
80496
80560
  return exports2.ALL_SCENARIO_KINDS.filter((k) => kinds.has(k));
80497
80561
  }
80498
80562
  function coverageGaps(signals, plannedKinds) {
@@ -80507,6 +80571,12 @@ var require_scenario_taxonomy = __commonJS({
80507
80571
  if (/\b(delete|remove|wrong row|entity|archive)\b/.test(text) && /\b(list|row|item|record)\b/.test(text)) {
80508
80572
  return "crud_identity";
80509
80573
  }
80574
+ if (/\b(pull.?to.?refresh|refresh list|list refresh|after refresh)\b/.test(text)) {
80575
+ return "list_refresh";
80576
+ }
80577
+ if (/\b(search|filter results|query)\b/.test(text)) {
80578
+ return "search_filter";
80579
+ }
80510
80580
  if (/\b(badge|count|unread|open invoices|\+\d|increment|decrement)\b/.test(text)) {
80511
80581
  return "count_delta";
80512
80582
  }
@@ -80516,7 +80586,7 @@ var require_scenario_taxonomy = __commonJS({
80516
80586
  if (/\b(sign\s*out|log\s*out|session|sign\s*in|remember me|auth)\b/.test(text) || scenario.type === "HAPPY_PATH" && /\b(login|sign in)\b/.test(text)) {
80517
80587
  return "auth_session";
80518
80588
  }
80519
- if (/\b(tab|navigate|navigation|back|deep link|empty.?state)\b/.test(text)) {
80589
+ if (/\b(tab|navigate|navigation|back|deep link|empty.?state|empty cta|dead route)\b/.test(text)) {
80520
80590
  return "navigation_integrity";
80521
80591
  }
80522
80592
  if (/\b(permission|camera|microphone|location|allow)\b/.test(text)) {
@@ -80539,6 +80609,9 @@ var require_scenario_taxonomy = __commonJS({
80539
80609
  function isStrongOracleKind(kind) {
80540
80610
  return kind !== "smoke_nav" && kind !== "navigation_integrity";
80541
80611
  }
80612
+ function kindRequiresStateDelta(kind) {
80613
+ return kind === "crud_identity" || kind === "count_delta" || kind === "persistence_roundtrip" || kind === "double_submit" || kind === "list_refresh" || kind === "search_filter";
80614
+ }
80542
80615
  function formatRequiredScenarioKindsPrompt(signals) {
80543
80616
  const required = requiredScenarioKinds(signals);
80544
80617
  if (required.length === 0)
@@ -80565,17 +80638,280 @@ var require_scenario_taxonomy = __commonJS({
80565
80638
  return "- permission_system: permission prompt or settings path behaves honestly (allow/deny/settings)";
80566
80639
  case "double_submit":
80567
80640
  return "- double_submit: single user intent must not double-create (assert count +1 not +2, or single confirmation)";
80641
+ case "search_filter":
80642
+ return "- search_filter: type a distinctive query observed on-device, assert the result set filters (row count/labels change); empty query restores or shows empty-state honestly";
80643
+ case "list_refresh":
80644
+ return "- list_refresh: capture list ids/count, pull-to-refresh or tap Refresh, assert list still coherent (not permanently wiped) OR honest empty + recoverable";
80568
80645
  default:
80569
80646
  return `- ${kind}`;
80570
80647
  }
80571
80648
  });
80572
- return "## Required scenario kinds (from observed UI \u2014 fill each or explain impossibility in reasoning)\nThese kinds are derived ONLY from controls/screens the explorer saw. Plan at least one scenario per kind when evidence supports it.\n" + lines.join("\n");
80649
+ return "## Required scenario kinds (from observed UI \u2014 fill each or explain impossibility in reasoning)\nThese kinds are derived ONLY from controls/screens the explorer saw. Plan at least one scenario per kind when evidence supports it.\nCap pure smoke_nav to ONE scenario per feature. Prefer strong kinds when signals require them.\nAuth credential validation (empty/wrong/malformed login) belongs ONLY on Authentication/Sign-in features \u2014 elsewhere login is setup, not a scenario.\nIf a scenario name claims delete/count/persist/refresh, expectedOutcomes MUST require before/after state proof (capture + delta), not tab chrome.\n" + lines.join("\n");
80573
80650
  }
80574
80651
  function formatCoverageGapLog(gaps) {
80575
80652
  if (gaps.length === 0)
80576
80653
  return "Scenario taxonomy: all required kinds covered by plan";
80577
80654
  return `Scenario taxonomy coverage gaps (required but not planned): ${gaps.join(", ")}`;
80578
80655
  }
80656
+ function synthesizeScenariosForMissingKinds(input) {
80657
+ const feature = input.featureName.trim() || "Feature";
80658
+ const used = input.existingNames ?? /* @__PURE__ */ new Set();
80659
+ const maxNew = Math.max(1, input.maxNew ?? 6);
80660
+ const pages = input.targetPages.length > 0 ? input.targetPages : [input.entryRoute];
80661
+ const entry = input.entryRoute || pages[0] || "";
80662
+ const out = [];
80663
+ for (const kind of input.gaps) {
80664
+ if (out.length >= maxNew)
80665
+ break;
80666
+ if (kind === "smoke_nav")
80667
+ continue;
80668
+ const draft = scenarioStubForKind(kind, feature, entry, pages);
80669
+ if (!draft)
80670
+ continue;
80671
+ let name = draft.name;
80672
+ let n = 2;
80673
+ while (used.has(name.toLowerCase())) {
80674
+ name = `${draft.name} (${n})`;
80675
+ n += 1;
80676
+ if (n > 20)
80677
+ break;
80678
+ }
80679
+ if (used.has(name.toLowerCase()))
80680
+ continue;
80681
+ used.add(name.toLowerCase());
80682
+ out.push({ ...draft, name });
80683
+ }
80684
+ return out;
80685
+ }
80686
+ function scenarioStubForKind(kind, feature, entryRoute, targetPages) {
80687
+ const base2 = {
80688
+ featureGroup: feature,
80689
+ entryRoute,
80690
+ startFromLanding: true,
80691
+ targetPages: [...targetPages],
80692
+ evidence: {
80693
+ observedLocators: [],
80694
+ observedTransitions: []
80695
+ },
80696
+ prerequisites: [],
80697
+ isAISuggested: false
80698
+ };
80699
+ const step = (action, target) => ({ action, target });
80700
+ const outcome = (type, description) => ({ type, description, reliability: "medium" });
80701
+ switch (kind) {
80702
+ case "crud_identity":
80703
+ return {
80704
+ ...base2,
80705
+ name: `${feature} - Delete entity proves identity removed`,
80706
+ type: "HAPPY_PATH",
80707
+ priority: 1,
80708
+ variant: "happy",
80709
+ goal: "Capture list/entity identity, delete or remove that entity, prove the identity set changed (not merely that the list screen is still visible)",
80710
+ steps: [
80711
+ step("Launch the app and sign in if the product requires a session", entryRoute),
80712
+ step(`Navigate to the ${feature} list or collection screen`),
80713
+ step("Capture visible row/entity identifiers into a state bag before mutation"),
80714
+ step("Perform delete/remove/archive on one specific observed entity"),
80715
+ step("Assert state delta: that entity identity is gone or the wrong neighbor was not removed")
80716
+ ],
80717
+ expectedOutcomes: [
80718
+ outcome("element-hidden", "Target entity identity no longer present in the list"),
80719
+ outcome("element-visible", "List or collection still reachable after mutation")
80720
+ ]
80721
+ };
80722
+ case "count_delta":
80723
+ return {
80724
+ ...base2,
80725
+ name: `${feature} - Count or badge changes after action`,
80726
+ type: "HAPPY_PATH",
80727
+ priority: 1,
80728
+ variant: "happy",
80729
+ goal: "Capture a numeric badge or count, perform a product action, assert the number changed as expected",
80730
+ steps: [
80731
+ step("Launch the app and sign in if needed", entryRoute),
80732
+ step(`Navigate to the ${feature} surface showing a count or badge`),
80733
+ step("Capture the numeric count/badge into a state bag"),
80734
+ step("Perform an action that should change the count (add, remove, mark read, complete)"),
80735
+ step("Assert state delta: count increased or decreased as expected")
80736
+ ],
80737
+ expectedOutcomes: [
80738
+ outcome("element-visible", "Count or badge reflects the post-action value")
80739
+ ]
80740
+ };
80741
+ case "persistence_roundtrip":
80742
+ return {
80743
+ ...base2,
80744
+ name: `${feature} - Change persists after leave and return`,
80745
+ type: "HAPPY_PATH",
80746
+ priority: 1,
80747
+ variant: "happy",
80748
+ goal: "Change a setting, toggle, or favorite; leave the screen; return; prove the value persisted or honestly reset",
80749
+ steps: [
80750
+ step("Launch the app and sign in if needed", entryRoute),
80751
+ step(`Navigate to the ${feature} settings or toggle surface`),
80752
+ step("Capture current field/toggle/favorite state"),
80753
+ step("Change a durable control (toggle, text field, favorite) and save if required"),
80754
+ step("Navigate away to another tab or parent screen"),
80755
+ step("Return to the same surface"),
80756
+ step("Assert state delta: value persisted (or product honestly shows it did not)")
80757
+ ],
80758
+ expectedOutcomes: [
80759
+ outcome("element-visible", "Changed value is still reflected after round-trip")
80760
+ ]
80761
+ };
80762
+ case "negative_validation":
80763
+ return {
80764
+ ...base2,
80765
+ name: `${feature} - Invalid or empty input is rejected`,
80766
+ type: "EDGE_CASE",
80767
+ priority: 2,
80768
+ variant: "validation",
80769
+ goal: "Submit empty or malformed input and assert error feedback or blocked submit \u2014 never invent success",
80770
+ steps: [
80771
+ step("Launch the app and sign in if needed", entryRoute),
80772
+ step(`Navigate to the ${feature} form`),
80773
+ step("Leave required fields empty or type structurally invalid input (e.g. email-like field without a domain)"),
80774
+ step("Attempt submit"),
80775
+ step("Assert validation error is visible OR submit remains blocked on the form")
80776
+ ],
80777
+ expectedOutcomes: [
80778
+ outcome("element-visible", "Validation error or blocked submit state is visible")
80779
+ ]
80780
+ };
80781
+ case "happy_submit":
80782
+ return {
80783
+ ...base2,
80784
+ name: `${feature} - Happy path durable submit`,
80785
+ type: "HAPPY_PATH",
80786
+ priority: 1,
80787
+ variant: "happy",
80788
+ goal: "Complete the primary form/action and assert a durable product result (new row, saved state, confirmation that outlives a toast)",
80789
+ steps: [
80790
+ step("Launch the app and sign in if needed", entryRoute),
80791
+ step(`Navigate to the ${feature} form or primary action`),
80792
+ step("Fill required fields with run-scoped values"),
80793
+ step("Submit or save"),
80794
+ step("Assert durable outcome (new row, success state, or persisted values)")
80795
+ ],
80796
+ expectedOutcomes: [
80797
+ outcome("element-visible", "Durable success result is visible")
80798
+ ]
80799
+ };
80800
+ case "auth_session":
80801
+ return {
80802
+ ...base2,
80803
+ name: `${feature} - Session proof after sign-in or sign-out`,
80804
+ type: "HAPPY_PATH",
80805
+ priority: 1,
80806
+ variant: "happy",
80807
+ goal: "Prove authenticated session (profile/sign-out) or that sign-out leaves only logged-out chrome",
80808
+ steps: [
80809
+ step("Launch the app", entryRoute),
80810
+ step("Sign in with test credentials if not already authenticated"),
80811
+ step("Open account/profile or use a signed-in-only control"),
80812
+ step("Assert Sign out / profile identity is available, or complete sign-out and assert login chrome")
80813
+ ],
80814
+ expectedOutcomes: [
80815
+ outcome("element-visible", "Signed-in or signed-out durable chrome matches the action")
80816
+ ]
80817
+ };
80818
+ case "navigation_integrity":
80819
+ return {
80820
+ ...base2,
80821
+ name: `${feature} - Navigation reaches intended destination`,
80822
+ type: "USER_JOURNEY",
80823
+ priority: 2,
80824
+ variant: "happy",
80825
+ goal: "Follow in-app navigation (tab, back, empty-state CTA, overflow) and assert the intended product destination \u2014 not hub chrome after wandering",
80826
+ steps: [
80827
+ step("Launch the app and sign in if needed", entryRoute),
80828
+ step(`Navigate into ${feature} via the observed entry path`),
80829
+ step("If an empty-state CTA is present, tap it and assert the destination form/error"),
80830
+ step("Use back/up if nested and assert the parent screen family")
80831
+ ],
80832
+ expectedOutcomes: [
80833
+ outcome("screen-visible", "Intended destination screen family is visible")
80834
+ ]
80835
+ };
80836
+ case "permission_system":
80837
+ return {
80838
+ ...base2,
80839
+ name: `${feature} - Permission prompt behaves honestly`,
80840
+ type: "EDGE_CASE",
80841
+ priority: 2,
80842
+ variant: "happy",
80843
+ goal: "Trigger permission flow and assert allow/deny/settings path is honest",
80844
+ steps: [
80845
+ step("Launch the app and sign in if needed", entryRoute),
80846
+ step(`Navigate to the ${feature} permission-related surface`),
80847
+ step("Trigger the permission request"),
80848
+ step("Assert system or in-app permission UI appears and deny/allow path is reachable")
80849
+ ],
80850
+ expectedOutcomes: [
80851
+ outcome("element-visible", "Permission or settings path is visible")
80852
+ ]
80853
+ };
80854
+ case "double_submit":
80855
+ return {
80856
+ ...base2,
80857
+ name: `${feature} - Single submit does not double-create`,
80858
+ type: "EDGE_CASE",
80859
+ priority: 2,
80860
+ variant: "bug",
80861
+ goal: "Submit once and assert count +1 (not +2) or a single confirmation",
80862
+ steps: [
80863
+ step("Launch the app and sign in if needed", entryRoute),
80864
+ step(`Navigate to the ${feature} submit flow`),
80865
+ step("Capture pre-submit count or confirmation state"),
80866
+ step("Tap primary submit only once"),
80867
+ step("Assert state delta: single creation (count +1 or one confirmation id)")
80868
+ ],
80869
+ expectedOutcomes: [
80870
+ outcome("element-visible", "Exactly one durable result from a single submit")
80871
+ ]
80872
+ };
80873
+ case "search_filter":
80874
+ return {
80875
+ ...base2,
80876
+ name: `${feature} - Search filters the visible result set`,
80877
+ type: "HAPPY_PATH",
80878
+ priority: 1,
80879
+ variant: "happy",
80880
+ goal: "Type a distinctive query and assert the result set filters; clear/empty query restores or shows honest empty state",
80881
+ steps: [
80882
+ step("Launch the app and sign in if needed", entryRoute),
80883
+ step(`Navigate to the ${feature} search/filter surface`),
80884
+ step("Capture the unfiltered list identifiers"),
80885
+ step("Type a distinctive query into the search field"),
80886
+ step("Assert state delta: visible results changed (filtered or empty-state)")
80887
+ ],
80888
+ expectedOutcomes: [
80889
+ outcome("element-visible", "Filtered results or empty-state copy matches the query")
80890
+ ]
80891
+ };
80892
+ case "list_refresh":
80893
+ return {
80894
+ ...base2,
80895
+ name: `${feature} - Pull to refresh keeps list coherent`,
80896
+ type: "HAPPY_PATH",
80897
+ priority: 1,
80898
+ variant: "happy",
80899
+ goal: "Capture list state, pull-to-refresh or tap Refresh, assert list remains coherent (not permanently wiped) or honest empty",
80900
+ steps: [
80901
+ step("Launch the app and sign in if needed", entryRoute),
80902
+ step(`Navigate to the ${feature} list with refresh`),
80903
+ step("Capture list identifiers before refresh"),
80904
+ step("Pull to refresh or activate Refresh"),
80905
+ step("Assert state delta: list still has coherent content or an honest empty state")
80906
+ ],
80907
+ expectedOutcomes: [
80908
+ outcome("element-visible", "List after refresh is coherent (not permanently destroyed without recovery)")
80909
+ ]
80910
+ };
80911
+ default:
80912
+ return null;
80913
+ }
80914
+ }
80579
80915
  }
80580
80916
  });
80581
80917
 
@@ -80664,10 +81000,15 @@ var require_mobile_discovery_prompts = __commonJS({
80664
81000
  var mobile_observation_js_1 = require_mobile_observation();
80665
81001
  var scenario_taxonomy_js_1 = require_scenario_taxonomy();
80666
81002
  var coverage_map_js_1 = require_coverage_map();
81003
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
80667
81004
  function isTargetAppScreenId(screenId, appId) {
80668
81005
  if (!appId)
80669
81006
  return true;
80670
- return screenId === appId || screenId.startsWith(`${appId}#`) || screenId.startsWith(`${appId}.`);
81007
+ const hashIndex = screenId.indexOf("#");
81008
+ const owner = (hashIndex >= 0 ? screenId.slice(0, hashIndex) : screenId).trim();
81009
+ if (!owner)
81010
+ return false;
81011
+ return (0, shared_1.mobileScreenOwnerBelongsToApp)(owner, appId);
80671
81012
  }
80672
81013
  function targetAppScreens(graph, appId) {
80673
81014
  return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
@@ -80809,12 +81150,19 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
80809
81150
  Citing observed labels / accessibility-ids in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
80810
81151
 
80811
81152
  ## Coverage Strategy
80812
- - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
80813
- - For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 13) + validation (wrong password, empty fields)
81153
+ - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission). When email-like fields exist, include ONE weak/malformed value case (structurally invalid input such as missing domain) and assert reject OR honest accept \u2014 never invent error copy not seen in evidence.
81154
+ - For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 13) + validation (wrong password, empty fields) **only when this deep feature is Authentication / Sign-in / Onboarding / Account**. For every other feature, credentials are setup steps inside product scenarios \u2014 do NOT emit standalone "Sign In - Empty Credentials" / "Wrong Password" scenarios under Home, Inbox, Settings, etc.
80814
81155
  - For credential sign-in flows: only plan credential scenarios when evidence shows the credential path itself \u2014 an email/username field, a password field, and a native Sign In/Login/Submit control on the target screen, or an Explorer-Recorded Journey that typed those fields and submitted them. Do NOT treat Google/Apple/Facebook/SSO buttons, magic-link entry, logout menus, profile menus, or already-signed-in screens as evidence for credential sign-in. If the explorer only observed signed-in menu/logout/session surfaces, plan session/logout scenarios only; surface missing credential coverage as a gap instead of inventing credential sign-in variants.
80815
- - For CRUD: each scenario MUST own the entity it operates on. Read/search/update/delete/cancel-on-existing/view-existing scenarios MUST include a create step earlier in the same scenario (or declare an immutable seed fixture by name). Never assume a record from prior runs or sibling tests exists. When create/delete are tightly coupled, bundle into a single USER_JOURNEY: create \u2192 verify \u2192 delete \u2192 verify removal.
81156
+ - For CRUD: each scenario MUST own the entity it operates on. Read/search/update/delete/cancel-on-existing/view-existing scenarios MUST include a create step earlier in the same scenario (or declare an immutable seed fixture by name). Never assume a record from prior runs or sibling tests exists. When create/delete are tightly coupled, bundle into a single USER_JOURNEY: create \u2192 verify \u2192 delete \u2192 verify removal. Delete scenarios MUST capture entity identity BEFORE delete and assert that identity is gone or the wrong neighbor remains (list chrome still visible is NOT enough).
81157
+ - For list counts / badges: plan before/after count or badge checks when those numbers were observed \u2014 not "Navigate up is visible".
81158
+ - For search/filter fields: plan a filter scenario with a distinctive query and assert the visible result set changes.
81159
+ - For pull-to-refresh / Refresh: capture list state, refresh, assert list remains coherent (not permanently wiped) or honest empty.
81160
+ - For empty-state CTAs ("Add first\u2026", "Create first\u2026"): tap the CTA and assert the DESTINATION product surface (form, detail, or honest error) \u2014 not hub tab chrome after wandering.
81161
+ - For settings/toggles/favorites: change value, leave screen (navigate away), return, assert value persisted or honestly reset.
80816
81162
  - For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule 12 - never stop at "button tapped" or "sheet opened"
80817
81163
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
81164
+ - **Smoke budget:** at most ONE pure smoke/navigation-only scenario per feature. Prefer required strong kinds when UI signals support them.
81165
+ - **Name = proof:** if the scenario name claims delete/count/persist/refresh/empty-CTA destination, expectedOutcomes and steps must prove that claim. Never name a test after a failure mode the steps do not exercise.
80818
81166
 
80819
81167
  ## Output JSON Schema
80820
81168
  {
@@ -81105,6 +81453,13 @@ Each entry is exactly one of:
81105
81453
  if (context.appBrief) {
81106
81454
  sections.push(`## Application Brief
81107
81455
  ${context.appBrief.slice(0, 500)}`);
81456
+ }
81457
+ const knownFacts = context.knowledgeContext?.trim();
81458
+ if (knownFacts) {
81459
+ sections.push(`## Known Facts
81460
+ ${knownFacts.slice(0, 8e3)}
81461
+
81462
+ These facts come from project documents / knowledge. Prefer scenarios that verify them when the explorer observed related UI. If a fact conflicts with live UI evidence, prefer live evidence. When this section is absent, rely only on explorer evidence.`);
81108
81463
  }
81109
81464
  const targetBits = [];
81110
81465
  if (context.platform)
@@ -81543,14 +81898,46 @@ ${constraints.join("\n")}`);
81543
81898
  }
81544
81899
  return normalized;
81545
81900
  }
81901
+ function buildMobileTaxonomyScenarios(input, gaps) {
81902
+ const orderedScreens = plannableTargetAppScreens(input.graph, input.appId).filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
81903
+ if (orderedScreens.length === 0)
81904
+ return [];
81905
+ const entryScreenId = orderedScreens[0].screenId;
81906
+ const targetPages = orderedScreens.slice(0, 8).map((s) => s.screenId);
81907
+ const featureName = input.featureName?.trim() || nonGenericScreenName(orderedScreens[0], input.appId) || inferMobileFeatureGroupName(orderedScreens.slice(0, 3), input.appId) || "App";
81908
+ const signals = (0, scenario_taxonomy_js_1.extractUiSignals)(input.graph, input.appId);
81909
+ const missing = gaps && gaps.length > 0 ? gaps : (0, scenario_taxonomy_js_1.requiredScenarioKinds)(signals).filter((k) => k !== "smoke_nav");
81910
+ if (missing.length === 0) {
81911
+ return (0, scenario_taxonomy_js_1.synthesizeScenariosForMissingKinds)({
81912
+ gaps: ["navigation_integrity", "happy_submit"],
81913
+ featureName,
81914
+ entryRoute: entryScreenId,
81915
+ targetPages,
81916
+ existingNames: new Set((input.existingTestNames ?? []).map((n) => n.toLowerCase())),
81917
+ maxNew: Math.min(3, Math.max(1, input.testBudget || 3))
81918
+ });
81919
+ }
81920
+ return (0, scenario_taxonomy_js_1.synthesizeScenariosForMissingKinds)({
81921
+ gaps: missing,
81922
+ featureName,
81923
+ entryRoute: entryScreenId,
81924
+ targetPages,
81925
+ existingNames: new Set((input.existingTestNames ?? []).map((n) => n.toLowerCase())),
81926
+ maxNew: Math.min(6, Math.max(2, input.testBudget || 6))
81927
+ });
81928
+ }
81546
81929
  function buildMobileFallbackScenarios(input) {
81547
81930
  if (input.mode === "survey")
81548
81931
  return [];
81549
- if (input.featureName?.trim())
81550
- return [];
81932
+ if (input.featureName?.trim()) {
81933
+ return buildMobileTaxonomyScenarios(input);
81934
+ }
81551
81935
  const orderedScreens = plannableTargetAppScreens(input.graph, input.appId).filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
81552
81936
  if (orderedScreens.length === 0)
81553
81937
  return [];
81938
+ const taxonomy = buildMobileTaxonomyScenarios(input);
81939
+ if (taxonomy.length > 0)
81940
+ return taxonomy;
81554
81941
  const entryScreenId = orderedScreens[0].screenId;
81555
81942
  const usedNames = new Set((input.existingTestNames ?? []).map((name) => name.toLowerCase()));
81556
81943
  const scenarios = [];
@@ -81843,6 +82230,7 @@ ${constraints.join("\n")}`);
81843
82230
  userPromptOverride: formatMobileEvidenceForPlanner(input.graph, {
81844
82231
  mode: input.mode,
81845
82232
  appBrief: input.appBrief,
82233
+ knowledgeContext: input.knowledgeContext,
81846
82234
  appId: input.appId,
81847
82235
  platform: input.platform,
81848
82236
  featureName: input.featureName,
@@ -81875,44 +82263,65 @@ ${constraints.join("\n")}`);
81875
82263
  if (guestWallFilter.dropped > 0) {
81876
82264
  mobileAdaptationLogs.push(`Dropped ${guestWallFilter.dropped} guest-wall scenario(s) \u2014 explore session was authenticated; re-exec would not see sign-in gates`);
81877
82265
  }
81878
- const plannedScenarios = guestWallFilter.scenarios;
82266
+ let plannedScenarios = guestWallFilter.scenarios;
81879
82267
  let taxonomyGapLogs = [];
81880
82268
  let taxonomyCoverageGaps = result.coverageGaps ?? [];
81881
- if (input.mode !== "survey" && plannedScenarios.length > 0) {
82269
+ if (input.mode !== "survey" && input.graph.screens.size > 0) {
81882
82270
  const signals = (0, scenario_taxonomy_js_1.extractUiSignals)(input.graph, input.appId);
81883
82271
  const plannedKinds = (0, scenario_taxonomy_js_1.inferScenarioKinds)(plannedScenarios);
81884
- const gaps = (0, scenario_taxonomy_js_1.coverageGaps)(signals, plannedKinds);
81885
- const coverageMap = (0, coverage_map_js_1.buildMobileCoverageMap)({
81886
- graph: input.graph,
81887
- appId: input.appId,
81888
- plannedScenarios
81889
- });
81890
- taxonomyGapLogs = [(0, scenario_taxonomy_js_1.formatCoverageGapLog)(gaps), coverageMap.summary];
81891
- if (coverageMap.warnings.length > 0) {
81892
- taxonomyGapLogs.push(...coverageMap.warnings.slice(0, 12));
82272
+ let gaps = (0, scenario_taxonomy_js_1.coverageGaps)(signals, plannedKinds);
82273
+ if (plannedScenarios.length === 0) {
82274
+ gaps = (0, scenario_taxonomy_js_1.requiredScenarioKinds)(signals).filter((k) => k !== "smoke_nav");
81893
82275
  }
81894
82276
  if (gaps.length > 0) {
81895
- taxonomyGapLogs.push(`UI signals: listRowActions=${signals.hasListWithRowActions} forms=${signals.hasFormFields} email=${signals.hasEmailField} badge=${signals.hasNumericBadge} toggles=${signals.hasSettingsToggles} signOut=${signals.hasSignOut} tabs=${signals.hasTabOrDrawerNav}`);
81896
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
81897
- taxonomyCoverageGaps = [
81898
- ...taxonomyCoverageGaps,
81899
- ...gaps.map((kind) => ({
81900
- scenarioName: `[taxonomy] ${kind}`,
81901
- reason: `Required scenario kind "${kind}" not represented in the planned set`,
81902
- suggestion: `Add a ${kind} scenario backed by observed controls, or document impossibility`,
81903
- unmetPrerequisites: [],
81904
- detectedAt
81905
- })),
81906
- ...coverageMap.warnings.filter((w) => /smoke-only|no strong-oracle/i.test(w)).map((w) => ({
81907
- scenarioName: "[coverage-map] strong-oracle gap",
81908
- reason: w,
81909
- suggestion: "Author durable assertStateDelta/assertText outcomes for this kind",
81910
- unmetPrerequisites: [],
81911
- detectedAt
81912
- }))
81913
- ];
82277
+ const synthesized = buildMobileTaxonomyScenarios(input, gaps);
82278
+ if (synthesized.length > 0) {
82279
+ const before = plannedScenarios.length;
82280
+ plannedScenarios = [...plannedScenarios, ...synthesized];
82281
+ mobileAdaptationLogs.push(`Taxonomy synthesis: added ${synthesized.length} scenario(s) for missing kinds [${gaps.join(", ")}] (plan had ${before})`);
82282
+ gaps = (0, scenario_taxonomy_js_1.coverageGaps)(signals, (0, scenario_taxonomy_js_1.inferScenarioKinds)(plannedScenarios));
82283
+ }
82284
+ }
82285
+ if (plannedScenarios.length > 0) {
82286
+ const coverageMap = (0, coverage_map_js_1.buildMobileCoverageMap)({
82287
+ graph: input.graph,
82288
+ appId: input.appId,
82289
+ plannedScenarios
82290
+ });
82291
+ taxonomyGapLogs = [(0, scenario_taxonomy_js_1.formatCoverageGapLog)(gaps), coverageMap.summary];
82292
+ if (coverageMap.warnings.length > 0) {
82293
+ taxonomyGapLogs.push(...coverageMap.warnings.slice(0, 12));
82294
+ }
82295
+ if (gaps.length > 0 || coverageMap.warnings.length > 0) {
82296
+ taxonomyGapLogs.push(`UI signals: listRowActions=${signals.hasListWithRowActions} forms=${signals.hasFormFields} email=${signals.hasEmailField} badge=${signals.hasNumericBadge} toggles=${signals.hasSettingsToggles} signOut=${signals.hasSignOut} tabs=${signals.hasTabOrDrawerNav} search=${signals.hasSearchField} refresh=${signals.hasPullToRefresh} emptyCta=${signals.hasEmptyStateCta}`);
82297
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
82298
+ taxonomyCoverageGaps = [
82299
+ ...taxonomyCoverageGaps,
82300
+ ...gaps.map((kind) => ({
82301
+ scenarioName: `[taxonomy] ${kind}`,
82302
+ reason: `Required scenario kind "${kind}" not represented in the planned set`,
82303
+ suggestion: `Add a ${kind} scenario backed by observed controls, or document impossibility`,
82304
+ unmetPrerequisites: [],
82305
+ detectedAt
82306
+ })),
82307
+ ...coverageMap.warnings.filter((w) => /smoke-only|no strong-oracle/i.test(w)).map((w) => ({
82308
+ scenarioName: "[coverage-map] strong-oracle gap",
82309
+ reason: w,
82310
+ suggestion: "Author durable assertStateDelta/assertText outcomes for this kind",
82311
+ unmetPrerequisites: [],
82312
+ detectedAt
82313
+ }))
82314
+ ];
82315
+ }
81914
82316
  }
81915
82317
  }
82318
+ if (input.mode !== "survey" && input.featureName?.trim()) {
82319
+ const deepFeature = input.featureName.trim();
82320
+ plannedScenarios = plannedScenarios.map((s) => ({
82321
+ ...s,
82322
+ featureGroup: s.featureGroup?.trim() || deepFeature
82323
+ }));
82324
+ }
81916
82325
  if (input.mode !== "survey" && plannedScenarios.length === 0 && input.graph.screens.size > 0) {
81917
82326
  const fallbackScenarios = buildMobileFallbackScenarios(input);
81918
82327
  if (fallbackScenarios.length > 0) {
@@ -81923,7 +82332,7 @@ ${constraints.join("\n")}`);
81923
82332
  logs: [
81924
82333
  ...result.logs,
81925
82334
  ...mobileAdaptationLogs,
81926
- `Mobile fallback: synthesized ${fallbackScenarios.length} screen-reachability scenario(s) from the native screen graph`
82335
+ `Mobile fallback: synthesized ${fallbackScenarios.length} taxonomy/bug-capture scenario(s) from native UI signals`
81927
82336
  ],
81928
82337
  fallbackUsed: true,
81929
82338
  fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
@@ -81967,6 +82376,25 @@ ${constraints.join("\n")}`);
81967
82376
  fallbackReason: result.fallbackReason ?? plannerGroupSelection.reason ?? `Planner omitted ${supplementedSurveyRouteCount} plannable mobile screen(s)`
81968
82377
  };
81969
82378
  }
82379
+ if (mobileFeatureGroups.length === 0 && input.graph.screens.size > 0) {
82380
+ const appId = input.appId?.trim();
82381
+ const owners = [...new Set([...input.graph.screens.values()].map((state2) => {
82382
+ const hashIndex = state2.screenId.indexOf("#");
82383
+ return (hashIndex >= 0 ? state2.screenId.slice(0, hashIndex) : state2.screenId).trim();
82384
+ }).filter(Boolean))];
82385
+ const inAppOwners = appId ? owners.filter((owner) => (0, shared_1.mobileScreenOwnerBelongsToApp)(owner, appId)) : owners;
82386
+ const reason = inAppOwners.length === 0 && appId ? `Mapped ${input.graph.screens.size} screen(s) but none were attributed to app "${appId}" (screen owners seen: ${owners.slice(0, 4).join(", ")}). No features could be created.` : `Mapped ${input.graph.screens.size} screen(s) but none could be grouped into a feature (too few actionable elements, or every screen was filtered as generic/system).`;
82387
+ return {
82388
+ ...result,
82389
+ scenarios: plannedScenarios,
82390
+ featureGroups: mobileFeatureGroups,
82391
+ coverageGaps: taxonomyCoverageGaps.length > 0 ? taxonomyCoverageGaps : result.coverageGaps,
82392
+ logs: [...result.logs, ...mobileAdaptationLogs, ...taxonomyGapLogs, `\u26A0 ${reason}`],
82393
+ warning: result.warning ?? reason,
82394
+ fallbackUsed: true,
82395
+ fallbackReason: result.fallbackReason ?? reason
82396
+ };
82397
+ }
81970
82398
  return {
81971
82399
  ...result,
81972
82400
  scenarios: plannedScenarios,
@@ -82035,6 +82463,7 @@ var require_anr_env = __commonJS({
82035
82463
  exports2.isAnrOrSystemUiEnvironmentFailure = isAnrOrSystemUiEnvironmentFailure;
82036
82464
  exports2.stepLooksLikeAnrChromeAssert = stepLooksLikeAnrChromeAssert;
82037
82465
  var ANR_TEXT_RE = /system\s*ui\s*isn['’]?t\s*responding|app\s*isn['’]?t\s*responding|application\s*not\s*responding|\banr\b|close\s*app|wait\s*and\s*see|isn['’]?t\s*responding/i;
82466
+ var SYSTEM_FREEZE_RE = /system\s*ui\s*isn['’]?t\s*responding|\bsystem\s*ui\b[^.\n]{0,40}\b(froze|frozen|hung|unresponsive|not\s*responding)\b|\b(device|emulator|simulator)\b[^.\n]{0,40}\b(froze|frozen|hung|unresponsive)\b/i;
82038
82467
  function textLooksLikeAnrOrSystemUi(text) {
82039
82468
  if (!text || typeof text !== "string")
82040
82469
  return false;
@@ -82043,7 +82472,7 @@ var require_anr_env = __commonJS({
82043
82472
  function isAnrOrSystemUiEnvironmentFailure(error2, logs2) {
82044
82473
  const blob = `${error2 ?? ""}
82045
82474
  ${logs2 ?? ""}`;
82046
- if (textLooksLikeAnrOrSystemUi(blob))
82475
+ if (SYSTEM_FREEZE_RE.test(blob))
82047
82476
  return true;
82048
82477
  if (/\bcold-boot\b/i.test(blob) && /\b(froze|hang|anr|unresponsive)\b/i.test(blob))
82049
82478
  return true;
@@ -82065,13 +82494,48 @@ var require_oracle_quality = __commonJS({
82065
82494
  "../runner-core/dist/services/mobile/oracle-quality.js"(exports2) {
82066
82495
  "use strict";
82067
82496
  Object.defineProperty(exports2, "__esModule", { value: true });
82497
+ exports2.isToastOrAckOutcomeText = isToastOrAckOutcomeText;
82498
+ exports2.isAuthPrimaryFeatureName = isAuthPrimaryFeatureName;
82499
+ exports2.isStandaloneAuthValidationScenario = isStandaloneAuthValidationScenario;
82500
+ exports2.textLooksLikeProductOutcome = textLooksLikeProductOutcome;
82501
+ exports2.textLooksLikeChromeSurface = textLooksLikeChromeSurface;
82068
82502
  exports2.assessStrongOracleQuality = assessStrongOracleQuality;
82503
+ exports2.assessScenarioClaimCoherence = assessScenarioClaimCoherence;
82069
82504
  var anr_env_js_1 = require_anr_env();
82505
+ var scenario_taxonomy_js_1 = require_scenario_taxonomy();
82070
82506
  var CHROME_ONLY_RE = /\b(tab|home tab|catalog tab|forms tab|account tab|back|menu|navigation|nav bar|bottom nav|status bar|query|search field)\b/i;
82071
82507
  var PRODUCT_OUTCOME_TEXT_RE = /\b(error|invalid|required|must|cannot|failed|denied|blocked|empty|missing|deleted|removed|saved|success|created|updated|signed?\s*out|logged?\s*out|wrong|incorrect)\b/i;
82508
+ var TOAST_ACK_EXACT_RE = /^(saved!?|success!?|successfully (saved|deleted|updated|created)!?|deleted!?|removed!?|done!?|ok!?|updated!?|created!?)$/i;
82509
+ function isToastOrAckOutcomeText(text) {
82510
+ const t = text.trim();
82511
+ if (!t)
82512
+ return false;
82513
+ if (TOAST_ACK_EXACT_RE.test(t))
82514
+ return true;
82515
+ const parts = t.split(/\s+/).filter(Boolean);
82516
+ return parts.length > 0 && parts.every((part) => TOAST_ACK_EXACT_RE.test(part));
82517
+ }
82518
+ function isAuthPrimaryFeatureName(name) {
82519
+ const n = (name ?? "").trim();
82520
+ if (!n)
82521
+ return false;
82522
+ return /\b(auth|authentication|sign[\s-]*in|log[\s-]*in|login|session|onboarding|account)\b/i.test(n);
82523
+ }
82524
+ function isStandaloneAuthValidationScenario(scenario) {
82525
+ const text = `${scenario.name ?? ""} ${scenario.goal ?? ""} ${scenario.variant ?? ""}`.toLowerCase();
82526
+ if (!/\b(sign\s*in|log\s*in|login|credentials?|password|email)\b/.test(text))
82527
+ return false;
82528
+ return /\b(empty|invalid|wrong password|malformed|required|validation|reject|blocked)\b/.test(text) || scenario.variant === "validation";
82529
+ }
82072
82530
  function stepBlob(step) {
82073
82531
  return [step.description, step.assertion, step.value, step.target?.primary].filter(Boolean).join(" ");
82074
82532
  }
82533
+ function textLooksLikeProductOutcome(text) {
82534
+ return PRODUCT_OUTCOME_TEXT_RE.test(text);
82535
+ }
82536
+ function textLooksLikeChromeSurface(text) {
82537
+ return CHROME_ONLY_RE.test(text);
82538
+ }
82075
82539
  function isChromeOnlyVisible(step) {
82076
82540
  if (step.action !== "assertVisible")
82077
82541
  return false;
@@ -82108,6 +82572,8 @@ var require_oracle_quality = __commonJS({
82108
82572
  if (/^(email|password|username|name|query|search)$/i.test((s.assertion ?? "").trim())) {
82109
82573
  return false;
82110
82574
  }
82575
+ if (isToastOrAckOutcomeText(s.assertion ?? s.value ?? ""))
82576
+ return false;
82111
82577
  return blob.length >= 3;
82112
82578
  });
82113
82579
  const hasStrongValidationVisible = steps.some((s) => {
@@ -82115,7 +82581,12 @@ var require_oracle_quality = __commonJS({
82115
82581
  return false;
82116
82582
  if ((0, anr_env_js_1.stepLooksLikeAnrChromeAssert)(s) || isChromeOnlyVisible(s))
82117
82583
  return false;
82118
- return PRODUCT_OUTCOME_TEXT_RE.test(stepBlob(s));
82584
+ const blob = stepBlob(s);
82585
+ if (isToastOrAckOutcomeText(s.assertion ?? s.value ?? ""))
82586
+ return false;
82587
+ if (isToastOrAckOutcomeText(blob))
82588
+ return false;
82589
+ return PRODUCT_OUTCOME_TEXT_RE.test(blob);
82119
82590
  });
82120
82591
  const strong = hasStateDelta || hasStrongAssertText || hasStrongValidationVisible;
82121
82592
  let reason;
@@ -82140,6 +82611,70 @@ var require_oracle_quality = __commonJS({
82140
82611
  reason
82141
82612
  };
82142
82613
  }
82614
+ function assessScenarioClaimCoherence(scenario, steps) {
82615
+ const variant = scenario.variant === "happy" || scenario.variant === "validation" || scenario.variant === "bug" ? scenario.variant : void 0;
82616
+ const kind = (0, scenario_taxonomy_js_1.inferScenarioKind)({
82617
+ type: scenario.type || "HAPPY_PATH",
82618
+ variant,
82619
+ name: scenario.name ?? "",
82620
+ goal: scenario.goal ?? ""
82621
+ });
82622
+ const quality = assessStrongOracleQuality(steps);
82623
+ const text = `${scenario.name ?? ""} ${scenario.goal ?? ""}`.toLowerCase();
82624
+ const featureGroup = scenario.featureGroup ?? "";
82625
+ if (isStandaloneAuthValidationScenario(scenario) && featureGroup && !isAuthPrimaryFeatureName(featureGroup)) {
82626
+ return {
82627
+ coherent: false,
82628
+ kind,
82629
+ discard: true,
82630
+ reason: `auth credential validation scenario under non-auth feature "${featureGroup}" \u2014 login is setup only`
82631
+ };
82632
+ }
82633
+ if (quality.anrOnly) {
82634
+ return {
82635
+ coherent: false,
82636
+ kind,
82637
+ discard: true,
82638
+ reason: quality.reason
82639
+ };
82640
+ }
82641
+ if ((0, scenario_taxonomy_js_1.kindRequiresStateDelta)(kind) && !quality.hasStateDelta) {
82642
+ const namesStateClaim = /\b(count|delta|delete|removed|persist|roundtrip|wrong row|entity|double.?submit|fires?:?\s*2|refresh)\b/i.test(text);
82643
+ return {
82644
+ coherent: false,
82645
+ kind,
82646
+ // Discard when the name/kind clearly promised state proof; otherwise demote.
82647
+ discard: namesStateClaim || kind === "crud_identity" || kind === "count_delta" || kind === "list_refresh",
82648
+ reason: `scenario kind ${kind} claims durable state change but steps lack assertStateDelta (${quality.reason})`
82649
+ };
82650
+ }
82651
+ if (/\b(empty.?state|empty cta|dead route|404|not found|broken blank)\b/i.test(text)) {
82652
+ const hasProductDestination = steps.some((s) => {
82653
+ if (s.action !== "assertVisible" && s.action !== "assertText")
82654
+ return false;
82655
+ if ((0, anr_env_js_1.stepLooksLikeAnrChromeAssert)(s))
82656
+ return false;
82657
+ const blob = stepBlob(s);
82658
+ if (CHROME_ONLY_RE.test(blob) && !PRODUCT_OUTCOME_TEXT_RE.test(blob))
82659
+ return false;
82660
+ return PRODUCT_OUTCOME_TEXT_RE.test(blob) || /\b(404|not found|empty|no items|blank|error)\b/i.test(blob);
82661
+ });
82662
+ if (!hasProductDestination && !quality.hasStateDelta) {
82663
+ return {
82664
+ coherent: false,
82665
+ kind,
82666
+ discard: false,
82667
+ reason: "empty-state/dead-route claim without destination or empty-state product assertion"
82668
+ };
82669
+ }
82670
+ }
82671
+ return {
82672
+ coherent: true,
82673
+ kind,
82674
+ discard: false,
82675
+ reason: "scenario claim matches step oracles"
82676
+ };
82677
+ }
82143
82678
  }
82144
82679
  });
82145
82680
 
@@ -82657,7 +83192,18 @@ ${hybridSkeleton.join("\n")}`;
82657
83192
  function hasStableId(element) {
82658
83193
  return !!nonEmpty3(element.accessibilityId) || !!nonEmpty3(element.resourceId);
82659
83194
  }
82660
- function buildSystemPrompt(platform3, targetAppId, maxIterations = MAX_SCENARIO_ITERATIONS) {
83195
+ function buildKnownFactsPromptBlock(knowledgeContext) {
83196
+ const text = knowledgeContext?.trim();
83197
+ if (!text)
83198
+ return "";
83199
+ return `
83200
+
83201
+ ## Known Facts (project documents \u2014 optional)
83202
+ ${text.slice(0, 6e3)}
83203
+
83204
+ When facts are present, prefer assertions that prove them if the live UI supports it. If a fact conflicts with what you see on device, prefer live UI. Never invent requirements not in these facts and not visible on screen. When this section is absent, rely only on the scenario + live UI.`;
83205
+ }
83206
+ function buildSystemPrompt(platform3, targetAppId, maxIterations = MAX_SCENARIO_ITERATIONS, knowledgeContext) {
82661
83207
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
82662
83208
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
82663
83209
  return `You are an expert mobile QA agent generating ONE automated test by driving a real ${platformLabel} app on a connected device. The app has just been relaunched into a clean state.
@@ -82681,14 +83227,14 @@ The scenario name + goal + steps describe ONE product behavior from survey/disco
82681
83227
  - For list delete/create or count changes: mobile_capture_state({stateKey, kind}) BEFORE the mutation, then mobile_assert_state_delta({stateKey, expect}) AFTER \u2014 BOTH are required. captureState alone does NOT prove the outcome.
82682
83228
  - Negative validation: leave a required field EMPTY (or type clearly invalid email-like input), submit, assert the app BLOCKS success \u2014 error message / still on form / no navigation to Home. Do NOT fill a valid password-only path and call it "empty email".
82683
83229
  - 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.
82684
- - Do NOT assert bottom-nav tab labels alone, notification badge counts ("N unread"), or one-time empty-state CTAs as the scenario outcome.
83230
+ - Do NOT assert bottom-nav tab labels alone or notification badge counts ("N unread") as the scenario outcome.
82685
83231
  - NEVER assert system freeze chrome ("System UI isn't responding", ANR dialogs, Wait/Close app). If the app freezes, finish_generate with verified:false (environment) \u2014 do NOT write a test whose only proof is the ANR dialog.
82686
83232
  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.
82687
83233
 
82688
83234
  ## Rules
82689
83235
  - Re-snapshot before every assertion so the ref is fresh.
82690
83236
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
82691
- - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
83237
+ - Assert only DURABLE product outcomes. Prefer entity identity, form values, validation copy, destination screens after navigation. For empty-state CTA scenarios: tap the CTA and assert the DESTINATION (form, detail, or honest error) \u2014 do NOT treat the CTA label alone as success, and do NOT use empty-state CTAs as proof for unrelated happy paths.
82692
83238
  - Do NOT trigger irreversible or destructive side effects \u2014 real payments; sending messages to others; and ESPECIALLY account/data deletion, deactivation, or any "type DELETE to confirm" gate (the runner hard-blocks destructive taps and confirmation keystrokes). Note them and assert what you safely can.
82693
83239
  - STATE OWNERSHIP for mutation scenarios (edit / rename / archive / remove): CREATE the record first in this same recording, then act on the row YOU created \u2014 never mutate pre-existing data (it may not exist on a clean install, so the replay fails forever, and another test may depend on it). If the app offers no way to create one, do NOT act on someone else's data: assert what you can safely prove and finish with verified:false, explaining why in the description.
82694
83240
  - If the APP itself misbehaves (product error screen, broken validation, wrong entity deleted), assert the product failure and call finish_generate with suspectedBug:true and a concrete bugDescription. If the device shows ANR / "System UI isn't responding" / freeze dialogs, that is ENVIRONMENT \u2014 finish with verified:false and do NOT assert the freeze dialog as the test outcome.
@@ -82701,7 +83247,7 @@ The scenario name + goal + steps describe ONE product behavior from survey/disco
82701
83247
  - Each action must make NEW progress toward the goal. If an action leaves the screen unchanged, do NOT repeat it \u2014 pick a different element or move on.
82702
83248
  - Finish promptly once the outcome is proven: the instant the expected outcome is visible, assert it and call finish_generate. Do not keep exploring after the goal is proven.
82703
83249
 
82704
- Max iterations: ${maxIterations}. You will be warned near the end.`;
83250
+ Max iterations: ${maxIterations}. You will be warned near the end.` + buildKnownFactsPromptBlock(knowledgeContext);
82705
83251
  }
82706
83252
  function buildGenerateCredentialPromptBlock(secrets) {
82707
83253
  const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
@@ -82791,7 +83337,7 @@ Start by calling mobile_snapshot, then execute ONLY the steps needed for this go
82791
83337
  }
82792
83338
  }
82793
83339
  async function runScenarioLoop(args) {
82794
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch } = args;
83340
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch, knowledgeContext } = args;
82795
83341
  const log2 = (line) => emitLog(redactCredentialValues(line, redactionCredentials));
82796
83342
  const actions = [];
82797
83343
  const stateBag = /* @__PURE__ */ new Map();
@@ -83169,7 +83715,7 @@ ${lines.join("\n")}`
83169
83715
  (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
83170
83716
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
83171
83717
  }
83172
- const systemPrompt = buildSystemPrompt(platform3, targetAppId, maxIterations) + buildGenerateCredentialPromptBlock(credentials);
83718
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId, maxIterations, knowledgeContext) + buildGenerateCredentialPromptBlock(credentials);
83173
83719
  const kickoff = buildScenarioKickoff(scenario);
83174
83720
  const recentExchanges = [];
83175
83721
  const hasStoredCredentials = Object.values(credentials).some((v) => typeof v === "string" && v.length > 0);
@@ -85118,6 +85664,8 @@ ${statePacket}`, redactionCredentials) },
85118
85664
  const t = text.trim();
85119
85665
  if (!t)
85120
85666
  return false;
85667
+ if ((0, oracle_quality_js_1.isToastOrAckOutcomeText)(t))
85668
+ return true;
85121
85669
  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)) {
85122
85670
  return true;
85123
85671
  }
@@ -85223,6 +85771,30 @@ ${statePacket}`, redactionCredentials) },
85223
85771
  }
85224
85772
  const ephemeral = modelAsserts.filter((a) => isEphemeralContentAssertText(assertIdentityText(a)));
85225
85773
  const durable = modelAsserts.filter((a) => !isEphemeralContentAssertText(assertIdentityText(a)));
85774
+ const chromeOnlyVisible = (a) => {
85775
+ if (a.type !== "ASSERT_VISIBLE")
85776
+ return false;
85777
+ const blob = assertIdentityText(a);
85778
+ if ((0, oracle_quality_js_1.isToastOrAckOutcomeText)(blob))
85779
+ return true;
85780
+ return /\b(tab|home|settings|back|menu|navigation|nav bar|bottom nav|status bar)\b/i.test(blob) && !/\b(error|invalid|required|failed|deleted|removed|created)\b/i.test(blob);
85781
+ };
85782
+ if (durable.length > 0 && durable.every(chromeOnlyVisible) && !actions.some((a) => a.type === "ASSERT_STATE_DELTA")) {
85783
+ return {
85784
+ ok: false,
85785
+ severity: "downgrade",
85786
+ reason: "only landing/chrome visibility after interaction \u2014 not a durable product outcome",
85787
+ signals: {
85788
+ apiTest: false,
85789
+ interacted: actions.some((a) => a.type === "TAP" || a.type === "TYPE"),
85790
+ mutated: actions.some((a) => a.type === "TYPE" || a.type === "CLEAR" || a.type === "TAP"),
85791
+ navOnly: false,
85792
+ hasOutcomeProof: false,
85793
+ errorBanner: false,
85794
+ terminal: null
85795
+ }
85796
+ };
85797
+ }
85226
85798
  if (ephemeral.length > 0 && durable.length === 0) {
85227
85799
  return {
85228
85800
  ok: false,
@@ -85317,10 +85889,11 @@ ${statePacket}`, redactionCredentials) },
85317
85889
  }
85318
85890
  const baseStrengthVerdict = evaluateMobileAssertionStrength(actions);
85319
85891
  const bugDescription = recording.finish?.bugDescription?.trim() ?? "";
85892
+ const bugWitnessEnabled = (0, shared_1.parseBoolFlag)(process.env.DISCOVERY_BUG_WITNESS_ENABLED, true);
85320
85893
  const bugLooksLikeAnr = (0, anr_env_js_1.textLooksLikeAnrOrSystemUi)(bugDescription);
85321
- const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0 && !bugLooksLikeAnr;
85894
+ const bugWitnessHonored = bugWitnessEnabled && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0 && !bugLooksLikeAnr;
85322
85895
  if (recording.finish?.suspectedBug === true && !bugWitnessHonored) {
85323
- log2(" suspectedBug declared but NOT honored: " + (bugLooksLikeAnr ? "ANR/System UI freeze is environment noise, not a product bug-witness." : process.env.DISCOVERY_BUG_WITNESS_ENABLED !== "true" ? "bug-witness channel disabled (DISCOVERY_BUG_WITNESS_ENABLED)." : bugDescription.length < 12 ? "bugDescription too short." : "no honored assert backs the observation."));
85896
+ log2(" suspectedBug declared but NOT honored: " + (bugLooksLikeAnr ? "ANR/System UI freeze is environment noise, not a product bug-witness." : !bugWitnessEnabled ? "bug-witness channel disabled (DISCOVERY_BUG_WITNESS_ENABLED)." : bugDescription.length < 12 ? "bugDescription too short." : "no honored assert backs the observation."));
85324
85897
  }
85325
85898
  const strengthVerdict = bugWitnessHonored ? {
85326
85899
  ...baseStrengthVerdict,
@@ -85350,30 +85923,48 @@ ${statePacket}`, redactionCredentials) },
85350
85923
  if (selectorLintEnforced && verified) {
85351
85924
  log2(` selector-quality lint (enforce): ${selectorQuality.summary.high} high-severity locator issue(s) \u2014 tagging @needs-review.`);
85352
85925
  }
85353
- const reviewTag = verified && !selectorLintEnforced ? "@verified" : "@needs-review";
85926
+ const strengthBlocked = !strengthVerdict.ok || strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug" || !verified || coldResetUnavailable;
85927
+ const claimCoherence = (0, oracle_quality_js_1.assessScenarioClaimCoherence)({
85928
+ name: scenario.name,
85929
+ goal: scenario.goal,
85930
+ type: scenario.type,
85931
+ variant: scenario.variant,
85932
+ featureGroup: scenario.featureGroup
85933
+ }, compiled.steps);
85934
+ const oracleQualityEarly = (0, oracle_quality_js_1.assessStrongOracleQuality)(compiled.steps);
85935
+ if (oracleQualityEarly.anrOnly || claimCoherence.discard) {
85936
+ log2(` discarding incoherent/ANR recording: ${claimCoherence.discard ? claimCoherence.reason : oracleQualityEarly.reason}`);
85937
+ return null;
85938
+ }
85939
+ const reviewTag = verified && !selectorLintEnforced && claimCoherence.coherent ? "@verified" : "@needs-review";
85354
85940
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
85355
85941
  if (strengthVerdict.severity === "downgrade")
85356
85942
  tags.push("@smoke");
85943
+ if (strengthVerdict.severity === "route-bug" && !tags.includes("@bug-found")) {
85944
+ tags.push("@bug-found");
85945
+ }
85357
85946
  if (coldResetUnavailable)
85358
85947
  tags.push("@warm-state");
85359
- const strengthBlocked = !strengthVerdict.ok || strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug" || !verified || coldResetUnavailable;
85360
85948
  {
85361
85949
  const kind = (0, scenario_taxonomy_js_1.inferScenarioKind)(scenario);
85362
- const oracleQuality = (0, oracle_quality_js_1.assessStrongOracleQuality)(compiled.steps);
85363
- if (oracleQuality.anrOnly) {
85364
- log2(` discarding ANR-chrome-only recording: ${oracleQuality.reason}`);
85365
- return null;
85366
- }
85950
+ const oracleQuality = oracleQualityEarly;
85367
85951
  if (oracleQuality.unpairedCaptures > 0) {
85368
85952
  log2(` oracle quality: ${oracleQuality.reason}`);
85369
85953
  }
85370
- const strong = verified && !strengthBlocked && strengthVerdict.severity === "none" && oracleQuality.strong && ((0, scenario_taxonomy_js_1.isStrongOracleKind)(kind) || oracleQuality.hasStateDelta || oracleQuality.hasStrongAssertText);
85954
+ if (!claimCoherence.coherent) {
85955
+ log2(` scenario claim weak: ${claimCoherence.reason}`);
85956
+ if (!tags.includes("@smoke"))
85957
+ tags.push("@smoke");
85958
+ }
85959
+ const strong = claimCoherence.coherent && verified && !strengthBlocked && strengthVerdict.severity === "none" && oracleQuality.strong && ((0, scenario_taxonomy_js_1.isStrongOracleKind)(kind) || oracleQuality.hasStateDelta || oracleQuality.hasStrongAssertText);
85371
85960
  if (strong)
85372
85961
  tags.push("@strong-oracle");
85373
85962
  else if (!tags.includes("@smoke")) {
85374
85963
  tags.push("@smoke");
85375
85964
  if (!oracleQuality.strong) {
85376
85965
  log2(` tagged @smoke (not strong-oracle): ${oracleQuality.reason}`);
85966
+ } else if (!claimCoherence.coherent) {
85967
+ log2(` tagged @smoke (claim incoherent): ${claimCoherence.reason}`);
85377
85968
  }
85378
85969
  }
85379
85970
  }
@@ -85393,11 +85984,9 @@ ${statePacket}`, redactionCredentials) },
85393
85984
  playwrightCode: "",
85394
85985
  verified,
85395
85986
  tags,
85396
- // Per-test feature label → TestCase.suite. One deep run plans scenarios for
85397
- // MULTIPLE feature groups (the planner reuses survey feature names), so the
85398
- // run-level ctx.featureName fallback in /discovery-test would wrongly file
85399
- // every test under the deep-dived feature. The scenario's own group is the
85400
- // correct home; the runner forwards it as `suiteName`.
85987
+ // Per-test suite hint. Server deep mode overrides suite to ctx.featureName
85988
+ // so leaf features receive their own tests; full-mode multi-group still uses
85989
+ // featureGroup when present.
85401
85990
  ...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
85402
85991
  // Carry the planner scenario identity so incremental/resume can match an
85403
85992
  // already-generated scenario reliably (scenarioRef.name === the plan's
@@ -85564,7 +86153,8 @@ ${statePacket}`, redactionCredentials) },
85564
86153
  credentials: credentialSecrets,
85565
86154
  redactionCredentials: credentialValuesForRedaction,
85566
86155
  log: log2,
85567
- allowRelaunch: (ctx.target?.resetPolicy ?? "AUTO") !== "PRESERVE_APP_DATA"
86156
+ allowRelaunch: (ctx.target?.resetPolicy ?? "AUTO") !== "PRESERVE_APP_DATA",
86157
+ knowledgeContext: ctx.knowledgeContext
85568
86158
  });
85569
86159
  consecutiveInfraFailures = 0;
85570
86160
  const test = assembleTest({
@@ -85629,13 +86219,23 @@ var require_failure_triage = __commonJS({
85629
86219
  "../runner-core/dist/services/mobile/failure-triage.js"(exports2) {
85630
86220
  "use strict";
85631
86221
  Object.defineProperty(exports2, "__esModule", { value: true });
86222
+ exports2.isOracleStep = isOracleStep;
85632
86223
  exports2.hasStrongMobileOracle = hasStrongMobileOracle;
85633
86224
  exports2.isStrongOracleStep = isStrongOracleStep;
85634
86225
  exports2.listStrongOracleKinds = listStrongOracleKinds;
85635
86226
  exports2.triageMobileFailure = triageMobileFailure;
85636
86227
  exports2.triageToHealTerminal = triageToHealTerminal;
85637
86228
  var anr_env_js_1 = require_anr_env();
85638
- var STRONG_ACTIONS = /* @__PURE__ */ new Set(["assertStateDelta", "assertText", "captureState"]);
86229
+ var oracle_quality_js_1 = require_oracle_quality();
86230
+ var STRONG_ACTIONS = /* @__PURE__ */ new Set(["assertStateDelta", "assertText"]);
86231
+ var ORACLE_ACTIONS = /* @__PURE__ */ new Set([
86232
+ "assertVisible",
86233
+ "assertText",
86234
+ "assertStateDelta"
86235
+ ]);
86236
+ function isOracleStep(step) {
86237
+ return ORACLE_ACTIONS.has(step.action);
86238
+ }
85639
86239
  var CHROME_VISIBLE_RE = /\b(tab|home|settings|back|menu|navigation|nav bar|bottom nav|status bar)\b/i;
85640
86240
  function hasStrongMobileOracle(steps) {
85641
86241
  return steps.some(isStrongOracleStep);
@@ -85651,23 +86251,31 @@ var require_failure_triage = __commonJS({
85651
86251
  step.target?.primary ?? "",
85652
86252
  ...step.target?.fallbacks ?? []
85653
86253
  ].join(" ");
85654
- if (/\b(error|invalid|required|must|cannot|failed|denied|blocked|empty|missing|deleted|removed|saved|success|created|updated)\b/i.test(blob)) {
86254
+ if ((0, anr_env_js_1.stepLooksLikeAnrChromeAssert)(step))
86255
+ return false;
86256
+ if ((0, oracle_quality_js_1.isToastOrAckOutcomeText)(step.assertion ?? step.value ?? ""))
86257
+ return false;
86258
+ if ((0, oracle_quality_js_1.textLooksLikeProductOutcome)(blob))
85655
86259
  return true;
85656
- }
85657
- if (CHROME_VISIBLE_RE.test(blob) && blob.length < 80)
86260
+ const anchor = [step.target?.primary ?? "", ...step.target?.fallbacks ?? []].join(" ").trim();
86261
+ if (!anchor)
85658
86262
  return false;
85659
- return false;
86263
+ if (CHROME_VISIBLE_RE.test(blob) || (0, oracle_quality_js_1.textLooksLikeChromeSurface)(blob))
86264
+ return false;
86265
+ return blob.trim().length >= 3;
85660
86266
  }
85661
86267
  function listStrongOracleKinds(steps) {
85662
86268
  const kinds = /* @__PURE__ */ new Set();
86269
+ const hasDelta = steps.some((s) => s.action === "assertStateDelta");
85663
86270
  for (const step of steps) {
85664
86271
  if (step.action === "assertStateDelta")
85665
86272
  kinds.add("assertStateDelta");
85666
86273
  else if (step.action === "assertText")
85667
86274
  kinds.add("assertText");
85668
- else if (step.action === "captureState")
85669
- kinds.add("captureState");
85670
- else if (isStrongOracleStep(step))
86275
+ else if (step.action === "captureState") {
86276
+ if (hasDelta)
86277
+ kinds.add("captureState");
86278
+ } else if (isStrongOracleStep(step))
85671
86279
  kinds.add(step.action);
85672
86280
  }
85673
86281
  return [...kinds];
@@ -85689,6 +86297,8 @@ ${logs2 ?? ""}`.trim();
85689
86297
  return true;
85690
86298
  if (/Missing mobile test variable/i.test(text))
85691
86299
  return true;
86300
+ if (/entry.?screen drift|screen drift|started on an unexpected screen/i.test(text))
86301
+ return true;
85692
86302
  if (/\b(quota|rate.?limit|429|lockout)\b/i.test(text))
85693
86303
  return true;
85694
86304
  if (/\b(device (?:offline|disconnected|not found)|session (?:not started|terminated|deleted))\b/i.test(text)) {
@@ -85709,7 +86319,10 @@ ${logs2 ?? ""}`.trim();
85709
86319
  return /not found|no such element|No identity locator|unable to locate/i.test(text);
85710
86320
  }
85711
86321
  function looksLikeOracleFailure(text) {
85712
- return /assertStateDelta/i.test(text) || /expected state/i.test(text) || /expected (?:["'].+["'] )?(?:to be |text|visible|removed|count)/i.test(text) || /assertion failed|Assert .+ (?:visible|shows|contains)/i.test(text);
86322
+ return /assertStateDelta/i.test(text) || /expected state/i.test(text) || /expected (?:["'].+["'] )?(?:to be |text|visible|removed|count)/i.test(text) || /assertion failed|Assert .+ (?:visible|shows|contains)/i.test(text) || /found but not visible/i.test(text);
86323
+ }
86324
+ function looksLikeUnpairedStateDelta(text) {
86325
+ return /assertStateDelta[^\n]*no prior captureState/i.test(text);
85713
86326
  }
85714
86327
  function triageMobileFailure(input) {
85715
86328
  const failureCount = Math.max(1, input.failureCount ?? 1);
@@ -85718,11 +86331,20 @@ ${logs2 ?? ""}`.trim();
85718
86331
  const prefix = leadingPrefix(input.error);
85719
86332
  const strongKinds = listStrongOracleKinds(input.steps);
85720
86333
  const hasStrong = strongKinds.length > 0 || tags.some((t) => t === "@strong-oracle" || t === "strong-oracle");
86334
+ const failureText = (input.error ?? "").trim() || text;
86335
+ const failedStep = typeof input.failedStepOrder === "number" ? input.steps.find((s) => s.order === input.failedStepOrder) : void 0;
86336
+ const failedStepIsOracle = !!failedStep && isOracleStep(failedStep);
86337
+ const postHeal = input.postHeal === true;
86338
+ const locatorDrift = looksLikeLocatorMiss(failureText) && !failedStepIsOracle;
86339
+ const explicitOracleFailure = looksLikeOracleFailure(failureText);
86340
+ const missingOutcome = failedStepIsOracle && looksLikeLocatorMiss(failureText);
85721
86341
  const evidence = {
85722
86342
  hasStrongOracle: hasStrong,
85723
86343
  strongOracleKinds: strongKinds,
85724
86344
  failureCount,
85725
- errorPrefix: prefix
86345
+ errorPrefix: prefix,
86346
+ ...failedStep ? { failedStepAction: failedStep.action } : {},
86347
+ ...failedStep ? { failedOnOracleStep: failedStepIsOracle } : {}
85726
86348
  };
85727
86349
  if ((0, anr_env_js_1.isAnrOrSystemUiEnvironmentFailure)(input.error, input.logs)) {
85728
86350
  return {
@@ -85787,11 +86409,47 @@ ${logs2 ?? ""}`.trim();
85787
86409
  evidence
85788
86410
  };
85789
86411
  }
85790
- if (hasStrong && failureCount >= 2 && (looksLikeOracleFailure(text) || !looksLikeLocatorMiss(text))) {
85791
- if (looksLikeLocatorMiss(text) && failureCount < 3) {
86412
+ if (looksLikeUnpairedStateDelta(failureText)) {
86413
+ return {
86414
+ verdict: "test_invalid",
86415
+ reason: "assertStateDelta has no prior captureState for its key \u2014 the test cannot prove a delta",
86416
+ shouldHeal: false,
86417
+ healVerdict: "test_invalid",
86418
+ evidence
86419
+ };
86420
+ }
86421
+ if (hasStrong && strongKinds.includes("assertStateDelta") && /assertStateDelta/i.test(failureText) && explicitOracleFailure && !locatorDrift) {
86422
+ return {
86423
+ verdict: "app_bug_candidate",
86424
+ reason: "assertStateDelta oracle failed \u2014 durable product state did not match expected outcome",
86425
+ shouldHeal: false,
86426
+ healVerdict: "app_bug",
86427
+ evidence
86428
+ };
86429
+ }
86430
+ if (hasStrong && (strongKinds.includes("assertText") || strongKinds.includes("assertVisible")) && explicitOracleFailure && !locatorDrift) {
86431
+ return {
86432
+ verdict: "app_bug_candidate",
86433
+ reason: `Strong oracle (${strongKinds.join(", ") || "tagged"}) failed with product-shaped error`,
86434
+ shouldHeal: false,
86435
+ healVerdict: "app_bug",
86436
+ evidence
86437
+ };
86438
+ }
86439
+ if (hasStrong && missingOutcome && postHeal) {
86440
+ return {
86441
+ verdict: "app_bug_candidate",
86442
+ reason: `Strong oracle (${strongKinds.join(", ") || "tagged"}) outcome never appeared and heal could not locate it`,
86443
+ shouldHeal: false,
86444
+ healVerdict: "app_bug",
86445
+ evidence
86446
+ };
86447
+ }
86448
+ if (hasStrong && failureCount >= 2) {
86449
+ if ((locatorDrift || missingOutcome) && !postHeal) {
85792
86450
  return {
85793
86451
  verdict: "needs_heal",
85794
- reason: "Strong oracle present but error looks like a locator miss \u2014 attempt heal",
86452
+ reason: "Strong oracle present but the element was not found \u2014 attempt heal first",
85795
86453
  shouldHeal: true,
85796
86454
  evidence
85797
86455
  };
@@ -85938,7 +86596,7 @@ var require_oracle_protection = __commonJS({
85938
86596
  return `heal patch dropped assertText outcome proof (${before.assertTexts.length} \u2192 ${after.assertTexts.length})`;
85939
86597
  }
85940
86598
  if (lost.length > 0) {
85941
- const softKept = lost.every((t) => after.assertTexts.some((a) => a.includes(t) || t.includes(a)));
86599
+ const softKept = lost.every((t) => after.assertTexts.some((a) => a === t || a.includes(t)));
85942
86600
  if (!softKept) {
85943
86601
  return `heal patch replaced assertText expectations (${lost.slice(0, 2).join("; ")}) \u2014 outcome must stay`;
85944
86602
  }
@@ -85964,6 +86622,23 @@ var require_oracle_protection = __commonJS({
85964
86622
  return `heal patch downgraded strong oracle "${key.slice(0, 60)}" to assertVisible`;
85965
86623
  }
85966
86624
  }
86625
+ const OUTCOME_LANG = /\b(error|invalid|required|empty|saved|success|created|updated|deleted|removed|failed|missing)\b/i;
86626
+ const CHROME_TARGET = /\b(tab|home|settings|back|menu|navigation|nav bar|bottom nav|status bar)\b/i;
86627
+ for (const orig of original) {
86628
+ if (orig.action !== "assertVisible" || !(0, failure_triage_js_1.isStrongOracleStep)(orig))
86629
+ continue;
86630
+ const origBlob = [orig.assertion, orig.description, orig.target?.primary].filter(Boolean).join(" ");
86631
+ if (!OUTCOME_LANG.test(origBlob))
86632
+ continue;
86633
+ const key = normalizeText(orig.description ?? orig.assertion ?? "");
86634
+ const match = patched.find((p) => p.action === "assertVisible" && normalizeText(p.description ?? p.assertion ?? "") === key);
86635
+ if (!match)
86636
+ continue;
86637
+ const newTarget = match.target?.primary ?? "";
86638
+ if (CHROME_TARGET.test(newTarget) && !OUTCOME_LANG.test(newTarget)) {
86639
+ return `heal patch retargeted outcome assert "${key.slice(0, 60)}" onto chrome`;
86640
+ }
86641
+ }
85967
86642
  return null;
85968
86643
  }
85969
86644
  function oraclesPreserved(original, patched) {
@@ -86054,14 +86729,14 @@ var require_mobile_heal_postprocess = __commonJS({
86054
86729
  isVerifiedFix = false;
86055
86730
  const anrEnv = (0, anr_env_js_1.isAnrOrSystemUiEnvironmentFailure)(heal.healReason ?? args.finalError ?? args.reproduceError, typeof args.finalError === "string" ? args.finalError : void 0);
86056
86731
  const isConfigIssue = heal.verdict === "config_issue" || heal.verdict === "app_bug" && anrEnv;
86057
- const isAppBug = !anrEnv && (heal.verdict === "app_bug" || Boolean(oracleWeakening && heal.passed));
86732
+ const isAppBug = !anrEnv && heal.verdict === "app_bug";
86058
86733
  const extraTags = new Set((heal.extraTags ?? []).filter((t) => typeof t === "string" && t.length > 0));
86059
86734
  if (secretLeak)
86060
86735
  extraTags.add("@needs-review");
86061
- if (oracleWeakening && !anrEnv) {
86062
- extraTags.add("@needs-review");
86736
+ if (isAppBug)
86063
86737
  extraTags.add("@bug-found");
86064
- }
86738
+ if (oracleWeakening && !anrEnv)
86739
+ extraTags.add("@needs-review");
86065
86740
  if (anrEnv) {
86066
86741
  extraTags.delete("@bug-found");
86067
86742
  extraTags.add("@needs-review");
@@ -86085,7 +86760,7 @@ var require_mobile_heal_postprocess = __commonJS({
86085
86760
  } else if (secretLeak) {
86086
86761
  error2 = `HEAL_SECRET_LEAK: healed steps contained a literal secret (${secretLeak}); patch dropped, tagged @needs-review`;
86087
86762
  } else if (oracleWeakening && !anrEnv) {
86088
- error2 = `APP_BUG: heal attempted to drop strong oracle (${oracleWeakening}); patch refused`;
86763
+ error2 = `HEAL_ORACLE_REFUSED: heal attempted to weaken a strong oracle (${oracleWeakening}); patch refused, tagged @needs-review`;
86089
86764
  } else if (isConfigIssue) {
86090
86765
  const reason = heal.healReason ?? args.finalError ?? args.reproduceError ?? "environment/rate-limit blocker";
86091
86766
  error2 = anrEnv ? `CONFIG_ISSUE: ANR / System UI freeze (not a product assertion failure) \u2014 ${reason}` : `CONFIG_ISSUE: ${reason}`;
@@ -86542,7 +87217,7 @@ Reason over steps + error + tree + registry and return working healed steps. Do
86542
87217
  1. ERROR: which step failed, on what locator/assertion? "element not found"/"no such element" almost always means a WRONG LOCATOR on a screen that DOES exist \u2014 pick a stronger IDENTITY locator that is actually present in the captured tree: a resource-id, an accessibility-id/content-desc, or an EXACT @text= predicate (never a loose contains()). Do NOT declare a feature absent from one failed locator.
86543
87218
  2. STEP RESULTS: which steps passed before the failure.
86544
87219
  3. TREE: does the current screen match what the failed step expects?
86545
- 3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs, first-run prompts, onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
87220
+ 3b. MISSING PRODUCT OUTCOME: a failing assert on empty-state CTAs, confirmation copy, a created/deleted row, or validation text is a PRODUCT oracle, not a locator problem. If that outcome is absent from a healthy screen, return verdict "app_bug". Do NOT replace outcome asserts with tab / header / nav chrome. Only retarget a locator when the SAME outcome copy is present in the tree.
86546
87221
  3c. ANR / "System UI isn't responding" / freeze dialogs: this is ENVIRONMENT (config_issue), NOT app_bug. Do not quarantine product bugs for device freezes.
86547
87222
  4. EXTERNAL AUTH HANDOFF: if the failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT heal by choosing Google/Apple/Facebook/SSO or another external app. If the test later expects Logout/session state, return test_invalid or prereq_unmet: the recorded test lacks a real credential setup.
86548
87223
  5. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
@@ -86793,6 +87468,14 @@ For a fix, return the COMPLETE steps array (not a diff).
86793
87468
  function isIdentityEqualityXpath(value) {
86794
87469
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
86795
87470
  }
87471
+ var STEP_ACTIONS_NEEDING_ELEMENT = /* @__PURE__ */ new Set([
87472
+ "tap",
87473
+ "type",
87474
+ "clear",
87475
+ "waitForElement",
87476
+ "assertVisible",
87477
+ "assertText"
87478
+ ]);
86796
87479
  function stepLacksIdentityAnchor(step) {
86797
87480
  const metadata = step.metadata;
86798
87481
  if (metadata && (metadata.appContext === "WEBVIEW" || typeof metadata.webCss === "string"))
@@ -86833,7 +87516,7 @@ For a fix, return the COMPLETE steps array (not a diff).
86833
87516
  async function tryIdentityGateStaticRepair(steps, target, ctx, options) {
86834
87517
  if (!ctx.error || !IDENTITY_GATE_ERROR_RE.test(ctx.error))
86835
87518
  return null;
86836
- const identityless = steps.filter(stepLacksIdentityAnchor);
87519
+ const identityless = steps.filter((step) => STEP_ACTIONS_NEEDING_ELEMENT.has(step.action) && stepLacksIdentityAnchor(step));
86837
87520
  if (identityless.length === 0)
86838
87521
  return null;
86839
87522
  const isAssert = (step) => /^assert/i.test(step.action);
@@ -86844,7 +87527,8 @@ For a fix, return the COMPLETE steps array (not a diff).
86844
87527
  healReason: "identity gate: an action step carries no identity locator (accessibility id / resource id / text); the recording lacks the anchors replay requires \u2014 re-record the step"
86845
87528
  };
86846
87529
  }
86847
- const pruned = steps.filter((step) => !stepLacksIdentityAnchor(step));
87530
+ const identitylessOrders = new Set(identityless.map((step) => step.order));
87531
+ const pruned = steps.filter((step) => !identitylessOrders.has(step.order));
86848
87532
  if (pruned.length === 0 || !pruned.some(isAssert) || !options.runNativeTest) {
86849
87533
  return {
86850
87534
  passed: false,
@@ -86852,6 +87536,14 @@ For a fix, return the COMPLETE steps array (not a diff).
86852
87536
  healReason: "identity gate: the only assertion(s) carry no identity locator, so the test cannot prove its outcome as recorded \u2014 re-record with an identifiable anchor"
86853
87537
  };
86854
87538
  }
87539
+ const identityOracleRejection = (0, oracle_protection_js_1.rejectOracleWeakeningPatch)(steps, pruned);
87540
+ if (identityOracleRejection) {
87541
+ return {
87542
+ passed: false,
87543
+ verdict: "app_bug",
87544
+ healReason: `identity gate: pruning identity-less asserts would drop product oracles (${identityOracleRejection})`
87545
+ };
87546
+ }
86855
87547
  const reindexed = pruned.map((step, order) => ({ ...step, order }));
86856
87548
  try {
86857
87549
  const verify = await options.runNativeTest({ steps: reindexed, target, testVariables: ctx.testVariables });
@@ -87108,7 +87800,12 @@ ${safeLastError}`);
87108
87800
  ${args.directive}`);
87109
87801
  return parts.join("\n\n");
87110
87802
  }
87111
- function findFailingStepIndex(steps, error2) {
87803
+ function findFailingStepIndex(steps, error2, failedStepOrder) {
87804
+ if (typeof failedStepOrder === "number") {
87805
+ const exact = steps.findIndex((step) => step.order === failedStepOrder);
87806
+ if (exact >= 0)
87807
+ return exact;
87808
+ }
87112
87809
  const withTarget = steps.map((step, index) => ({ step, index })).filter((entry) => Boolean(entry.step.target?.primary));
87113
87810
  if (withTarget.length === 0)
87114
87811
  return -1;
@@ -87144,9 +87841,13 @@ ${args.directive}`);
87144
87841
  if (!runNativeTest || !options.selectorRegistryContext || !ctx.error)
87145
87842
  return null;
87146
87843
  const candidates = (0, selector_registry_context_js_1.collectMobileRegistryCandidates)(options.selectorRegistryContext);
87147
- const failingIndex = findFailingStepIndex(steps, ctx.error);
87844
+ const failingIndex = findFailingStepIndex(steps, ctx.error, ctx.failedStepOrder);
87148
87845
  if (failingIndex < 0)
87149
87846
  return null;
87847
+ if ((0, failure_triage_js_1.isOracleStep)(steps[failingIndex])) {
87848
+ options.onProgress?.(`Registry preflight skipped: step ${steps[failingIndex].order} is an oracle (${steps[failingIndex].action}) \u2014 a registry selector cannot stand in for a product outcome`);
87849
+ return null;
87850
+ }
87150
87851
  let tried = 0;
87151
87852
  for (const candidate of candidates) {
87152
87853
  if (tried >= REGISTRY_PREFLIGHT_MAX_CANDIDATES)
@@ -87156,6 +87857,8 @@ ${args.directive}`);
87156
87857
  continue;
87157
87858
  tried += 1;
87158
87859
  const healedSteps = substituteStepTarget(steps, failingIndex, candidate, mapped.locatorBundle);
87860
+ if ((0, oracle_protection_js_1.rejectOracleWeakeningPatch)(steps, healedSteps))
87861
+ continue;
87159
87862
  let verify;
87160
87863
  try {
87161
87864
  verify = await runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
@@ -87481,6 +88184,41 @@ ${args.directive}`);
87481
88184
  async function executeMobileTestWithHealing2(steps, target, ctx, options) {
87482
88185
  return timed("total", "heal-total", options.onProgress, () => executeMobileTestWithHealingImpl(steps, target, ctx, options));
87483
88186
  }
88187
+ var TERMINAL_HEAL_VERDICTS = /* @__PURE__ */ new Set([
88188
+ "app_bug",
88189
+ "config_issue",
88190
+ "prereq_unmet",
88191
+ "state_stale",
88192
+ "test_invalid",
88193
+ "unhealable_dynamic"
88194
+ ]);
88195
+ function escalateFailedHealToTriage(steps, ctx, lastError) {
88196
+ const post = (0, failure_triage_js_1.triageMobileFailure)({
88197
+ steps,
88198
+ error: lastError ?? ctx.error,
88199
+ logs: ctx.logs,
88200
+ tags: ctx.tags,
88201
+ // Heal already ran and failed. Bump past the locator-miss heal window
88202
+ // so a remaining strong-oracle fail becomes APP_BUG, not silent FAILED.
88203
+ failureCount: Math.max(3, (ctx.failureCount ?? 1) + 1),
88204
+ failedStepOrder: ctx.failedStepOrder,
88205
+ // Heal ran and failed: a still-missing element is no longer "maybe drift".
88206
+ postHeal: true,
88207
+ uiSuccessWithFailedWriteApi: (0, network_oracle_js_1.uiSuccessWithFailedWriteApi)({
88208
+ apiCalls: ctx.apiCalls,
88209
+ uiPathSucceeded: ctx.uiPathSucceeded
88210
+ })
88211
+ });
88212
+ const term = (0, failure_triage_js_1.triageToHealTerminal)(post);
88213
+ if (!term)
88214
+ return null;
88215
+ return {
88216
+ passed: false,
88217
+ verdict: term.verdict,
88218
+ healReason: term.healReason,
88219
+ extraTags: term.extraTags
88220
+ };
88221
+ }
87484
88222
  async function executeMobileTestWithHealingImpl(steps, target, ctx, options) {
87485
88223
  const invalidAuthSetup = detectInvalidMobileAuthSetup(steps, ctx.error);
87486
88224
  if (invalidAuthSetup) {
@@ -87496,6 +88234,7 @@ ${args.directive}`);
87496
88234
  logs: ctx.logs,
87497
88235
  tags: ctx.tags,
87498
88236
  failureCount: ctx.failureCount,
88237
+ failedStepOrder: ctx.failedStepOrder,
87499
88238
  uiSuccessWithFailedWriteApi: networkBug
87500
88239
  });
87501
88240
  options.onProgress?.(`Pre-heal triage: ${triage.verdict} \u2014 ${triage.reason}`);
@@ -87553,6 +88292,17 @@ ${args.directive}`);
87553
88292
  if (live.providerUnavailable) {
87554
88293
  return await tryDeterministicProviderFallback({ steps, target, ctx, options }) ?? live;
87555
88294
  }
88295
+ if (!live.passed && !TERMINAL_HEAL_VERDICTS.has(live.verdict)) {
88296
+ const escalated = escalateFailedHealToTriage(steps, ctx, live.healReason ?? ctx.error);
88297
+ if (escalated) {
88298
+ return {
88299
+ ...live,
88300
+ ...escalated,
88301
+ healedSteps: live.healedSteps,
88302
+ healedAppiumCode: live.healedAppiumCode
88303
+ };
88304
+ }
88305
+ }
87556
88306
  return live;
87557
88307
  } catch {
87558
88308
  return {
@@ -87564,6 +88314,21 @@ ${args.directive}`);
87564
88314
  };
87565
88315
  }
87566
88316
  }
88317
+ if (!script.passed && !TERMINAL_HEAL_VERDICTS.has(script.verdict)) {
88318
+ const escalated = escalateFailedHealToTriage(steps, ctx, script.lastError ?? script.healReason ?? ctx.error);
88319
+ if (escalated) {
88320
+ return {
88321
+ passed: false,
88322
+ verdict: escalated.verdict,
88323
+ healedSteps: script.healedSteps,
88324
+ healedAppiumCode: script.healedAppiumCode,
88325
+ healReason: escalated.healReason,
88326
+ extraTags: escalated.extraTags,
88327
+ confidence: script.confidence,
88328
+ removeTags: script.removeTags
88329
+ };
88330
+ }
88331
+ }
87567
88332
  return {
87568
88333
  passed: script.passed,
87569
88334
  healedSteps: script.healedSteps,
@@ -87584,7 +88349,12 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
87584
88349
  1. Compare the recorded first step's expected screen to the LIVE tree.
87585
88350
  2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
87586
88351
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
87587
- 3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA or first-run prompt) that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
88352
+ 3b. If the failing step ASSERTS a product outcome (empty-state CTA, confirmation, created/deleted row, validation text) that is absent from a healthy LIVE tree, return verdict "app_bug". Do NOT replace that assert with tab / header / nav chrome. Only retarget a locator when the SAME outcome copy is present in the live tree.
88353
+ ## Strong oracle protection (non-negotiable)
88354
+ - NEVER remove or downgrade assertStateDelta, captureState, or assertText outcome proof to go green.
88355
+ - NEVER replace a durable outcome assert with mere assertVisible of a tab/header.
88356
+ - You MAY fix locators, waits, and navigation around those oracles.
88357
+ - If the app truly violates the oracle on a healthy UI, return verdict "app_bug" instead of stripping the assert.
87588
88358
  4. If the prior failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT observe/tap Google/Apple/Facebook/SSO. For logout/session tests, return test_invalid or prereq_unmet because the recorded test lacks a real credential setup.
87589
88359
  5. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
87590
88360
  ## Response \u2014 STRICT JSON, exactly one verdict:
@@ -87593,7 +88363,7 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
87593
88363
  {"verdict":"observe","action":{"type":"back"}}
87594
88364
  {"verdict":"observe","action":{"type":"relaunch"}}
87595
88365
  {"verdict":"observe","action":{"type":"reset"}} \u2190 clears app data + cold-relaunches: use when the app is in the WRONG STATE for the recorded flow (e.g. signed in when the test expects the cleared first-launch screens, stale onboarding, poisoned local state)
87596
- {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
88366
+ {"verdict":"app_bug","reason":"product defect with durable oracle evidence (wrong entity, validation bypass, wrong count, missing confirmation) \u2014 NOT ANR/System UI"}
87597
88367
  {"verdict":"config_issue","reason":"429/rate limit/lockout/quota"}
87598
88368
  {"verdict":"needs_device","reason":"cannot resolve even with live access"}
87599
88369
  {"verdict":"prereq_unmet","reason":"required entity/state absent for the account"}
@@ -91660,7 +92430,7 @@ var require_kill_tree = __commonJS({
91660
92430
  var node_child_process_1 = require("child_process");
91661
92431
  var node_util_1 = require("util");
91662
92432
  var node_fs_1 = require("fs");
91663
- var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
92433
+ var execFileAsync5 = (0, node_util_1.promisify)(node_child_process_1.execFile);
91664
92434
  var PS_TIMEOUT_MS = 2e3;
91665
92435
  var PS_BUFFER = 1024 * 1024;
91666
92436
  async function gracefulCloseWithTreeKill(options) {
@@ -91714,7 +92484,7 @@ var require_kill_tree = __commonJS({
91714
92484
  timer.unref?.();
91715
92485
  let stdout;
91716
92486
  try {
91717
- const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
92487
+ const result = await execFileAsync5("ps", ["-A", "-o", "pid=,ppid=,command="], {
91718
92488
  maxBuffer: PS_BUFFER,
91719
92489
  signal: controller.signal
91720
92490
  });
@@ -91752,7 +92522,7 @@ var require_kill_tree = __commonJS({
91752
92522
  timer.unref?.();
91753
92523
  let stdout;
91754
92524
  try {
91755
- const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid=,command="], {
92525
+ const result = await execFileAsync5("ps", ["-A", "-o", "pid=,ppid=,command="], {
91756
92526
  maxBuffer: PS_BUFFER,
91757
92527
  signal: controller.signal
91758
92528
  });
@@ -91829,7 +92599,7 @@ var require_kill_tree = __commonJS({
91829
92599
  timer.unref?.();
91830
92600
  let stdout;
91831
92601
  try {
91832
- const result = await execFileAsync4("ps", ["-A", "-o", "pid=,ppid="], {
92602
+ const result = await execFileAsync5("ps", ["-A", "-o", "pid=,ppid="], {
91833
92603
  maxBuffer: PS_BUFFER,
91834
92604
  signal: controller.signal
91835
92605
  });
@@ -101405,13 +102175,12 @@ Every assertion in the generated test must be proven live.
101405
102175
  **Frame**
101406
102176
  - \`import { test, expect } from '@playwright/test'\` with tags [${tagLiteral}].
101407
102177
  - Credentials: \`process.env.TEST_USER_EMAIL!\` / \`process.env.TEST_USER_PASSWORD!\` \u2014 never hardcode. ${emailVerifyArmed ? `Registration uses \`process.env.${test_inbox_broker_js_1.TEST_INBOX_EMAIL_ENV}!\` (the fresh real inbox address the runner mints per run \u2014 it RECEIVES the verification email; see the EMAIL VERIFICATION TEST section) with \`process.env.${test_inbox_broker_js_1.TEST_INBOX_PASSWORD_ENV}!\`. Do NOT register with an \`@example.com\` address \u2014 that domain receives no mail and email verification will hang.` : `Registration uses \`testuser-\${Date.now()}@example.com\`.`}
101408
- - **Credential rotation (rare \u2014 read carefully):** emit \`persistCredential\` ONLY when the scenario:
101409
- (a) logs in as the STORED test user (the test uses \`process.env.TEST_USER_PASSWORD\` to log in \u2014 i.e. the credential listed under the Browser Credentials section above), AND
101410
- (b) successfully rotates THAT SAME user's credential (e.g. user opens *their own* settings \u2192 Change Password \u2192 submits \u2192 success toast), AND
101411
- (c) the test has an assertion proving the change took effect.
101412
- When all three hold, generate a fresh value into a local const (\`const newPassword = \\\`Pw-\\\${Date.now()}-\\\${Math.random().toString(36).slice(2, 8)}!\\\`\`), fill it into the new-password / confirm fields, submit, verify success, THEN add: \`import { persistCredential } from '../setup/credentials';\` at the top and \`await persistCredential('TEST_USER_PASSWORD', newPassword, 'short reason');\` as the LAST statement in the test body. Also add the tag \`@mutates-credential:TEST_USER_PASSWORD\` to the test options array. Adjust the relative path of the import to match the spec's location (e.g. \`'../../setup/credentials'\` for deeply-nested specs).
101413
- - **Do NOT emit \`persistCredential\` when:** (i) the scenario registers a fresh user (${emailVerifyArmed ? `e.g. the runner-minted \`process.env.${test_inbox_broker_js_1.TEST_INBOX_EMAIL_ENV}!\` inbox account` : `e.g. \`testuser-\${Date.now()}@example.com\``}) and rotates THAT user's password \u2014 the fresh user is throwaway, the stored credential is untouched; (ii) the scenario is a negative test verifying a bad-password / mismatched-confirm / empty-field / permission-denied case; (iii) the scenario only fills form validation paths without a successful submit. In all these cases simply omit the call.
101414
- - **Restore-inline is FORBIDDEN for the stored user.** Never "change the stored user's password to a temp value, then change it back to the old one" as plain sequential statements. The change mutates the LIVE account immediately; if any later step (the second navigation, the restore submit, the success assertion) fails, the live password stays at the temp value while \`process.env.TEST_USER_PASSWORD\` still holds the old one \u2014 and every other auth test then fails to log in. The ONLY supported way to mutate the stored user's password is rotate-FORWARD via \`persistCredential\` (above): change it to a fresh value, verify success, and write that SAME final value back as the last statement. Do NOT restore the old password, and do NOT introduce a temp-then-revert sequence.
102178
+ - **Credential rotation (REQUIRED when the STORED user's password changes):** emit \`persistCredential\` whenever the scenario successfully changes the password (or other secret) of the STORED test user \u2014 the account identified by \`process.env.TEST_USER_EMAIL!\` / \`process.env.TEST_USER_PASSWORD!\` (the credentials listed under Browser Credentials above). This includes BOTH:
102179
+ (a) **In-session change password** \u2014 log in as the stored user, open *their own* settings \u2192 Change Password \u2192 submit \u2192 success assertion; AND
102180
+ (b) **Forgot-password / password-reset of the stored user** \u2014 request a reset for \`process.env.TEST_USER_EMAIL!\` (logged-out is fine), complete the emailed link/code, set a NEW password on the reset form, and assert success. You do NOT need a prior login with the old password \u2014 a successful reset of the stored account still MUST write the new password back so the Test Data page stays correct and later tests do not fail with stale credentials.
102181
+ When either case holds, generate a fresh value into a local const (\`const newPassword = \\\`Pw-\\\${Date.now()}-\\\${Math.random().toString(36).slice(2, 8)}!\\\`\`), fill it into the new-password / confirm fields, submit, verify success, THEN add: \`import { persistCredential } from '../setup/credentials';\` at the top and \`await persistCredential('TEST_USER_PASSWORD', newPassword, 'short reason');\` as the LAST statement in the test body (after the final assertion is fine only if the assertion does not depend on the write-back; prefer write-back immediately after the success proof). Also add the tag \`@mutates-credential:TEST_USER_PASSWORD\` to the test options array. Adjust the relative path of the import to match the spec's location (e.g. \`'../../setup/credentials'\` for deeply-nested specs).
102182
+ - **Do NOT emit \`persistCredential\` when:** (i) the scenario registers a fresh throwaway user (${emailVerifyArmed ? `e.g. the runner-minted \`process.env.${test_inbox_broker_js_1.TEST_INBOX_EMAIL_ENV}!\` inbox account` : `e.g. \`testuser-\${Date.now()}@example.com\``}) and rotates THAT user's password \u2014 the fresh user is throwaway, the stored credential is untouched; (ii) the scenario is a negative test verifying a bad-password / mismatched-confirm / empty-field / permission-denied case; (iii) the scenario only fills form validation paths without a successful submit; (iv) the scenario only requests a reset email without completing the reset and setting a new password. In all these cases simply omit the call.
102183
+ - **Restore-inline is FORBIDDEN for the stored user.** Never "change the stored user's password to a temp value, then change it back to the old one" as plain sequential statements. The change mutates the LIVE account immediately; if any later step (the second navigation, the restore submit, the success assertion) fails, the live password stays at the temp value while \`process.env.TEST_USER_PASSWORD\` still holds the old one \u2014 and every other auth test then fails to log in. The ONLY supported way to mutate the stored user's password is rotate-FORWARD via \`persistCredential\` (above): change it to a fresh value, verify success, and write that SAME final value back as the last write-back statement. Do NOT restore the old password, and do NOT introduce a temp-then-revert sequence.
101415
102184
  - **Start every test from \`${scenarioEntryRoute}\`.** ${hasVerifiedRecipe ? "Your first body line is the injected `await __vqaLogin(page);` \u2014 login is handled for you. After it, reach feature pages by clicking visible nav or a direct goto to the scenario route." : `First line: \`await page.goto('${scenarioEntryRoute}', { waitUntil: 'domcontentloaded' })\`. You are logged OUT; if the route redirects, that proves you must authenticate FIRST via the visible login flow \u2014 never \`waitForURL\` on a post-login route before submitting credentials.`} Do NOT deep-link to \`/login\` or \`/dashboard/...\` before login; reach feature pages by clicking visible nav (sidebar / topbar / link / button) like a user would.
101416
102185
  - ${hasVerifiedRecipe ? "Auth is proven inside `__vqaLogin` \u2014 do NOT add your own login or auth-proof assertion; focus your assertions on the feature outcome." : tags.includes("@needs-auth") ? "For `@needs-auth` scenarios, add one auth-proof assertion before feature steps: prefer Logout button, then avatar/menu, authenticated sidebar/nav, or a stable post-login heading. Never use user email, time-of-day greetings, display name text, personalized headings, or redirect alone." : "Use stable assertions tied to the current flow \u2014 avoid personalized or account-specific copy when a structural proof exists."}
101417
102186
  - Always \`await\` on \`expect()\`. Never use \`test.tags()\` as a method \u2014 tags go in the options object.
@@ -102025,7 +102794,7 @@ Fix EVERY flagged locator, keep every other line exactly as-is, then submit the
102025
102794
  }
102026
102795
  const verifyCallsSinceLastFinish = verifyCallCount - verifyCallsAtLastFinishGenerate;
102027
102796
  verifyCallsAtLastFinishGenerate = verifyCallCount;
102028
- const bugWitnessEnabled = (0, shared_1.parseBoolFlag)(process.env.DISCOVERY_BUG_WITNESS_ENABLED);
102797
+ const bugWitnessEnabled = (0, shared_1.parseBoolFlag)(process.env.DISCOVERY_BUG_WITNESS_ENABLED, true);
102029
102798
  const bugWitnessVerdict = bugWitnessEnabled ? resolveSuspectedBugVerdict(suspectedBugDeclared, bugDescription, verifyCallsSinceLastFinish, strengthVerdict?.signals) : null;
102030
102799
  if (bugWitnessVerdict) {
102031
102800
  strengthVerdict = bugWitnessVerdict;
@@ -338915,6 +339684,10 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
338915
339684
  appId,
338916
339685
  platform: platform3,
338917
339686
  appBrief: ctx.appBrief,
339687
+ // Optional project docs / knowledge facts — empty is valid; planner
339688
+ // relies on live explorer evidence when absent (bug capture does not
339689
+ // require documents).
339690
+ knowledgeContext: ctx.knowledgeContext,
338918
339691
  featureName: ctx.featureName,
338919
339692
  existingTestNames: ctx.existingTestNames,
338920
339693
  existingFeatureNames: ctx.existingFeatureNames,
@@ -349075,7 +349848,7 @@ var require_update = __commonJS({
349075
349848
  var require_opts_arg = __commonJS({
349076
349849
  "../../node_modules/tar/node_modules/mkdirp/lib/opts-arg.js"(exports2, module2) {
349077
349850
  "use strict";
349078
- var { promisify: promisify4 } = require("util");
349851
+ var { promisify: promisify5 } = require("util");
349079
349852
  var fs = require("fs");
349080
349853
  var optsArg = (opts) => {
349081
349854
  if (!opts)
@@ -349089,9 +349862,9 @@ var require_opts_arg = __commonJS({
349089
349862
  else
349090
349863
  throw new TypeError("invalid options argument");
349091
349864
  opts.mkdir = opts.mkdir || opts.fs.mkdir || fs.mkdir;
349092
- opts.mkdirAsync = promisify4(opts.mkdir);
349865
+ opts.mkdirAsync = promisify5(opts.mkdir);
349093
349866
  opts.stat = opts.stat || opts.fs.stat || fs.stat;
349094
- opts.statAsync = promisify4(opts.stat);
349867
+ opts.statAsync = promisify5(opts.stat);
349095
349868
  opts.statSync = opts.statSync || opts.fs.statSync || fs.statSync;
349096
349869
  opts.mkdirSync = opts.mkdirSync || opts.fs.mkdirSync || fs.mkdirSync;
349097
349870
  return opts;
@@ -353940,7 +354713,7 @@ var require_process_inventory = __commonJS({
353940
354713
  var node_child_process_1 = require("child_process");
353941
354714
  var node_os_1 = __importDefault(require("os"));
353942
354715
  var node_util_1 = require("util");
353943
- var execFileAsync4 = (0, node_util_1.promisify)(node_child_process_1.execFile);
354716
+ var execFileAsync5 = (0, node_util_1.promisify)(node_child_process_1.execFile);
353944
354717
  var DEFAULT_LIMIT = 60;
353945
354718
  var DEFAULT_TIMEOUT_MS2 = 800;
353946
354719
  var MAX_COMMAND_LENGTH = 320;
@@ -354002,7 +354775,7 @@ var require_process_inventory = __commonJS({
354002
354775
  const timer = setTimeout(() => controller.abort(), timeoutMs);
354003
354776
  timer.unref?.();
354004
354777
  try {
354005
- const { stdout } = await execFileAsync4(command, args, {
354778
+ const { stdout } = await execFileAsync5(command, args, {
354006
354779
  maxBuffer: 1024 * 1024,
354007
354780
  signal: controller.signal
354008
354781
  });
@@ -355296,7 +356069,7 @@ var require_package4 = __commonJS({
355296
356069
  "package.json"(exports2, module2) {
355297
356070
  module2.exports = {
355298
356071
  name: "@validate.qa/runner",
355299
- version: "1.0.90",
356072
+ version: "1.0.92",
355300
356073
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
355301
356074
  bin: {
355302
356075
  "validate-runner": "dist/cli.js"
@@ -356161,11 +356934,7 @@ var SYSTEM_UI_DISMISS_LABELS = [
356161
356934
  "OK",
356162
356935
  "Got it",
356163
356936
  "GOT IT",
356164
- "Continue",
356165
- "Close",
356166
356937
  "Dismiss",
356167
- "I understand",
356168
- "Accept",
356169
356938
  // Android 15+ / 16 KB page-size compatibility dialog primary action.
356170
356939
  "OK, got it"
356171
356940
  ];
@@ -360092,7 +360861,11 @@ async function executeAssertVisible(sessionId, step, platform3, isCrossPlatform
360092
360861
  tolerateAmbiguity: true
360093
360862
  });
360094
360863
  const result2 = await wdRequest(`${sessionPath}/element/${elementId2}/displayed`);
360095
- if (!result2?.value) throw new Error(`WEBVIEW element found but not visible: css=${css}`);
360864
+ if (!result2?.value) {
360865
+ throw new Error(
360866
+ `Assertion failed: expected "${css}" to be visible, but the WEBVIEW element resolved and is not displayed`
360867
+ );
360868
+ }
360096
360869
  });
360097
360870
  return;
360098
360871
  }
@@ -360104,7 +360877,9 @@ async function executeAssertVisible(sessionId, step, platform3, isCrossPlatform
360104
360877
  });
360105
360878
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
360106
360879
  if (!result?.value) {
360107
- throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
360880
+ throw new Error(
360881
+ `Assertion failed: expected "${step.target?.primary ?? "element"}" to be visible, but it resolved and is not displayed`
360882
+ );
360108
360883
  }
360109
360884
  }
360110
360885
  async function executeAssertText(sessionId, step, platform3, isCrossPlatform = false) {
@@ -362631,8 +363406,28 @@ function getMirrorSessionId(deviceId) {
362631
363406
  }
362632
363407
 
362633
363408
  // src/services/mobile/mobile-bridge-driver.ts
363409
+ var import_node_child_process3 = require("child_process");
363410
+ var import_node_util3 = require("util");
362634
363411
  var import_runner_core9 = __toESM(require_dist2());
362635
363412
  init_dist();
363413
+ var execFileAsync4 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
363414
+ async function clearAndroidAppDataViaAdb(deviceId, appId) {
363415
+ const serial = deviceId?.trim();
363416
+ const pkg2 = appId?.trim();
363417
+ if (!serial || !pkg2 || !serial.startsWith("emulator-")) return false;
363418
+ try {
363419
+ const { stdout, stderr } = await execFileAsync4(
363420
+ "adb",
363421
+ ["-s", serial, "shell", "pm", "clear", pkg2],
363422
+ { timeout: 2e4, maxBuffer: 64 * 1024 }
363423
+ );
363424
+ const out = `${stdout ?? ""}
363425
+ ${stderr ?? ""}`.toLowerCase();
363426
+ return out.includes("success");
363427
+ } catch {
363428
+ return false;
363429
+ }
363430
+ }
362636
363431
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
362637
363432
  function isRecoverableAppiumSessionFailure(err) {
362638
363433
  if (isAppiumDriverDeadError(err)) return true;
@@ -363031,7 +363826,17 @@ var MobileBridgeDriver = class {
363031
363826
  throw new Error(`iOS simulator app-data reset failed: ${wiped.detail}`);
363032
363827
  }
363033
363828
  } else {
363034
- await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
363829
+ const adbFirst = await clearAndroidAppDataViaAdb(this.deviceId, this.appId);
363830
+ if (!adbFirst) {
363831
+ try {
363832
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
363833
+ } catch (clearErr) {
363834
+ const adbCleared = await clearAndroidAppDataViaAdb(this.deviceId, this.appId);
363835
+ if (!adbCleared) {
363836
+ throw clearErr;
363837
+ }
363838
+ }
363839
+ }
363035
363840
  }
363036
363841
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
363037
363842
  try {
@@ -364263,10 +365068,28 @@ function truncateAuditRequestBody(body) {
364263
365068
  function isTerminalAuditStatus(status) {
364264
365069
  return status === 400 || status === 404 || status === 409 || status === 413;
364265
365070
  }
365071
+ var AuditDrainError = class extends Error {
365072
+ constructor(message, dropped) {
365073
+ super(message);
365074
+ this.dropped = dropped;
365075
+ this.name = "AuditDrainError";
365076
+ }
365077
+ };
365078
+ function auditDroppedFromDrainFailure(reason) {
365079
+ if (reason instanceof AuditDrainError) return reason.dropped;
365080
+ if (reason && typeof reason === "object" && "dropped" in reason) {
365081
+ const dropped = reason.dropped;
365082
+ if (typeof dropped === "number" && Number.isFinite(dropped)) {
365083
+ return Math.max(0, Math.floor(dropped));
365084
+ }
365085
+ }
365086
+ return 0;
365087
+ }
364266
365088
  function createAuditFlusher(url, headers) {
364267
365089
  const entries = [];
364268
365090
  let byteEstimate = 0;
364269
365091
  let flushInFlight = null;
365092
+ let droppedTotal = 0;
364270
365093
  const doFlush = () => {
364271
365094
  if (entries.length === 0) return Promise.resolve();
364272
365095
  if (flushInFlight) return flushInFlight;
@@ -364289,9 +365112,16 @@ function createAuditFlusher(url, headers) {
364289
365112
  body: JSON.stringify({ entries: toSend.map((e) => e.entry) }),
364290
365113
  signal: AbortSignal.timeout(3e4)
364291
365114
  });
364292
- if (res.ok) continue;
365115
+ if (res.ok) {
365116
+ const ack = await res.json().catch(() => null);
365117
+ if (typeof ack?.dropped === "number" && Number.isFinite(ack.dropped)) {
365118
+ droppedTotal += Math.max(0, Math.floor(ack.dropped));
365119
+ }
365120
+ continue;
365121
+ }
364293
365122
  if (isTerminalAuditStatus(res.status)) {
364294
365123
  console.warn(`[audit-flush] HTTP ${res.status} (terminal) - dropping ${toSend.length} audit entries`);
365124
+ droppedTotal += toSend.length;
364295
365125
  continue;
364296
365126
  }
364297
365127
  entries.unshift(...toSend);
@@ -364331,6 +365161,7 @@ function createAuditFlusher(url, headers) {
364331
365161
  droppedCount++;
364332
365162
  }
364333
365163
  console.warn(`[audit-flush] queue over ${AUDIT_QUEUE_MAX_BYTES} bytes - dropped ${droppedCount} oldest audit entries`);
365164
+ droppedTotal += droppedCount;
364334
365165
  }
364335
365166
  if (entries.length >= AUDIT_FLUSH_COUNT || byteEstimate >= AUDIT_FLUSH_BYTES) {
364336
365167
  void doFlush();
@@ -364339,16 +365170,20 @@ function createAuditFlusher(url, headers) {
364339
365170
  const drain = async () => {
364340
365171
  for (let attempt = 1; attempt <= AUDIT_DRAIN_ATTEMPTS; attempt++) {
364341
365172
  if (flushInFlight) await flushInFlight;
364342
- if (entries.length === 0) return;
365173
+ if (entries.length === 0) return { dropped: droppedTotal };
364343
365174
  await doFlush();
364344
- if (entries.length === 0) return;
365175
+ if (entries.length === 0) return { dropped: droppedTotal };
364345
365176
  if (attempt < AUDIT_DRAIN_ATTEMPTS) {
364346
365177
  await new Promise((resolve2) => setTimeout(resolve2, AUDIT_DRAIN_DELAY_MS * attempt));
364347
365178
  }
364348
365179
  }
364349
365180
  if (entries.length > 0) {
364350
- throw new Error(`audit drain failed after ${AUDIT_DRAIN_ATTEMPTS} attempts - ${entries.length} entries could not be flushed to ${url}`);
365181
+ throw new AuditDrainError(
365182
+ `audit drain failed after ${AUDIT_DRAIN_ATTEMPTS} attempts - ${entries.length} entries could not be flushed to ${url}`,
365183
+ droppedTotal + entries.length
365184
+ );
364351
365185
  }
365186
+ return { dropped: droppedTotal };
364352
365187
  };
364353
365188
  return { push, drain };
364354
365189
  }
@@ -365597,7 +366432,7 @@ ${finalVerifyResult.logs ?? ""}`;
365597
366432
  log.warn("[stream] discovery-plan failed after 3 attempts - plan checkpoint skipped");
365598
366433
  };
365599
366434
  let discoveryResult;
365600
- const buildFinalBody = (r, overrideError, auditWarning) => {
366435
+ const buildFinalBody = (r, overrideError, auditWarning, auditDroppedCount = 0) => {
365601
366436
  const isNativeDiscovery = ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE";
365602
366437
  const { exploreFinalMessages: _exploreFinalMessages, ...rest } = r;
365603
366438
  void _exploreFinalMessages;
@@ -365610,6 +366445,7 @@ ${finalVerifyResult.logs ?? ""}`;
365610
366445
  summary: rest.summary,
365611
366446
  mode: rest.mode,
365612
366447
  error: overrideError ?? rest.error,
366448
+ auditDroppedCount,
365613
366449
  // Resumable-pause checkpoint: native deep generation tripped the AI-overload breaker.
365614
366450
  // Without forwarding these, the server's isDiscoveryPaused is false → the run finalizes
365615
366451
  // COMPLETED/FAILED instead of PAUSED → the Resume affordance never appears for
@@ -365995,12 +366831,13 @@ ${finalVerifyResult.logs ?? ""}`;
365995
366831
  discoveryAuditWarning = `[audit] ${drainErr instanceof Error ? drainErr.message : String(drainErr)}`;
365996
366832
  console.warn(`[runner] ${discoveryAuditWarning}`);
365997
366833
  }
366834
+ const auditDroppedCount = drainOutcome.status === "fulfilled" ? drainOutcome.value.dropped : auditDroppedFromDrainFailure(drainOutcome.reason);
365998
366835
  const harStatus = harOutcome.status === "fulfilled" ? harOutcome.value : null;
365999
366836
  harPosted = decideHarPosted(harStatus !== null ? { status: harStatus } : null);
366000
366837
  if (!harPosted) {
366001
366838
  log.warn("[runner] discovery-har POST failed - final /discovery-result will carry HAR fallback");
366002
366839
  }
366003
- const finalBody = buildFinalBody(discoveryResult, void 0, discoveryAuditWarning || void 0);
366840
+ const finalBody = buildFinalBody(discoveryResult, void 0, discoveryAuditWarning || void 0, auditDroppedCount);
366004
366841
  let postOk = false;
366005
366842
  for (let attempt = 0; attempt < 3; attempt++) {
366006
366843
  const timeoutMs = attempt === 0 ? 12e4 : 3e4;
@@ -366034,14 +366871,16 @@ ${finalVerifyResult.logs ?? ""}`;
366034
366871
  const msg = err instanceof Error ? err.message : String(err);
366035
366872
  log.fail(`Discovery FAILED: ${msg}`);
366036
366873
  let drainMsg = "";
366874
+ let auditDroppedCount = 0;
366037
366875
  try {
366038
- await drainAudit();
366876
+ auditDroppedCount = (await drainAudit()).dropped;
366039
366877
  } catch (drainErr) {
366878
+ auditDroppedCount = auditDroppedFromDrainFailure(drainErr);
366040
366879
  drainMsg = `
366041
366880
  [audit] ${drainErr instanceof Error ? drainErr.message : String(drainErr)}`;
366042
366881
  console.warn(`[runner]${drainMsg}`);
366043
366882
  }
366044
- const errorBody = discoveryResult ? buildFinalBody(discoveryResult, msg, drainMsg || void 0) : prepareDiscoveryResultForPost({
366883
+ const errorBody = discoveryResult ? buildFinalBody(discoveryResult, msg, drainMsg || void 0, auditDroppedCount) : prepareDiscoveryResultForPost({
366045
366884
  ...explorePosted ? {} : { collectedPages: [], collectedTransitions: [] },
366046
366885
  tests: [],
366047
366886
  logs: `Fatal error: ${msg}${drainMsg}`,
@@ -366049,6 +366888,7 @@ ${finalVerifyResult.logs ?? ""}`;
366049
366888
  summary: `Fatal error: ${msg}`,
366050
366889
  exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
366051
366890
  duration: (Date.now() - startTime) / 1e3,
366891
+ auditDroppedCount,
366052
366892
  mode: ctx.mode,
366053
366893
  error: msg,
366054
366894
  streamedTestCount: streamedTestKeys.size,
@@ -366414,6 +367254,7 @@ ${finalVerifyResult.logs ?? ""}`;
366414
367254
  status: typeof c.status === "number" ? c.status : void 0
366415
367255
  })) : void 0;
366416
367256
  const uiPathSucceeded = Array.isArray(reproduce.stepResults) && reproduce.stepResults.filter((s) => s.status === "passed").length >= Math.max(1, reproduce.stepResults.length - 1);
367257
+ const failedStepOrder = Array.isArray(reproduce.stepResults) ? reproduce.stepResults.find((s) => s.status === "failed")?.stepOrder : void 0;
366417
367258
  const heal = await (0, import_runner_core14.executeMobileTestWithHealing)(
366418
367259
  mobileSteps,
366419
367260
  healTarget,
@@ -366425,6 +367266,7 @@ ${finalVerifyResult.logs ?? ""}`;
366425
367266
  testName: run2.testName,
366426
367267
  tags: tagList,
366427
367268
  failureCount,
367269
+ failedStepOrder,
366428
367270
  apiCalls: reproduceApiCalls,
366429
367271
  uiPathSucceeded
366430
367272
  },