@validate.qa/runner 1.0.88 → 1.0.90

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 +1575 -34
  2. package/dist/cli.mjs +1575 -34
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -1798,7 +1798,11 @@ var init_constants = __esm({
1798
1798
  "home",
1799
1799
  "waitForElement",
1800
1800
  "assertVisible",
1801
- "assertText"
1801
+ "assertText",
1802
+ /** Capture list labels / badge counts into run-local state bag (Phase 1 oracles). */
1803
+ "captureState",
1804
+ /** Assert a prior captureState delta (removed id, count change, etc.). */
1805
+ "assertStateDelta"
1802
1806
  ];
1803
1807
  BLOCKED_ENV_VARS = /* @__PURE__ */ new Set([
1804
1808
  // Execution / code-injection vectors
@@ -2233,7 +2237,10 @@ function readMobileMetadata(value) {
2233
2237
  webCss: typeof value.webCss === "string" && value.webCss.trim() ? value.webCss.trim() : void 0,
2234
2238
  webviewContext: typeof value.webviewContext === "string" && /^WEBVIEW/i.test(value.webviewContext.trim()) ? value.webviewContext.trim() : void 0,
2235
2239
  matchIndex: hasValidMatchPair ? rawMatchIndex : void 0,
2236
- expectedMatchCount: hasValidMatchPair ? rawExpectedMatchCount : void 0
2240
+ expectedMatchCount: hasValidMatchPair ? rawExpectedMatchCount : void 0,
2241
+ stateKey: typeof value.stateKey === "string" && value.stateKey.trim() ? value.stateKey.trim() : void 0,
2242
+ stateKind: typeof value.stateKind === "string" && value.stateKind.trim() ? value.stateKind.trim() : void 0,
2243
+ expect: typeof value.expect === "string" && value.expect.trim() ? value.expect.trim() : void 0
2237
2244
  };
2238
2245
  }
2239
2246
  function nonEmpty(value) {
@@ -2316,6 +2323,10 @@ function descriptionForStep(action, stepAction, metadata) {
2316
2323
  return `Assert ${label} is visible`;
2317
2324
  case "assertText":
2318
2325
  return `Assert ${label} shows "${action.value ?? action.innerText ?? ""}"`;
2326
+ case "captureState":
2327
+ return `Capture UI state "${metadata.stateKey ?? label}" (${metadata.stateKind ?? "list_ids"})`;
2328
+ case "assertStateDelta":
2329
+ return `Assert state delta "${metadata.stateKey ?? label}" expect=${metadata.expect ?? action.value ?? "changed"}`;
2319
2330
  default:
2320
2331
  return `Execute ${stepAction}`;
2321
2332
  }
@@ -2361,6 +2372,8 @@ function buildMobileStep(action, order) {
2361
2372
  step.assertion = `${labelForAction(action, metadata)} should be visible`;
2362
2373
  } else if (stepAction === "assertText") {
2363
2374
  step.assertion = action.value ?? action.innerText ?? void 0;
2375
+ } else if (stepAction === "assertStateDelta") {
2376
+ step.assertion = metadata.expect ?? action.value ?? "changed";
2364
2377
  }
2365
2378
  if ((stepAction === "swipe" || stepAction === "scroll") && (metadata.direction || metadata.distance)) {
2366
2379
  const stepMetadata = ensureMetadata(step);
@@ -2369,6 +2382,24 @@ function buildMobileStep(action, order) {
2369
2382
  if (metadata.distance)
2370
2383
  stepMetadata.distance = metadata.distance;
2371
2384
  }
2385
+ if (stepAction === "captureState" || stepAction === "assertStateDelta") {
2386
+ const stepMetadata = ensureMetadata(step);
2387
+ const stateKey = nonEmpty(metadata.stateKey) ?? nonEmpty(action.selector) ?? nonEmpty(metadata.targetLabel) ?? `state-${order}`;
2388
+ stepMetadata.stateKey = stateKey;
2389
+ if (stepAction === "captureState") {
2390
+ stepMetadata.stateKind = nonEmpty(metadata.stateKind) ?? "list_ids";
2391
+ }
2392
+ if (stepAction === "assertStateDelta") {
2393
+ stepMetadata.expect = nonEmpty(metadata.expect) ?? nonEmpty(action.value) ?? "changed";
2394
+ }
2395
+ if (!step.target) {
2396
+ step.target = {
2397
+ primary: stateKey,
2398
+ fallbacks: [],
2399
+ screenName: metadata.screenName || "UnknownScreen"
2400
+ };
2401
+ }
2402
+ }
2372
2403
  return step;
2373
2404
  }
2374
2405
  function buildLaunchStep(target, medium, appResetOnLaunch = false) {
@@ -2580,6 +2611,9 @@ function buildAppiumCode(testName, target, steps) {
2580
2611
  used.find = true;
2581
2612
  }
2582
2613
  break;
2614
+ case "captureState":
2615
+ case "assertStateDelta":
2616
+ break;
2583
2617
  default:
2584
2618
  break;
2585
2619
  }
@@ -2698,6 +2732,8 @@ function buildAppiumCode(testName, target, steps) {
2698
2732
  const expectedLiteral = toJsStringLiteral(step.assertion ?? "");
2699
2733
  const replayExpected = step.assertion?.includes("{{") ? `resolveValue(${expectedLiteral})` : expectedLiteral;
2700
2734
  pushAction(`await expectText(driver, ${sel}, ${replayExpected});`);
2735
+ } else if (step.action === "captureState" || step.action === "assertStateDelta") {
2736
+ lines.push(` // ${step.action}: ${step.description.replace(/\n/g, " ").slice(0, 120)}`);
2701
2737
  } else if (step.action === "back") {
2702
2738
  lines.push(` await driver.back();`);
2703
2739
  } else if (step.action === "home") {
@@ -3040,7 +3076,9 @@ var init_mobile_test_generation = __esm({
3040
3076
  HOME: "home",
3041
3077
  WAIT_FOR_ELEMENT: "waitForElement",
3042
3078
  ASSERT_VISIBLE: "assertVisible",
3043
- ASSERT_TEXT: "assertText"
3079
+ ASSERT_TEXT: "assertText",
3080
+ CAPTURE_STATE: "captureState",
3081
+ ASSERT_STATE_DELTA: "assertStateDelta"
3044
3082
  };
3045
3083
  MOBILE_VALUE_PLACEHOLDER_RE = /\{\{([A-Za-z0-9_]+)\}\}/g;
3046
3084
  }
@@ -3271,7 +3309,9 @@ var init_mobile_step_healer = __esm({
3271
3309
  "home",
3272
3310
  "waitForElement",
3273
3311
  "assertVisible",
3274
- "assertText"
3312
+ "assertText",
3313
+ "captureState",
3314
+ "assertStateDelta"
3275
3315
  ]);
3276
3316
  FOREIGN_PACKAGE_RE = /\b[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*){2,}\b/gi;
3277
3317
  ANDROID_RESOURCE_OWNER_RE = /([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+):id\//gi;
@@ -80310,6 +80350,306 @@ ${JSON.stringify(gatePlan)}`;
80310
80350
  }
80311
80351
  });
80312
80352
 
80353
+ // ../runner-core/dist/services/mobile/scenario-taxonomy.js
80354
+ var require_scenario_taxonomy = __commonJS({
80355
+ "../runner-core/dist/services/mobile/scenario-taxonomy.js"(exports) {
80356
+ "use strict";
80357
+ Object.defineProperty(exports, "__esModule", { value: true });
80358
+ exports.ALL_SCENARIO_KINDS = void 0;
80359
+ exports.extractUiSignals = extractUiSignals;
80360
+ exports.requiredScenarioKinds = requiredScenarioKinds;
80361
+ exports.coverageGaps = coverageGaps;
80362
+ exports.inferScenarioKind = inferScenarioKind;
80363
+ exports.inferScenarioKinds = inferScenarioKinds;
80364
+ exports.isStrongOracleKind = isStrongOracleKind;
80365
+ exports.formatRequiredScenarioKindsPrompt = formatRequiredScenarioKindsPrompt;
80366
+ exports.formatCoverageGapLog = formatCoverageGapLog;
80367
+ exports.ALL_SCENARIO_KINDS = [
80368
+ "smoke_nav",
80369
+ "happy_submit",
80370
+ "negative_validation",
80371
+ "crud_identity",
80372
+ "count_delta",
80373
+ "persistence_roundtrip",
80374
+ "auth_session",
80375
+ "navigation_integrity",
80376
+ "permission_system",
80377
+ "double_submit"
80378
+ ];
80379
+ var DELETE_RE = /\b(delete|remove|archive|trash)\b/i;
80380
+ var EMAIL_RE = /\b(e-?mail|email address)\b/i;
80381
+ var PASSWORD_RE = /\b(password|passcode|pin)\b/i;
80382
+ var SIGN_OUT_RE = /\b(sign\s*out|log\s*out|logout)\b/i;
80383
+ var TOGGLE_RE = /\b(toggle|switch|enable|disable|on\/off)\b/i;
80384
+ var SUBMIT_RE = /\b(submit|save|send|create|confirm|apply|done|update|post)\b/i;
80385
+ var BADGE_OR_COUNT_RE = /\b(\d+)\s*(item|items|open|unread|invoice|message|result|pending|new)\b/i;
80386
+ var BADGE_LABEL_RE = /\b(badge|count|open invoices|unread|notifications?)\b/i;
80387
+ var PERMISSION_RE = /\b(camera|microphone|location|photos?|permission|allow access|not now)\b/i;
80388
+ var SETTINGS_RE = /\b(settings|preferences|profile|account)\b/i;
80389
+ var TAB_RE = /\b(tab|home|catalog|forms|edges|inbox|feed|dashboard)\b/i;
80390
+ function elementText(el) {
80391
+ return [
80392
+ el.label ?? "",
80393
+ el.text ?? "",
80394
+ el.accessibilityId ?? "",
80395
+ el.resourceId ?? "",
80396
+ el.role ?? ""
80397
+ ].join(" ");
80398
+ }
80399
+ function isEditable(el) {
80400
+ const role = el.role ?? "";
80401
+ return /EditText|TextField|SecureTextField|SearchField|AutoComplete|textbox|searchbox/i.test(role) || el.role === "textbox" || el.role === "searchbox";
80402
+ }
80403
+ function isToggle(el) {
80404
+ const role = el.role ?? "";
80405
+ return /Switch|CheckBox|Toggle|RadioButton/i.test(role) || TOGGLE_RE.test(elementText(el));
80406
+ }
80407
+ function isRowAction(el) {
80408
+ const t = elementText(el);
80409
+ return DELETE_RE.test(t) && el.actionable !== false;
80410
+ }
80411
+ function extractUiSignals(graph, appId) {
80412
+ const screens = [...graph.screens.values()].filter((s) => {
80413
+ if (!appId)
80414
+ return true;
80415
+ return s.screenId === appId || s.screenId.startsWith(`${appId}#`) || s.screenId.startsWith(`${appId}.`);
80416
+ });
80417
+ let hasListWithRowActions = false;
80418
+ let hasFormFields = false;
80419
+ let hasEmailField = false;
80420
+ let hasNumericBadge = false;
80421
+ let hasSettingsToggles = false;
80422
+ let hasSignOut = false;
80423
+ let hasDestructiveConfirm = false;
80424
+ let hasPrimarySubmit = false;
80425
+ let hasTabOrDrawerNav = false;
80426
+ let hasPermissionCopy = false;
80427
+ for (const state2 of screens) {
80428
+ const labels = state2.elements.map(elementText);
80429
+ const blob = [state2.screenName ?? "", state2.screenType ?? "", ...state2.observations ?? [], ...labels].join(" ");
80430
+ if (PASSWORD_RE.test(blob) || state2.elements.some(isEditable))
80431
+ hasFormFields = true;
80432
+ if (labels.some((t) => EMAIL_RE.test(t)) || state2.elements.some((el) => isEditable(el) && EMAIL_RE.test(elementText(el)))) {
80433
+ hasEmailField = true;
80434
+ hasFormFields = true;
80435
+ }
80436
+ if (state2.elements.some(isRowAction))
80437
+ hasListWithRowActions = true;
80438
+ if (!hasListWithRowActions && state2.elements.filter((el) => el.actionable).length >= 6 && DELETE_RE.test(blob)) {
80439
+ hasListWithRowActions = true;
80440
+ }
80441
+ if (BADGE_OR_COUNT_RE.test(blob) || BADGE_LABEL_RE.test(blob))
80442
+ hasNumericBadge = true;
80443
+ if (state2.elements.some(isToggle) || SETTINGS_RE.test(blob) && TOGGLE_RE.test(blob)) {
80444
+ hasSettingsToggles = true;
80445
+ }
80446
+ if (SIGN_OUT_RE.test(blob))
80447
+ hasSignOut = true;
80448
+ if (DELETE_RE.test(blob) && /\b(confirm|are you sure|permanently)\b/i.test(blob)) {
80449
+ hasDestructiveConfirm = true;
80450
+ }
80451
+ if (labels.some((t) => SUBMIT_RE.test(t)))
80452
+ hasPrimarySubmit = true;
80453
+ if (TAB_RE.test(blob) || state2.screenType === "TAB" || /\btab\b/i.test(state2.screenName ?? "")) {
80454
+ hasTabOrDrawerNav = true;
80455
+ }
80456
+ if (PERMISSION_RE.test(blob) || state2.screenType === "PERMISSION")
80457
+ hasPermissionCopy = true;
80458
+ }
80459
+ return {
80460
+ hasListWithRowActions,
80461
+ hasFormFields,
80462
+ hasEmailField,
80463
+ hasNumericBadge,
80464
+ hasSettingsToggles,
80465
+ hasSignOut,
80466
+ hasDestructiveConfirm,
80467
+ hasPrimarySubmit,
80468
+ hasTabOrDrawerNav,
80469
+ hasPermissionCopy
80470
+ };
80471
+ }
80472
+ function requiredScenarioKinds(signals) {
80473
+ const kinds = /* @__PURE__ */ new Set(["smoke_nav"]);
80474
+ if (signals.hasFormFields || signals.hasPrimarySubmit) {
80475
+ kinds.add("happy_submit");
80476
+ }
80477
+ if (signals.hasFormFields || signals.hasEmailField) {
80478
+ kinds.add("negative_validation");
80479
+ }
80480
+ if (signals.hasListWithRowActions) {
80481
+ kinds.add("crud_identity");
80482
+ }
80483
+ if (signals.hasNumericBadge) {
80484
+ kinds.add("count_delta");
80485
+ }
80486
+ if (signals.hasSettingsToggles) {
80487
+ kinds.add("persistence_roundtrip");
80488
+ }
80489
+ if (signals.hasSignOut) {
80490
+ kinds.add("auth_session");
80491
+ }
80492
+ if (signals.hasTabOrDrawerNav) {
80493
+ kinds.add("navigation_integrity");
80494
+ }
80495
+ if (signals.hasPermissionCopy) {
80496
+ kinds.add("permission_system");
80497
+ }
80498
+ if (signals.hasPrimarySubmit && signals.hasFormFields) {
80499
+ kinds.add("double_submit");
80500
+ }
80501
+ return exports.ALL_SCENARIO_KINDS.filter((k) => kinds.has(k));
80502
+ }
80503
+ function coverageGaps(signals, plannedKinds) {
80504
+ const planned = new Set(plannedKinds);
80505
+ return requiredScenarioKinds(signals).filter((k) => !planned.has(k));
80506
+ }
80507
+ function inferScenarioKind(scenario) {
80508
+ const text = `${scenario.name} ${scenario.goal} ${scenario.variant ?? ""} ${scenario.type}`.toLowerCase();
80509
+ if (/\b(invalid|empty|required|validation|too short|malformed|reject|error message)\b/.test(text) || scenario.variant === "validation" || scenario.type === "EDGE_CASE" && /\b(empty|invalid|required)\b/.test(text)) {
80510
+ return "negative_validation";
80511
+ }
80512
+ if (/\b(delete|remove|wrong row|entity|archive)\b/.test(text) && /\b(list|row|item|record)\b/.test(text)) {
80513
+ return "crud_identity";
80514
+ }
80515
+ if (/\b(badge|count|unread|open invoices|\+\d|increment|decrement)\b/.test(text)) {
80516
+ return "count_delta";
80517
+ }
80518
+ if (/\b(persist|revisit|return|leave|settings|toggle|favorite|remember)\b/.test(text)) {
80519
+ return "persistence_roundtrip";
80520
+ }
80521
+ 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)) {
80522
+ return "auth_session";
80523
+ }
80524
+ if (/\b(tab|navigate|navigation|back|deep link|empty.?state)\b/.test(text)) {
80525
+ return "navigation_integrity";
80526
+ }
80527
+ if (/\b(permission|camera|microphone|location|allow)\b/.test(text)) {
80528
+ return "permission_system";
80529
+ }
80530
+ if (/\b(double|twice|duplicate submit|double.?tap)\b/.test(text)) {
80531
+ return "double_submit";
80532
+ }
80533
+ if (scenario.type === "HAPPY_PATH" || scenario.variant === "happy") {
80534
+ return "happy_submit";
80535
+ }
80536
+ if (scenario.type === "USER_JOURNEY" || scenario.type === "REGRESSION") {
80537
+ return "navigation_integrity";
80538
+ }
80539
+ return "smoke_nav";
80540
+ }
80541
+ function inferScenarioKinds(scenarios) {
80542
+ return [...new Set(scenarios.map(inferScenarioKind))];
80543
+ }
80544
+ function isStrongOracleKind(kind) {
80545
+ return kind !== "smoke_nav" && kind !== "navigation_integrity";
80546
+ }
80547
+ function formatRequiredScenarioKindsPrompt(signals) {
80548
+ const required = requiredScenarioKinds(signals);
80549
+ if (required.length === 0)
80550
+ return "";
80551
+ const lines = required.map((kind) => {
80552
+ switch (kind) {
80553
+ case "smoke_nav":
80554
+ return '- smoke_nav: reach a primary destination and prove it with a durable product signal (not only "tab visible")';
80555
+ case "happy_submit":
80556
+ return "- happy_submit: complete a form/primary action and assert the durable result (new row, saved state, success that outlives a toast)";
80557
+ case "negative_validation":
80558
+ return "- negative_validation: submit empty/invalid input (e.g. empty required, malformed email-like field) and assert error OR blocked submit \u2014 never invent success copy";
80559
+ case "crud_identity":
80560
+ return '- crud_identity: create or note an entity id/label, perform delete/edit on THAT entity, assert identity set changed correctly (not "list still visible")';
80561
+ case "count_delta":
80562
+ return "- count_delta: capture a numeric badge/count, perform an action, assert the number changed as expected";
80563
+ case "persistence_roundtrip":
80564
+ return "- persistence_roundtrip: change a setting/toggle, leave the screen (or relaunch if policy allows), return, assert value persisted";
80565
+ case "auth_session":
80566
+ return "- auth_session: prove sign-in session (profile/sign-out) and/or sign-out leaves only logged-out chrome";
80567
+ case "navigation_integrity":
80568
+ return "- navigation_integrity: tab/drawer/back reaches the intended screen family";
80569
+ case "permission_system":
80570
+ return "- permission_system: permission prompt or settings path behaves honestly (allow/deny/settings)";
80571
+ case "double_submit":
80572
+ return "- double_submit: single user intent must not double-create (assert count +1 not +2, or single confirmation)";
80573
+ default:
80574
+ return `- ${kind}`;
80575
+ }
80576
+ });
80577
+ 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");
80578
+ }
80579
+ function formatCoverageGapLog(gaps) {
80580
+ if (gaps.length === 0)
80581
+ return "Scenario taxonomy: all required kinds covered by plan";
80582
+ return `Scenario taxonomy coverage gaps (required but not planned): ${gaps.join(", ")}`;
80583
+ }
80584
+ }
80585
+ });
80586
+
80587
+ // ../runner-core/dist/services/mobile/coverage-map.js
80588
+ var require_coverage_map = __commonJS({
80589
+ "../runner-core/dist/services/mobile/coverage-map.js"(exports) {
80590
+ "use strict";
80591
+ Object.defineProperty(exports, "__esModule", { value: true });
80592
+ exports.buildMobileCoverageMap = buildMobileCoverageMap;
80593
+ var scenario_taxonomy_js_1 = require_scenario_taxonomy();
80594
+ function strongLookup(name, map2) {
80595
+ if (!map2)
80596
+ return false;
80597
+ if (map2 instanceof Map)
80598
+ return map2.get(name) === true;
80599
+ return Object.prototype.hasOwnProperty.call(map2, name) && map2[name] === true;
80600
+ }
80601
+ function buildMobileCoverageMap(input) {
80602
+ const signals = (0, scenario_taxonomy_js_1.extractUiSignals)(input.graph, input.appId);
80603
+ const required = (0, scenario_taxonomy_js_1.requiredScenarioKinds)(signals);
80604
+ const planned = input.plannedScenarios ?? [];
80605
+ const plannedKinds = planned.map(scenario_taxonomy_js_1.inferScenarioKind);
80606
+ const plannedSet = new Set(plannedKinds);
80607
+ const gaps = (0, scenario_taxonomy_js_1.coverageGaps)(signals, plannedKinds);
80608
+ const kindsWithStrong = /* @__PURE__ */ new Set();
80609
+ for (const s of planned) {
80610
+ const kind = (0, scenario_taxonomy_js_1.inferScenarioKind)(s);
80611
+ const isStrong = (0, scenario_taxonomy_js_1.isStrongOracleKind)(kind) || strongLookup(s.name, input.strongByScenarioName);
80612
+ if (isStrong)
80613
+ kindsWithStrong.add(kind);
80614
+ }
80615
+ const warnings = [];
80616
+ const entries = required.map((kind) => {
80617
+ const isPlanned = plannedSet.has(kind);
80618
+ const strongPlanned = kindsWithStrong.has(kind);
80619
+ let warning;
80620
+ if (!isPlanned) {
80621
+ warning = `Required kind "${kind}" has no planned scenario`;
80622
+ warnings.push(warning);
80623
+ } else if (!strongPlanned && (0, scenario_taxonomy_js_1.isStrongOracleKind)(kind)) {
80624
+ warning = `Kind "${kind}" is planned but has no strong-oracle scenario (smoke-only coverage)`;
80625
+ warnings.push(warning);
80626
+ }
80627
+ return {
80628
+ kind,
80629
+ required: true,
80630
+ planned: isPlanned,
80631
+ strongPlanned,
80632
+ warning
80633
+ };
80634
+ });
80635
+ if (planned.length > 0 && plannedKinds.every((k) => k === "smoke_nav" || k === "navigation_integrity")) {
80636
+ const w = "All planned scenarios are smoke/nav \u2014 no strong product oracles";
80637
+ if (!warnings.includes(w))
80638
+ warnings.push(w);
80639
+ }
80640
+ const summary = warnings.length === 0 ? `Coverage map: ${required.length} required kinds, all covered with strong scenarios where needed` : `Coverage map: ${warnings.length} warning(s); ${(0, scenario_taxonomy_js_1.formatCoverageGapLog)(gaps)}`;
80641
+ return {
80642
+ signals,
80643
+ requiredKinds: required,
80644
+ gaps,
80645
+ entries,
80646
+ warnings,
80647
+ summary
80648
+ };
80649
+ }
80650
+ }
80651
+ });
80652
+
80313
80653
  // ../runner-core/dist/services/mobile/mobile-discovery-prompts.js
80314
80654
  var require_mobile_discovery_prompts = __commonJS({
80315
80655
  "../runner-core/dist/services/mobile/mobile-discovery-prompts.js"(exports) {
@@ -80327,6 +80667,8 @@ var require_mobile_discovery_prompts = __commonJS({
80327
80667
  var ai_provider_js_1 = require_ai_provider();
80328
80668
  var discover_ai_planner_js_1 = require_discover_ai_planner();
80329
80669
  var mobile_observation_js_1 = require_mobile_observation();
80670
+ var scenario_taxonomy_js_1 = require_scenario_taxonomy();
80671
+ var coverage_map_js_1 = require_coverage_map();
80330
80672
  function isTargetAppScreenId(screenId, appId) {
80331
80673
  if (!appId)
80332
80674
  return true;
@@ -80401,14 +80743,14 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
80401
80743
  2. **One purpose per scenario** - each scenario tests ONE thing (happy path, validation, journey, or bug)
80402
80744
  3. **Happy paths first** - then pair edge cases to the same form/flow
80403
80745
  4. **Multi-screen journeys** - only when screen transitions were actually observed. When an observed target element sits below the fold, include an explicit swipe step to scroll it into view before the tap (mobile elements off-screen are not hittable until revealed)
80404
- 5. **Bug reports** - for observed crashes, ANRs, or broken features
80746
+ 5. **Bug reports** - for observed *product* defects (wrong row deleted, validation bypassed, count wrong). Do NOT plan scenarios whose expected outcome is system ANR / "isn't responding" freezes \u2014 those are environment noise
80405
80747
  6. **Steps are human-readable** - "Type into the email field", not "driver.findElement(AppiumBy.accessibilityId('email')).sendKeys()"
80406
80748
  7. **Expected outcomes are plain-English success criteria** - describe the user-visible product result, not the controls touched along the way. Steps already cover buttons, inputs, tabs, sheets, and menus. Expected outcomes should say what the user can now do or rely on (record saved, message sent, search results filtered, validation explained, home screen reached). Name the target screen or exact copy only when it is the real product outcome; do NOT list "heading visible", "button visible", or similar element inventory unless that exact control is the behavior under test.
80407
80749
  8. **Auth-aware** - mark scenarios that need a signed-in session. Credentials are filled ON-DEVICE from the stored TEST_USER_EMAIL / TEST_USER_PASSWORD keys (key-based injection at run time) \u2014 say "type the test account email", never a literal value and never a code idiom like process.env
80408
80750
  9. **No duplicates** - check existing test names before adding
80409
80751
  10. **All tests start from the app launch entry** - every generated test starts by launching the target app. The runner clears app data when the device/reset policy safely supports it; otherwise it performs a terminate + activate relaunch, which can preserve app session data. Therefore scenarios MUST navigate through the app's own UI from whatever launch state is valid, and MUST NOT rely on mutable data from a prior scenario disappearing. Steps must reflect this: say "Launch the app and tap Sign In" not "deep-link to the login screen". Do NOT navigate by deep-link; the app's launch + tab/drawer navigation is the only entry path. \`targetPages\` lists the screen-ids involved in the flow.
80410
80752
  11. **targetPages must be copied CHARACTER-FOR-CHARACTER from the screen-ids in the evidence** (they look like \`com.example.app.MainActivity#ab12cd34\`). NEVER invent, prettify, or abbreviate them \u2014 no "Landing", "AUTH", "Home Screen", or any name that is not literally in the provided screens. A scenario whose targetPages contains even ONE id not present in the evidence is REJECTED WHOLESALE by the validator, so a single made-up route silently destroys the entire scenario. If you are unsure which screen-id a step touches, use the screen-id of the screen where the flow STARTS.
80411
- 12. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" / "Settings screen opened" is NOT a happy path - that is a UI interaction test. Form-mutation happy paths (change password, create/edit/save a record, send a message, checkout, etc.) MUST include typing into the relevant fields AND committing the form (submit/save/send or keyboard Enter/Done) \u2014 navigating to the form screen alone is insufficient. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
80753
+ 12. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" / "Settings screen opened" is NOT a happy path - that is a UI interaction test. When the explorer observed list rows with delete/remove, plan at least one scenario that captures entity identity before the action and asserts that identity is gone or changed afterward (not merely that the list screen is still visible). When numeric badges/counts were observed, plan a before/after count check. Form-mutation happy paths (change password, create/edit/save a record, send a message, checkout, etc.) MUST include typing into the relevant fields AND committing the form (submit/save/send or keyboard Enter/Done) \u2014 navigating to the form screen alone is insufficient. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
80412
80754
  - The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
80413
80755
  - A wait step for the operation to complete. Phrase it by SIGNAL, not wall-clock ("Wait for the result to appear - up to 60 seconds"). Prefer signals the app actually uses: spinner disappears, status badge flips to a success state, a new row appears, an item count increments, a success toast/snackbar shows, the screen transitions to a result screen.
80414
80756
  - A verification step on the DURABLE RESULT - the thing that outlives the transient UI: a new list row, a persisted record, a received message, a committed state change, visible output text. Do NOT assert on the spinner disappearing alone; assert on what replaced it.
@@ -80808,6 +81150,14 @@ SCOPE: this is a feature-focused deep run. Plan scenarios ONLY for the "${contex
80808
81150
  sections.push(`## Operator notes
80809
81151
  ${context.userNotes.trim()}`);
80810
81152
  }
81153
+ if (context.mode === "deep" || context.mode === "full") {
81154
+ const signals = (0, scenario_taxonomy_js_1.extractUiSignals)(graph, context.appId);
81155
+ const requiredBlock = (0, scenario_taxonomy_js_1.formatRequiredScenarioKindsPrompt)(signals);
81156
+ if (requiredBlock) {
81157
+ sections.push(requiredBlock);
81158
+ sections.push('## Scenario kind coverage rules\n1. Prefer scenarios whose expectedOutcomes prove durable product results (state deltas, validation errors, persistence), not only "control X is visible".\n2. For negative_validation: empty required field AND/OR malformed email-like input; expected outcome must be blocked submit or visible validation error \u2014 never success Home after invalid submit.\n3. For crud_identity: capture entity identity before delete/edit, then assert that identity is gone/changed (state delta) \u2014 never only that the list screen is still visible.\n4. For count_delta: capture before count, mutate, assert count changed as expected.\n5. For double_submit: assert count +1 not +2 (or single confirmation), not merely that a button remains visible.\n6. Never plan expectedOutcomes that are ANR / System UI freezes.\n7. If a required kind is impossible given evidence, say so in "reasoning" \u2014 do not invent unobserved screens.');
81159
+ }
81160
+ }
80811
81161
  const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration || a.screenId.localeCompare(b.screenId));
80812
81162
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
80813
81163
  const maxElementsPerScreen = context.mode === "survey" ? SURVEY_MAX_ELEMENTS_PER_SCREEN : MAX_ELEMENTS_PER_SCREEN;
@@ -81531,6 +81881,43 @@ ${constraints.join("\n")}`);
81531
81881
  mobileAdaptationLogs.push(`Dropped ${guestWallFilter.dropped} guest-wall scenario(s) \u2014 explore session was authenticated; re-exec would not see sign-in gates`);
81532
81882
  }
81533
81883
  const plannedScenarios = guestWallFilter.scenarios;
81884
+ let taxonomyGapLogs = [];
81885
+ let taxonomyCoverageGaps = result.coverageGaps ?? [];
81886
+ if (input.mode !== "survey" && plannedScenarios.length > 0) {
81887
+ const signals = (0, scenario_taxonomy_js_1.extractUiSignals)(input.graph, input.appId);
81888
+ const plannedKinds = (0, scenario_taxonomy_js_1.inferScenarioKinds)(plannedScenarios);
81889
+ const gaps = (0, scenario_taxonomy_js_1.coverageGaps)(signals, plannedKinds);
81890
+ const coverageMap = (0, coverage_map_js_1.buildMobileCoverageMap)({
81891
+ graph: input.graph,
81892
+ appId: input.appId,
81893
+ plannedScenarios
81894
+ });
81895
+ taxonomyGapLogs = [(0, scenario_taxonomy_js_1.formatCoverageGapLog)(gaps), coverageMap.summary];
81896
+ if (coverageMap.warnings.length > 0) {
81897
+ taxonomyGapLogs.push(...coverageMap.warnings.slice(0, 12));
81898
+ }
81899
+ if (gaps.length > 0) {
81900
+ 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}`);
81901
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
81902
+ taxonomyCoverageGaps = [
81903
+ ...taxonomyCoverageGaps,
81904
+ ...gaps.map((kind) => ({
81905
+ scenarioName: `[taxonomy] ${kind}`,
81906
+ reason: `Required scenario kind "${kind}" not represented in the planned set`,
81907
+ suggestion: `Add a ${kind} scenario backed by observed controls, or document impossibility`,
81908
+ unmetPrerequisites: [],
81909
+ detectedAt
81910
+ })),
81911
+ ...coverageMap.warnings.filter((w) => /smoke-only|no strong-oracle/i.test(w)).map((w) => ({
81912
+ scenarioName: "[coverage-map] strong-oracle gap",
81913
+ reason: w,
81914
+ suggestion: "Author durable assertStateDelta/assertText outcomes for this kind",
81915
+ unmetPrerequisites: [],
81916
+ detectedAt
81917
+ }))
81918
+ ];
81919
+ }
81920
+ }
81534
81921
  if (input.mode !== "survey" && plannedScenarios.length === 0 && input.graph.screens.size > 0) {
81535
81922
  const fallbackScenarios = buildMobileFallbackScenarios(input);
81536
81923
  if (fallbackScenarios.length > 0) {
@@ -81589,7 +81976,8 @@ ${constraints.join("\n")}`);
81589
81976
  ...result,
81590
81977
  scenarios: plannedScenarios,
81591
81978
  featureGroups: mobileFeatureGroups,
81592
- logs: [...result.logs, ...mobileAdaptationLogs]
81979
+ coverageGaps: taxonomyCoverageGaps.length > 0 ? taxonomyCoverageGaps : result.coverageGaps,
81980
+ logs: [...result.logs, ...mobileAdaptationLogs, ...taxonomyGapLogs]
81593
81981
  };
81594
81982
  }
81595
81983
  }
@@ -81643,6 +82031,123 @@ var require_discovery_lint_flags = __commonJS({
81643
82031
  }
81644
82032
  });
81645
82033
 
82034
+ // ../runner-core/dist/services/mobile/anr-env.js
82035
+ var require_anr_env = __commonJS({
82036
+ "../runner-core/dist/services/mobile/anr-env.js"(exports) {
82037
+ "use strict";
82038
+ Object.defineProperty(exports, "__esModule", { value: true });
82039
+ exports.textLooksLikeAnrOrSystemUi = textLooksLikeAnrOrSystemUi;
82040
+ exports.isAnrOrSystemUiEnvironmentFailure = isAnrOrSystemUiEnvironmentFailure;
82041
+ exports.stepLooksLikeAnrChromeAssert = stepLooksLikeAnrChromeAssert;
82042
+ 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;
82043
+ function textLooksLikeAnrOrSystemUi(text) {
82044
+ if (!text || typeof text !== "string")
82045
+ return false;
82046
+ return ANR_TEXT_RE.test(text);
82047
+ }
82048
+ function isAnrOrSystemUiEnvironmentFailure(error2, logs2) {
82049
+ const blob = `${error2 ?? ""}
82050
+ ${logs2 ?? ""}`;
82051
+ if (textLooksLikeAnrOrSystemUi(blob))
82052
+ return true;
82053
+ if (/\bcold-boot\b/i.test(blob) && /\b(froze|hang|anr|unresponsive)\b/i.test(blob))
82054
+ return true;
82055
+ if (/\bno actionable elements\b/i.test(blob) && /\b(system|anr|responding)\b/i.test(blob)) {
82056
+ return true;
82057
+ }
82058
+ return false;
82059
+ }
82060
+ function stepLooksLikeAnrChromeAssert(step) {
82061
+ if (step.action !== "assertVisible" && step.action !== "assertText")
82062
+ return false;
82063
+ return textLooksLikeAnrOrSystemUi([step.description, step.assertion, step.value].filter(Boolean).join(" "));
82064
+ }
82065
+ }
82066
+ });
82067
+
82068
+ // ../runner-core/dist/services/mobile/oracle-quality.js
82069
+ var require_oracle_quality = __commonJS({
82070
+ "../runner-core/dist/services/mobile/oracle-quality.js"(exports) {
82071
+ "use strict";
82072
+ Object.defineProperty(exports, "__esModule", { value: true });
82073
+ exports.assessStrongOracleQuality = assessStrongOracleQuality;
82074
+ var anr_env_js_1 = require_anr_env();
82075
+ 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;
82076
+ 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;
82077
+ function stepBlob(step) {
82078
+ return [step.description, step.assertion, step.value, step.target?.primary].filter(Boolean).join(" ");
82079
+ }
82080
+ function isChromeOnlyVisible(step) {
82081
+ if (step.action !== "assertVisible")
82082
+ return false;
82083
+ const blob = stepBlob(step);
82084
+ if (PRODUCT_OUTCOME_TEXT_RE.test(blob))
82085
+ return false;
82086
+ return CHROME_ONLY_RE.test(blob) || blob.length < 40;
82087
+ }
82088
+ function assessStrongOracleQuality(steps) {
82089
+ const productSteps = steps.filter((s) => s.action !== "launchApp");
82090
+ const anrAsserts = productSteps.filter(anr_env_js_1.stepLooksLikeAnrChromeAssert);
82091
+ const nonAnrAsserts = productSteps.filter((s) => (s.action === "assertVisible" || s.action === "assertText" || s.action === "assertStateDelta") && !(0, anr_env_js_1.stepLooksLikeAnrChromeAssert)(s));
82092
+ if (productSteps.length > 0 && anrAsserts.length > 0 && nonAnrAsserts.length === 0) {
82093
+ return {
82094
+ strong: false,
82095
+ unpairedCaptures: 0,
82096
+ hasStateDelta: false,
82097
+ hasStrongAssertText: false,
82098
+ hasStrongValidationVisible: false,
82099
+ anrOnly: true,
82100
+ reason: "only asserts system ANR / freeze chrome \u2014 not a product test"
82101
+ };
82102
+ }
82103
+ const captures = steps.filter((s) => s.action === "captureState").length;
82104
+ const deltas = steps.filter((s) => s.action === "assertStateDelta").length;
82105
+ const unpairedCaptures = Math.max(0, captures - deltas);
82106
+ const hasStateDelta = deltas > 0;
82107
+ const hasStrongAssertText = steps.some((s) => {
82108
+ if (s.action !== "assertText")
82109
+ return false;
82110
+ if ((0, anr_env_js_1.stepLooksLikeAnrChromeAssert)(s))
82111
+ return false;
82112
+ const blob = stepBlob(s);
82113
+ if (/^(email|password|username|name|query|search)$/i.test((s.assertion ?? "").trim())) {
82114
+ return false;
82115
+ }
82116
+ return blob.length >= 3;
82117
+ });
82118
+ const hasStrongValidationVisible = steps.some((s) => {
82119
+ if (s.action !== "assertVisible")
82120
+ return false;
82121
+ if ((0, anr_env_js_1.stepLooksLikeAnrChromeAssert)(s) || isChromeOnlyVisible(s))
82122
+ return false;
82123
+ return PRODUCT_OUTCOME_TEXT_RE.test(stepBlob(s));
82124
+ });
82125
+ const strong = hasStateDelta || hasStrongAssertText || hasStrongValidationVisible;
82126
+ let reason;
82127
+ if (strong && hasStateDelta) {
82128
+ reason = unpairedCaptures > 0 ? `has assertStateDelta but ${unpairedCaptures} unpaired captureState (prefer 1:1 pairing)` : "has assertStateDelta durable state proof";
82129
+ } else if (strong && hasStrongAssertText) {
82130
+ reason = "has product assertText outcome";
82131
+ } else if (strong) {
82132
+ reason = "has validation/error assertVisible outcome";
82133
+ } else if (unpairedCaptures > 0) {
82134
+ reason = `captureState without assertStateDelta (${unpairedCaptures}) \u2014 not strong`;
82135
+ } else {
82136
+ reason = "no durable state/text/validation oracle \u2014 smoke/nav only";
82137
+ }
82138
+ return {
82139
+ strong,
82140
+ unpairedCaptures,
82141
+ hasStateDelta,
82142
+ hasStrongAssertText,
82143
+ hasStrongValidationVisible,
82144
+ anrOnly: false,
82145
+ reason
82146
+ };
82147
+ }
82148
+ }
82149
+ });
82150
+
81646
82151
  // ../runner-core/dist/services/mobile/mobile-generator.js
81647
82152
  var require_mobile_generator = __commonJS({
81648
82153
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
@@ -81668,6 +82173,9 @@ var require_mobile_generator = __commonJS({
81668
82173
  var ai_retry_js_1 = require_ai_retry();
81669
82174
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81670
82175
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
82176
+ var scenario_taxonomy_js_1 = require_scenario_taxonomy();
82177
+ var anr_env_js_1 = require_anr_env();
82178
+ var oracle_quality_js_1 = require_oracle_quality();
81671
82179
  var mobile_source_parser_js_1 = require_mobile_source_parser();
81672
82180
  var mobile_source_parser_js_2 = require_mobile_source_parser();
81673
82181
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81809,6 +82317,50 @@ var require_mobile_generator = __commonJS({
81809
82317
  }
81810
82318
  }
81811
82319
  };
82320
+ var MOBILE_CAPTURE_STATE_TOOL = {
82321
+ type: "function",
82322
+ function: {
82323
+ name: "mobile_capture_state",
82324
+ description: "Capture list labels or numeric badge counts into a named state key BEFORE a mutating action (delete/create/toggle). MUST pair with mobile_assert_state_delta afterward \u2014 capture alone is not a strong oracle and will not tag the test @strong-oracle.",
82325
+ parameters: {
82326
+ type: "object",
82327
+ properties: {
82328
+ stateKey: {
82329
+ type: "string",
82330
+ description: 'Short key for this bag entry (e.g. "invoice_list", "open_count"). Reuse the same key in mobile_assert_state_delta.'
82331
+ },
82332
+ kind: {
82333
+ type: "string",
82334
+ enum: ["list_ids", "badge_count"],
82335
+ description: "list_ids = unique visible labels; badge_count = numeric count tokens. Default list_ids."
82336
+ }
82337
+ },
82338
+ required: ["stateKey"]
82339
+ }
82340
+ }
82341
+ };
82342
+ var MOBILE_ASSERT_STATE_DELTA_TOOL = {
82343
+ type: "function",
82344
+ function: {
82345
+ name: "mobile_assert_state_delta",
82346
+ description: "Compare the current screen to a prior mobile_capture_state. Verifies LIVE then records assertStateDelta. Use after delete/create/count-changing actions. expect: changed | entity_removed | count_decreased | count_increased | unchanged | persisted.",
82347
+ parameters: {
82348
+ type: "object",
82349
+ properties: {
82350
+ stateKey: {
82351
+ type: "string",
82352
+ description: "Must match a prior mobile_capture_state stateKey in this scenario."
82353
+ },
82354
+ expect: {
82355
+ type: "string",
82356
+ enum: ["changed", "entity_removed", "count_decreased", "count_increased", "unchanged", "persisted"],
82357
+ description: "What must differ (or persist) vs the prior capture. Default: changed."
82358
+ }
82359
+ },
82360
+ required: ["stateKey"]
82361
+ }
82362
+ }
82363
+ };
81812
82364
  var MOBILE_FILL_CREDENTIAL_TOOL = {
81813
82365
  type: "function",
81814
82366
  function: {
@@ -81831,7 +82383,7 @@ var require_mobile_generator = __commonJS({
81831
82383
  type: "function",
81832
82384
  function: {
81833
82385
  name: "finish_generate",
81834
- description: "Call when the scenario is fully exercised and its outcome is asserted. verified:true requires you to have called at least one assert tool (mobile_assert_visible / mobile_assert_text) \u2014 claiming verified without any assert is auto-downgraded.",
82386
+ description: "Call when the scenario is fully exercised and its outcome is asserted. verified:true requires you to have called at least one assert tool (mobile_assert_visible / mobile_assert_text / mobile_assert_state_delta) \u2014 claiming verified without any assert is auto-downgraded.",
81835
82387
  parameters: {
81836
82388
  type: "object",
81837
82389
  properties: {
@@ -81863,6 +82415,8 @@ var require_mobile_generator = __commonJS({
81863
82415
  MOBILE_RELAUNCH_TOOL,
81864
82416
  MOBILE_ASSERT_VISIBLE_TOOL,
81865
82417
  MOBILE_ASSERT_TEXT_TOOL,
82418
+ MOBILE_CAPTURE_STATE_TOOL,
82419
+ MOBILE_ASSERT_STATE_DELTA_TOOL,
81866
82420
  FINISH_GENERATE_TOOL
81867
82421
  ];
81868
82422
  var STRUCTURAL_KEY_MAX_ELEMENTS = 16;
@@ -82129,8 +82683,11 @@ The scenario name + goal + steps describe ONE product behavior from survey/disco
82129
82683
  - Navigation scenarios: you MUST land on the destination and assert something unique to it.
82130
82684
  4. PROVE the outcome with an assert tool that matches the scenario \u2014 not ambient chrome:
82131
82685
  - mobile_assert_visible({ref}) / mobile_assert_text({ref, expected})
82686
+ - 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.
82687
+ - 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".
82132
82688
  - After sign-in \u2192 signed-in product signal; after like \u2192 liked/unlike control or durable like state; after open Settings \u2192 a settings-specific control.
82133
82689
  - Do NOT assert bottom-nav tab labels alone, notification badge counts ("N unread"), or one-time empty-state CTAs as the scenario outcome.
82690
+ - 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.
82134
82691
  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.
82135
82692
 
82136
82693
  ## Rules
@@ -82139,7 +82696,7 @@ The scenario name + goal + steps describe ONE product behavior from survey/disco
82139
82696
  - 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.
82140
82697
  - 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.
82141
82698
  - 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.
82142
- - If the APP itself misbehaves (crash, error screen, a flow that cannot proceed as specified), do not force a green path: assert the failure you observed (e.g. the error text), then call finish_generate with suspectedBug:true and a concrete bugDescription of what is broken in the app.
82699
+ - 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.
82143
82700
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
82144
82701
  - NEVER tap the phone's home screen / launcher (app icons, "Predicted app" chips, search bars, widgets). Those are NOT part of the app under test \u2014 tapping them is junk that poisons the test. If the app ever backgrounds or you find yourself on the launcher, use mobile_relaunch to cold-start the app; do NOT tap a launcher icon or app-prediction to get back in.
82145
82702
  - Do NOT re-do a sequence you already completed. If you filled a form then navigated away, do NOT come back and fill it again \u2014 the scenario needs ONE clean pass. Filling the login form, going back, and re-filling it is exactly the kind of wandering that produces a bloated, broken test.
@@ -82242,6 +82799,7 @@ Start by calling mobile_snapshot, then execute ONLY the steps needed for this go
82242
82799
  const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch } = args;
82243
82800
  const log2 = (line) => emitLog(redactCredentialValues(line, redactionCredentials));
82244
82801
  const actions = [];
82802
+ const stateBag = /* @__PURE__ */ new Map();
82245
82803
  let assertCount = 0;
82246
82804
  let finish = null;
82247
82805
  let finishIntentRejections = 0;
@@ -82711,7 +83269,9 @@ ${statePacket}`, redactionCredentials) },
82711
83269
  const screenDependentTools = /* @__PURE__ */ new Set([
82712
83270
  ...screenMutatingTools,
82713
83271
  "mobile_assert_visible",
82714
- "mobile_assert_text"
83272
+ "mobile_assert_text",
83273
+ "mobile_capture_state",
83274
+ "mobile_assert_state_delta"
82715
83275
  ]);
82716
83276
  for (const toolCall of assistantMessage.tool_calls) {
82717
83277
  const toolName = toolCall.function.name;
@@ -82755,7 +83315,7 @@ ${statePacket}`, redactionCredentials) },
82755
83315
  if (intentReason)
82756
83316
  reasons.push(intentReason);
82757
83317
  if (verifiedWithoutAssert) {
82758
- reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
83318
+ reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text / mobile_assert_state_delta");
82759
83319
  }
82760
83320
  if (strengthRejectReason !== null) {
82761
83321
  reasons.push(`assertion strength: ${strengthRejectReason} \u2014 your only live proof re-checks a control you already used. Assert the scenario OUTCOME instead: an element on the resulting screen, the created/updated row, or the success/error message.`);
@@ -82826,10 +83386,12 @@ ${statePacket}`, redactionCredentials) },
82826
83386
  }
82827
83387
  } : action;
82828
83388
  actions.push((0, shared_1.placeholderizeRecordedMobileAction)(finalizeAction(stamped), credentials));
82829
- if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
83389
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "ASSERT_STATE_DELTA") {
82830
83390
  assertCount++;
83391
+ }
82831
83392
  },
82832
83393
  recordedActions: actions,
83394
+ stateBag,
82833
83395
  log: log2
82834
83396
  });
82835
83397
  if (screenMutatingTools.has(toolName))
@@ -82848,7 +83410,7 @@ ${statePacket}`, redactionCredentials) },
82848
83410
  return { actions, assertCount, finish, lastSnapshot: lastTargetSnapshot, lastScreenName: lastTargetScreenName };
82849
83411
  }
82850
83412
  async function dispatchGenerateTool(args) {
82851
- const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, allowRelaunch, recordAction, recordedActions, log: log2 } = args;
83413
+ const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, allowRelaunch, recordAction, recordedActions, stateBag, log: log2 } = args;
82852
83414
  const unknownRef = (ref) => ({
82853
83415
  ok: false,
82854
83416
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -83393,10 +83955,170 @@ ${statePacket}`, redactionCredentials) },
83393
83955
  target: metadata.targetLabel
83394
83956
  };
83395
83957
  }
83958
+ case "mobile_capture_state": {
83959
+ const stateKey = nonEmpty3(typeof toolArgs.stateKey === "string" ? toolArgs.stateKey : "");
83960
+ if (!stateKey) {
83961
+ return { ok: false, error: "mobile_capture_state requires a non-empty stateKey." };
83962
+ }
83963
+ const kindRaw = typeof toolArgs.kind === "string" ? toolArgs.kind.trim() : "list_ids";
83964
+ const kind = kindRaw === "badge_count" ? "badge_count" : "list_ids";
83965
+ const snap = latest();
83966
+ if (!snap) {
83967
+ return { ok: false, error: "No snapshot yet \u2014 call mobile_snapshot before mobile_capture_state." };
83968
+ }
83969
+ const values = captureValuesFromElements(snap.screen.elements, kind);
83970
+ stateBag.set(stateKey, { kind, values });
83971
+ recordAction({
83972
+ type: "CAPTURE_STATE",
83973
+ selector: stateKey,
83974
+ metadata: {
83975
+ screenName: screenName(),
83976
+ targetLabel: stateKey,
83977
+ stateKey,
83978
+ stateKind: kind
83979
+ }
83980
+ });
83981
+ log2(` capture_state: key=${stateKey} kind=${kind} n=${values.length}`);
83982
+ return {
83983
+ ok: true,
83984
+ captured: true,
83985
+ stateKey,
83986
+ kind,
83987
+ sample: values.slice(0, 8),
83988
+ count: values.length
83989
+ };
83990
+ }
83991
+ case "mobile_assert_state_delta": {
83992
+ const stateKey = nonEmpty3(typeof toolArgs.stateKey === "string" ? toolArgs.stateKey : "");
83993
+ if (!stateKey) {
83994
+ return { ok: false, error: "mobile_assert_state_delta requires a non-empty stateKey." };
83995
+ }
83996
+ const prior = stateBag.get(stateKey);
83997
+ if (!prior) {
83998
+ return {
83999
+ ok: false,
84000
+ error: `No prior mobile_capture_state for key "${stateKey}". Capture before the mutating action, then assert.`
84001
+ };
84002
+ }
84003
+ const expectRaw = typeof toolArgs.expect === "string" ? toolArgs.expect.trim() : "changed";
84004
+ const expect = expectRaw || "changed";
84005
+ const snap = latest();
84006
+ if (!snap) {
84007
+ return { ok: false, error: "No snapshot yet \u2014 call mobile_snapshot after the action, then assert_state_delta." };
84008
+ }
84009
+ const current = captureValuesFromElements(snap.screen.elements, prior.kind);
84010
+ const liveError = liveAssertStateDelta(prior.values, current, expect, stateKey);
84011
+ if (liveError) {
84012
+ return {
84013
+ ok: false,
84014
+ error: liveError,
84015
+ beforeSample: prior.values.slice(0, 8),
84016
+ afterSample: current.slice(0, 8)
84017
+ };
84018
+ }
84019
+ recordAction({
84020
+ type: "ASSERT_STATE_DELTA",
84021
+ value: expect,
84022
+ selector: stateKey,
84023
+ metadata: {
84024
+ screenName: screenName(),
84025
+ targetLabel: stateKey,
84026
+ stateKey,
84027
+ stateKind: prior.kind,
84028
+ expect
84029
+ }
84030
+ });
84031
+ log2(` assert_state_delta: key=${stateKey} expect=${expect}`);
84032
+ return {
84033
+ ok: true,
84034
+ asserted: "state_delta",
84035
+ stateKey,
84036
+ expect,
84037
+ beforeCount: prior.values.length,
84038
+ afterCount: current.length
84039
+ };
84040
+ }
83396
84041
  default:
83397
84042
  return { ok: false, error: `Unknown tool: ${toolName}` };
83398
84043
  }
83399
84044
  }
84045
+ function captureValuesFromElements(elements, kind) {
84046
+ if (kind === "badge_count") {
84047
+ const nums = /* @__PURE__ */ new Set();
84048
+ const preferred = /* @__PURE__ */ new Set();
84049
+ for (const el of elements) {
84050
+ const t = [el.label, el.text, el.accessibilityId].filter(Boolean).join(" ");
84051
+ for (const m of t.matchAll(/\b(\d{1,6})\b/g))
84052
+ nums.add(m[1]);
84053
+ if (/(?:count|badge|open|unread|item|invoice|result|pending)/i.test(t)) {
84054
+ for (const m of t.matchAll(/\b(\d{1,6})\b/g))
84055
+ preferred.add(m[1]);
84056
+ }
84057
+ }
84058
+ return preferred.size > 0 ? [...preferred] : [...nums].slice(0, 20);
84059
+ }
84060
+ const labels = /* @__PURE__ */ new Set();
84061
+ for (const el of elements) {
84062
+ for (const raw of [el.label, el.text, el.accessibilityId]) {
84063
+ const t = raw?.trim() ?? "";
84064
+ if (t.length < 2 || t.length > 80)
84065
+ continue;
84066
+ if (/^(true|false|null|OK|Cancel)$/i.test(t))
84067
+ continue;
84068
+ labels.add(t);
84069
+ }
84070
+ }
84071
+ return [...labels].slice(0, 80);
84072
+ }
84073
+ function liveAssertStateDelta(before, after, expect, key) {
84074
+ const beforeSet = new Set(before);
84075
+ const afterSet = new Set(after);
84076
+ const removed = before.filter((v) => !afterSet.has(v));
84077
+ const added = after.filter((v) => !beforeSet.has(v));
84078
+ const exp = expect.toLowerCase().trim();
84079
+ if (exp === "changed" || exp === "any_change") {
84080
+ if (removed.length === 0 && added.length === 0) {
84081
+ return `assertStateDelta(${key}): expected change, captures identical (${before.length} values)`;
84082
+ }
84083
+ return null;
84084
+ }
84085
+ if (exp === "removed" || exp === "entity_removed" || exp.startsWith("removed:")) {
84086
+ if (removed.length === 0) {
84087
+ return `assertStateDelta(${key}): expected at least one entity removed`;
84088
+ }
84089
+ return null;
84090
+ }
84091
+ if (exp === "count_decreased" || exp === "count-1") {
84092
+ const b = before.map(Number).filter((n) => Number.isFinite(n));
84093
+ const a = after.map(Number).filter((n) => Number.isFinite(n));
84094
+ const bMax = b.length ? Math.max(...b) : NaN;
84095
+ const aMax = a.length ? Math.max(...a) : NaN;
84096
+ if (!Number.isFinite(bMax) || !Number.isFinite(aMax) || aMax >= bMax) {
84097
+ return `assertStateDelta(${key}): expected count decrease (before max=${bMax}, after max=${aMax})`;
84098
+ }
84099
+ return null;
84100
+ }
84101
+ if (exp === "count_increased" || exp === "count+1") {
84102
+ const b = before.map(Number).filter((n) => Number.isFinite(n));
84103
+ const a = after.map(Number).filter((n) => Number.isFinite(n));
84104
+ const bMax = b.length ? Math.max(...b) : NaN;
84105
+ const aMax = a.length ? Math.max(...a) : NaN;
84106
+ if (!Number.isFinite(bMax) || !Number.isFinite(aMax) || aMax <= bMax) {
84107
+ return `assertStateDelta(${key}): expected count increase (before max=${bMax}, after max=${aMax})`;
84108
+ }
84109
+ return null;
84110
+ }
84111
+ if (exp === "unchanged" || exp === "persisted") {
84112
+ if (removed.length > 0 || added.length > 0) {
84113
+ return `assertStateDelta(${key}): expected persistence/unchanged but set changed`;
84114
+ }
84115
+ return null;
84116
+ }
84117
+ if (removed.length === 0 && added.length === 0) {
84118
+ return `assertStateDelta(${key}): expected change (expect=${expect}), state identical`;
84119
+ }
84120
+ return null;
84121
+ }
83400
84122
  function recordAssertVisible(toolArgs, ctx) {
83401
84123
  const { resolveRef, latest, screenName, platform: platform3, recordAction } = ctx;
83402
84124
  const element = resolveRef(toolArgs.ref);
@@ -83566,6 +84288,9 @@ ${statePacket}`, redactionCredentials) },
83566
84288
  function weakAssertionTargetReason(element) {
83567
84289
  if (isAlwaysPresentContainer(element))
83568
84290
  return "always-present app/system container";
84291
+ if ((0, anr_env_js_1.textLooksLikeAnrOrSystemUi)([element.text, element.label, element.accessibilityId, element.resourceId].filter(Boolean).join(" "))) {
84292
+ return "system ANR / freeze dialog chrome (environment, not product)";
84293
+ }
83569
84294
  const hasIdentitySignal = Boolean(nonEmpty3(element.accessibilityId) || nonEmpty3(element.resourceId) || nonEmpty3(element.text) || nonEmpty3(element.label));
83570
84295
  if (!hasIdentitySignal)
83571
84296
  return "no identity locator (accessibility id / resource id / text) for the assert gate";
@@ -84376,6 +85101,14 @@ ${statePacket}`, redactionCredentials) },
84376
85101
  lines.push(` await expect(page.getByText('${expected}')).toBeVisible();`);
84377
85102
  break;
84378
85103
  }
85104
+ case "CAPTURE_STATE":
85105
+ lines.push(` // captureState ${escape((action.metadata.stateKey ?? label).slice(0, 40))}`);
85106
+ break;
85107
+ case "ASSERT_STATE_DELTA": {
85108
+ const expect = escape((action.metadata.expect ?? action.value ?? "changed").slice(0, 40));
85109
+ lines.push(` await expect(page.getByText('state:${expect}')).toBeVisible();`);
85110
+ break;
85111
+ }
84379
85112
  case "CLEAR":
84380
85113
  lines.push(` await page.getByLabel('${label}').fill('');`);
84381
85114
  break;
@@ -84419,6 +85152,46 @@ ${statePacket}`, redactionCredentials) },
84419
85152
  return [action.selector, action.value, action.innerText, action.metadata?.targetLabel].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
84420
85153
  }
84421
85154
  function evaluateMobileAssertionStrength(actions) {
85155
+ const anrAsserts = actions.filter((a) => {
85156
+ if (a.type !== "ASSERT_VISIBLE" && a.type !== "ASSERT_TEXT")
85157
+ return false;
85158
+ return (0, anr_env_js_1.textLooksLikeAnrOrSystemUi)([a.value, a.innerText, a.metadata?.targetLabel, a.selector].filter(Boolean).join(" "));
85159
+ });
85160
+ if (anrAsserts.length > 0) {
85161
+ const otherAsserts = actions.filter((a) => (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT" || a.type === "ASSERT_STATE_DELTA") && !anrAsserts.includes(a));
85162
+ if (otherAsserts.length === 0) {
85163
+ return {
85164
+ ok: false,
85165
+ severity: "block",
85166
+ reason: `only asserts system ANR / "isn't responding" chrome \u2014 not a product test (discard or re-record on a healthy session)`,
85167
+ signals: {
85168
+ apiTest: false,
85169
+ interacted: actions.some((a) => a.type === "TAP" || a.type === "TYPE"),
85170
+ mutated: false,
85171
+ navOnly: false,
85172
+ hasOutcomeProof: false,
85173
+ errorBanner: false,
85174
+ terminal: null
85175
+ }
85176
+ };
85177
+ }
85178
+ }
85179
+ if (actions.some((a) => a.type === "ASSERT_STATE_DELTA")) {
85180
+ return {
85181
+ ok: true,
85182
+ severity: "none",
85183
+ reason: "state-delta oracle proves a durable product change (identity/count/persistence)",
85184
+ signals: {
85185
+ apiTest: false,
85186
+ interacted: actions.some((a) => a.type === "TAP" || a.type === "TYPE" || a.type === "CLEAR"),
85187
+ mutated: actions.some((a) => a.type === "TYPE" || a.type === "CLEAR" || a.type === "TAP"),
85188
+ navOnly: false,
85189
+ hasOutcomeProof: true,
85190
+ errorBanner: false,
85191
+ terminal: null
85192
+ }
85193
+ };
85194
+ }
84422
85195
  const modelAsserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84423
85196
  if (modelAsserts.length === 0) {
84424
85197
  return {
@@ -84549,9 +85322,10 @@ ${statePacket}`, redactionCredentials) },
84549
85322
  }
84550
85323
  const baseStrengthVerdict = evaluateMobileAssertionStrength(actions);
84551
85324
  const bugDescription = recording.finish?.bugDescription?.trim() ?? "";
84552
- const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0;
85325
+ const bugLooksLikeAnr = (0, anr_env_js_1.textLooksLikeAnrOrSystemUi)(bugDescription);
85326
+ const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0 && !bugLooksLikeAnr;
84553
85327
  if (recording.finish?.suspectedBug === true && !bugWitnessHonored) {
84554
- log2(" suspectedBug declared but NOT honored: " + (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."));
85328
+ 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."));
84555
85329
  }
84556
85330
  const strengthVerdict = bugWitnessHonored ? {
84557
85331
  ...baseStrengthVerdict,
@@ -84588,6 +85362,26 @@ ${statePacket}`, redactionCredentials) },
84588
85362
  if (coldResetUnavailable)
84589
85363
  tags.push("@warm-state");
84590
85364
  const strengthBlocked = !strengthVerdict.ok || strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug" || !verified || coldResetUnavailable;
85365
+ {
85366
+ const kind = (0, scenario_taxonomy_js_1.inferScenarioKind)(scenario);
85367
+ const oracleQuality = (0, oracle_quality_js_1.assessStrongOracleQuality)(compiled.steps);
85368
+ if (oracleQuality.anrOnly) {
85369
+ log2(` discarding ANR-chrome-only recording: ${oracleQuality.reason}`);
85370
+ return null;
85371
+ }
85372
+ if (oracleQuality.unpairedCaptures > 0) {
85373
+ log2(` oracle quality: ${oracleQuality.reason}`);
85374
+ }
85375
+ const strong = verified && !strengthBlocked && strengthVerdict.severity === "none" && oracleQuality.strong && ((0, scenario_taxonomy_js_1.isStrongOracleKind)(kind) || oracleQuality.hasStateDelta || oracleQuality.hasStrongAssertText);
85376
+ if (strong)
85377
+ tags.push("@strong-oracle");
85378
+ else if (!tags.includes("@smoke")) {
85379
+ tags.push("@smoke");
85380
+ if (!oracleQuality.strong) {
85381
+ log2(` tagged @smoke (not strong-oracle): ${oracleQuality.reason}`);
85382
+ }
85383
+ }
85384
+ }
84591
85385
  const strengthReason = coldResetUnavailable ? `Cold app-data reset unavailable${coldResetReason ? ` (${coldResetReason})` : ""} \u2014 generation may not match cold-boot replay. ${strengthVerdict.reason}` : !verified && recording.assertCount === 0 ? "Mobile scenario finished unverified \u2014 the agent never proved the scenario outcome live, so the recording may not demonstrate the goal (e.g. a happy path that never completed). Review before promotion." : strengthVerdict.reason;
84592
85386
  const test = {
84593
85387
  name: compiled.name,
@@ -84835,6 +85629,354 @@ Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) gene
84835
85629
  }
84836
85630
  });
84837
85631
 
85632
+ // ../runner-core/dist/services/mobile/failure-triage.js
85633
+ var require_failure_triage = __commonJS({
85634
+ "../runner-core/dist/services/mobile/failure-triage.js"(exports) {
85635
+ "use strict";
85636
+ Object.defineProperty(exports, "__esModule", { value: true });
85637
+ exports.hasStrongMobileOracle = hasStrongMobileOracle;
85638
+ exports.isStrongOracleStep = isStrongOracleStep;
85639
+ exports.listStrongOracleKinds = listStrongOracleKinds;
85640
+ exports.triageMobileFailure = triageMobileFailure;
85641
+ exports.triageToHealTerminal = triageToHealTerminal;
85642
+ var anr_env_js_1 = require_anr_env();
85643
+ var STRONG_ACTIONS = /* @__PURE__ */ new Set(["assertStateDelta", "assertText", "captureState"]);
85644
+ var CHROME_VISIBLE_RE = /\b(tab|home|settings|back|menu|navigation|nav bar|bottom nav|status bar)\b/i;
85645
+ function hasStrongMobileOracle(steps) {
85646
+ return steps.some(isStrongOracleStep);
85647
+ }
85648
+ function isStrongOracleStep(step) {
85649
+ if (STRONG_ACTIONS.has(step.action))
85650
+ return true;
85651
+ if (step.action !== "assertVisible")
85652
+ return false;
85653
+ const blob = [
85654
+ step.assertion ?? "",
85655
+ step.description ?? "",
85656
+ step.target?.primary ?? "",
85657
+ ...step.target?.fallbacks ?? []
85658
+ ].join(" ");
85659
+ if (/\b(error|invalid|required|must|cannot|failed|denied|blocked|empty|missing|deleted|removed|saved|success|created|updated)\b/i.test(blob)) {
85660
+ return true;
85661
+ }
85662
+ if (CHROME_VISIBLE_RE.test(blob) && blob.length < 80)
85663
+ return false;
85664
+ return false;
85665
+ }
85666
+ function listStrongOracleKinds(steps) {
85667
+ const kinds = /* @__PURE__ */ new Set();
85668
+ for (const step of steps) {
85669
+ if (step.action === "assertStateDelta")
85670
+ kinds.add("assertStateDelta");
85671
+ else if (step.action === "assertText")
85672
+ kinds.add("assertText");
85673
+ else if (step.action === "captureState")
85674
+ kinds.add("captureState");
85675
+ else if (isStrongOracleStep(step))
85676
+ kinds.add(step.action);
85677
+ }
85678
+ return [...kinds];
85679
+ }
85680
+ function errorText(error2, logs2) {
85681
+ return `${error2 ?? ""}
85682
+ ${logs2 ?? ""}`.trim();
85683
+ }
85684
+ function leadingPrefix(error2) {
85685
+ if (!error2)
85686
+ return null;
85687
+ const m = error2.match(/^(CONFIG_ISSUE|APP_BUG|RATE_LIMIT_BLOCKED|PREREQ_UNMET|HEAL_SECRET_LEAK)\s*:/i);
85688
+ return m ? m[1].toUpperCase() : null;
85689
+ }
85690
+ function looksLikeConfig(text) {
85691
+ if (/CONFIG_ISSUE\s*:/i.test(text))
85692
+ return true;
85693
+ if (/RATE_LIMIT_BLOCKED\s*:/i.test(text))
85694
+ return true;
85695
+ if (/Missing mobile test variable/i.test(text))
85696
+ return true;
85697
+ if (/\b(quota|rate.?limit|429|lockout)\b/i.test(text))
85698
+ return true;
85699
+ if (/\b(device (?:offline|disconnected|not found)|session (?:not started|terminated|deleted))\b/i.test(text)) {
85700
+ return true;
85701
+ }
85702
+ if (/\b(Appium|adbExec|UiAutomator2|XCUITest)\b/i.test(text) && /\b(ECONNREFUSED|timeout|not available|could not start|driver)\b/i.test(text)) {
85703
+ return true;
85704
+ }
85705
+ return false;
85706
+ }
85707
+ function looksLikePrereq(text) {
85708
+ return /PREREQ_UNMET\s*:/i.test(text) || /\b(prerequisite|fixture missing|no test data|account (?:missing|not provisioned)|required entity)\b/i.test(text);
85709
+ }
85710
+ function looksLikeFlake(text) {
85711
+ return /\b(stale element|animation|transition|intermittent|flaky|race condition)\b/i.test(text) || /\b(element (?:is )?not interactable|obscured|covered by)\b/i.test(text);
85712
+ }
85713
+ function looksLikeLocatorMiss(text) {
85714
+ return /not found|no such element|No identity locator|unable to locate/i.test(text);
85715
+ }
85716
+ function looksLikeOracleFailure(text) {
85717
+ 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);
85718
+ }
85719
+ function triageMobileFailure(input) {
85720
+ const failureCount = Math.max(1, input.failureCount ?? 1);
85721
+ const tags = input.tags ?? [];
85722
+ const text = errorText(input.error, input.logs);
85723
+ const prefix = leadingPrefix(input.error);
85724
+ const strongKinds = listStrongOracleKinds(input.steps);
85725
+ const hasStrong = strongKinds.length > 0 || tags.some((t) => t === "@strong-oracle" || t === "strong-oracle");
85726
+ const evidence = {
85727
+ hasStrongOracle: hasStrong,
85728
+ strongOracleKinds: strongKinds,
85729
+ failureCount,
85730
+ errorPrefix: prefix
85731
+ };
85732
+ if ((0, anr_env_js_1.isAnrOrSystemUiEnvironmentFailure)(input.error, input.logs)) {
85733
+ return {
85734
+ verdict: "config_issue",
85735
+ reason: "ANR / System UI freeze (device or app hung) \u2014 treat as environment, not product bug",
85736
+ shouldHeal: false,
85737
+ healVerdict: "config_issue",
85738
+ evidence
85739
+ };
85740
+ }
85741
+ if (prefix === "CONFIG_ISSUE" || prefix === "RATE_LIMIT_BLOCKED") {
85742
+ return {
85743
+ verdict: "config_issue",
85744
+ reason: `Environment/config prefix on error (${prefix})`,
85745
+ shouldHeal: false,
85746
+ healVerdict: "config_issue",
85747
+ evidence
85748
+ };
85749
+ }
85750
+ if (prefix === "APP_BUG") {
85751
+ return {
85752
+ verdict: "app_bug_candidate",
85753
+ reason: "Error already classified APP_BUG",
85754
+ shouldHeal: false,
85755
+ healVerdict: "app_bug",
85756
+ evidence
85757
+ };
85758
+ }
85759
+ if (prefix === "PREREQ_UNMET") {
85760
+ return {
85761
+ verdict: "prereq_unmet",
85762
+ reason: "Error already classified PREREQ_UNMET",
85763
+ shouldHeal: false,
85764
+ healVerdict: "prereq_unmet",
85765
+ evidence
85766
+ };
85767
+ }
85768
+ if (looksLikeConfig(text)) {
85769
+ return {
85770
+ verdict: "config_issue",
85771
+ reason: "Failure matches environment/config/device patterns",
85772
+ shouldHeal: false,
85773
+ healVerdict: "config_issue",
85774
+ evidence
85775
+ };
85776
+ }
85777
+ if (looksLikePrereq(text)) {
85778
+ return {
85779
+ verdict: "prereq_unmet",
85780
+ reason: "Failure matches missing prerequisite / fixture patterns",
85781
+ shouldHeal: false,
85782
+ healVerdict: "prereq_unmet",
85783
+ evidence
85784
+ };
85785
+ }
85786
+ if (input.uiSuccessWithFailedWriteApi && hasStrong) {
85787
+ return {
85788
+ verdict: "app_bug_candidate",
85789
+ reason: "UI path completed but a write API failed (network secondary oracle)",
85790
+ shouldHeal: false,
85791
+ healVerdict: "app_bug",
85792
+ evidence
85793
+ };
85794
+ }
85795
+ if (hasStrong && failureCount >= 2 && (looksLikeOracleFailure(text) || !looksLikeLocatorMiss(text))) {
85796
+ if (looksLikeLocatorMiss(text) && failureCount < 3) {
85797
+ return {
85798
+ verdict: "needs_heal",
85799
+ reason: "Strong oracle present but error looks like a locator miss \u2014 attempt heal",
85800
+ shouldHeal: true,
85801
+ evidence
85802
+ };
85803
+ }
85804
+ return {
85805
+ verdict: "app_bug_candidate",
85806
+ reason: `Strong oracle (${strongKinds.join(", ") || "tagged"}) failed ${failureCount}\xD7 with product-shaped error`,
85807
+ shouldHeal: false,
85808
+ healVerdict: "app_bug",
85809
+ evidence
85810
+ };
85811
+ }
85812
+ if (looksLikeFlake(text) && failureCount === 1 && !hasStrong) {
85813
+ return {
85814
+ verdict: "flake_candidate",
85815
+ reason: "Transient interactability / race signals on a weak-oracle test",
85816
+ shouldHeal: true,
85817
+ // still allow heal, but tag flaky path
85818
+ extraTags: ["@flaky"],
85819
+ evidence
85820
+ };
85821
+ }
85822
+ if (tags.includes("@smoke") && !hasStrong && looksLikeLocatorMiss(text)) {
85823
+ return {
85824
+ verdict: "needs_heal",
85825
+ reason: "Smoke/nav locator failure \u2014 heal allowed",
85826
+ shouldHeal: true,
85827
+ evidence
85828
+ };
85829
+ }
85830
+ return {
85831
+ verdict: "needs_heal",
85832
+ reason: hasStrong ? "Strong oracle first failure \u2014 allow heal once before APP_BUG escalation" : "Default: attempt script/live heal",
85833
+ shouldHeal: true,
85834
+ evidence
85835
+ };
85836
+ }
85837
+ function triageToHealTerminal(triage) {
85838
+ if (triage.shouldHeal || !triage.healVerdict)
85839
+ return null;
85840
+ return {
85841
+ verdict: triage.healVerdict,
85842
+ healReason: triage.reason,
85843
+ extraTags: triage.extraTags
85844
+ };
85845
+ }
85846
+ }
85847
+ });
85848
+
85849
+ // ../runner-core/dist/services/mobile/oracle-protection.js
85850
+ var require_oracle_protection = __commonJS({
85851
+ "../runner-core/dist/services/mobile/oracle-protection.js"(exports) {
85852
+ "use strict";
85853
+ Object.defineProperty(exports, "__esModule", { value: true });
85854
+ exports.fingerprintOracles = fingerprintOracles;
85855
+ exports.rejectOracleWeakeningPatch = rejectOracleWeakeningPatch;
85856
+ exports.oraclesPreserved = oraclesPreserved;
85857
+ var failure_triage_js_1 = require_failure_triage();
85858
+ var EXPECT_STRENGTH = {
85859
+ entity_removed: 5,
85860
+ removed: 5,
85861
+ count_decreased: 4,
85862
+ "count-1": 4,
85863
+ count_increased: 4,
85864
+ "count+1": 4,
85865
+ persisted: 3,
85866
+ unchanged: 3,
85867
+ changed: 2,
85868
+ any_change: 2
85869
+ };
85870
+ function normalizeText(value) {
85871
+ return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
85872
+ }
85873
+ function stateKeyOf(step) {
85874
+ const meta = step.metadata;
85875
+ const key = typeof meta?.stateKey === "string" ? meta.stateKey.trim() : "";
85876
+ return key || normalizeText(step.target?.primary) || normalizeText(step.description) || `order-${step.order}`;
85877
+ }
85878
+ function expectOf(step) {
85879
+ const meta = step.metadata;
85880
+ const fromMeta = typeof meta?.expect === "string" ? meta.expect : "";
85881
+ return normalizeText(fromMeta || step.assertion || step.value || "changed");
85882
+ }
85883
+ function fingerprintOracles(steps) {
85884
+ const assertTexts = [];
85885
+ const stateExpects = {};
85886
+ let stateDeltas = 0;
85887
+ let captures = 0;
85888
+ let strongVisible = 0;
85889
+ for (const step of steps) {
85890
+ if (step.action === "assertStateDelta") {
85891
+ stateDeltas += 1;
85892
+ stateExpects[stateKeyOf(step)] = expectOf(step);
85893
+ } else if (step.action === "captureState")
85894
+ captures += 1;
85895
+ else if (step.action === "assertText") {
85896
+ const t = normalizeText(step.assertion ?? step.value ?? step.description);
85897
+ if (t)
85898
+ assertTexts.push(t);
85899
+ } else if (step.action === "assertVisible" && (0, failure_triage_js_1.isStrongOracleStep)(step)) {
85900
+ strongVisible += 1;
85901
+ }
85902
+ }
85903
+ return {
85904
+ stateDeltas,
85905
+ captures,
85906
+ assertTexts,
85907
+ stateExpects,
85908
+ strongVisible,
85909
+ strongTotal: stateDeltas + captures + assertTexts.length + strongVisible
85910
+ };
85911
+ }
85912
+ function expectStrength(expect) {
85913
+ const e = expect.toLowerCase().trim();
85914
+ if (e.startsWith("removed:"))
85915
+ return 5;
85916
+ return EXPECT_STRENGTH[e] ?? 1;
85917
+ }
85918
+ function rejectOracleWeakeningPatch(original, patched) {
85919
+ if (!Array.isArray(patched) || patched.length === 0) {
85920
+ return "healed steps missing \u2014 refusing empty patch that would drop oracles";
85921
+ }
85922
+ const before = fingerprintOracles(original);
85923
+ const after = fingerprintOracles(patched);
85924
+ if (before.stateDeltas > 0 && after.stateDeltas < before.stateDeltas) {
85925
+ return `heal patch removed assertStateDelta oracle(s) (${before.stateDeltas} \u2192 ${after.stateDeltas}) \u2014 refusing to go green by dropping state proof`;
85926
+ }
85927
+ for (const [key, beforeExpect] of Object.entries(before.stateExpects)) {
85928
+ const afterExpect = after.stateExpects[key];
85929
+ if (!afterExpect) {
85930
+ return `heal patch removed assertStateDelta for key "${key}" (expect was ${beforeExpect})`;
85931
+ }
85932
+ if (expectStrength(afterExpect) < expectStrength(beforeExpect)) {
85933
+ return `heal patch softened assertStateDelta expect for "${key}" (${beforeExpect} \u2192 ${afterExpect})`;
85934
+ }
85935
+ }
85936
+ if (before.captures > 0 && after.captures < before.captures && after.stateDeltas === 0) {
85937
+ return `heal patch removed captureState without keeping assertStateDelta (${before.captures} \u2192 ${after.captures})`;
85938
+ }
85939
+ if (before.assertTexts.length > 0) {
85940
+ const afterSet = new Set(after.assertTexts);
85941
+ const lost = before.assertTexts.filter((t) => !afterSet.has(t));
85942
+ if (after.assertTexts.length < before.assertTexts.length) {
85943
+ return `heal patch dropped assertText outcome proof (${before.assertTexts.length} \u2192 ${after.assertTexts.length})`;
85944
+ }
85945
+ if (lost.length > 0) {
85946
+ const softKept = lost.every((t) => after.assertTexts.some((a) => a.includes(t) || t.includes(a)));
85947
+ if (!softKept) {
85948
+ return `heal patch replaced assertText expectations (${lost.slice(0, 2).join("; ")}) \u2014 outcome must stay`;
85949
+ }
85950
+ }
85951
+ }
85952
+ if (before.strongVisible > 0 && after.strongVisible + after.assertTexts.length + after.stateDeltas < before.strongVisible) {
85953
+ return `heal patch weakened strong assertVisible validation oracles (${before.strongVisible} \u2192 ${after.strongVisible})`;
85954
+ }
85955
+ if (before.strongTotal > 0 && after.strongTotal === 0) {
85956
+ return "heal patch removed all strong oracles \u2014 refusing visibility-only green path";
85957
+ }
85958
+ for (const orig of original) {
85959
+ if (orig.action !== "assertStateDelta" && orig.action !== "assertText")
85960
+ continue;
85961
+ const key = normalizeText(orig.description ?? orig.assertion ?? "");
85962
+ if (!key)
85963
+ continue;
85964
+ const stillStrong = patched.some((p) => (p.action === "assertStateDelta" || p.action === "assertText") && normalizeText(p.description ?? p.assertion ?? "") === key);
85965
+ if (stillStrong)
85966
+ continue;
85967
+ const onlyVisible = patched.some((p) => p.action === "assertVisible" && normalizeText(p.description ?? p.assertion ?? "") === key);
85968
+ if (onlyVisible) {
85969
+ return `heal patch downgraded strong oracle "${key.slice(0, 60)}" to assertVisible`;
85970
+ }
85971
+ }
85972
+ return null;
85973
+ }
85974
+ function oraclesPreserved(original, patched) {
85975
+ return rejectOracleWeakeningPatch(original, patched) === null;
85976
+ }
85977
+ }
85978
+ });
85979
+
84838
85980
  // ../runner-core/dist/services/mobile/mobile-heal-postprocess.js
84839
85981
  var require_mobile_heal_postprocess = __commonJS({
84840
85982
  "../runner-core/dist/services/mobile/mobile-heal-postprocess.js"(exports) {
@@ -84843,6 +85985,8 @@ var require_mobile_heal_postprocess = __commonJS({
84843
85985
  exports.redactMobileSecretsFromText = redactMobileSecretsFromText;
84844
85986
  exports.detectMobileStepSecretLeak = detectMobileStepSecretLeak;
84845
85987
  exports.computeMobileHealOutcome = computeMobileHealOutcome2;
85988
+ var oracle_protection_js_1 = require_oracle_protection();
85989
+ var anr_env_js_1 = require_anr_env();
84846
85990
  var REDACT_KEY_RE = /email|username|user|phone|password|secret|token|api[_-]?key|access[_-]?key|\bpass\b|credential|bearer|\bpat\b|session|cookie|jwt|otp|pin/i;
84847
85991
  var DETECT_KEY_RE = /password|secret|token|api[_]?key|\bpass\b|credential|bearer|\bpat\b/i;
84848
85992
  var MIN_REDACT_LEN = 4;
@@ -84908,18 +86052,32 @@ var require_mobile_heal_postprocess = __commonJS({
84908
86052
  function computeMobileHealOutcome2(args) {
84909
86053
  const { heal } = args;
84910
86054
  const hasFix = Array.isArray(heal.healedSteps) && heal.healedSteps.length > 0;
84911
- const isVerifiedFix = heal.passed === true && heal.verdict === "fix" && hasFix;
86055
+ let isVerifiedFix = heal.passed === true && heal.verdict === "fix" && hasFix;
84912
86056
  const secretLeak = isVerifiedFix ? detectMobileStepSecretLeak(heal.healedSteps, args.testVariables) : null;
84913
- const isConfigIssue = heal.verdict === "config_issue";
84914
- const isAppBug = heal.verdict === "app_bug";
86057
+ const oracleWeakening = isVerifiedFix && args.originalSteps ? (0, oracle_protection_js_1.rejectOracleWeakeningPatch)(args.originalSteps, heal.healedSteps ?? []) : null;
86058
+ if (oracleWeakening)
86059
+ isVerifiedFix = false;
86060
+ const anrEnv = (0, anr_env_js_1.isAnrOrSystemUiEnvironmentFailure)(heal.healReason ?? args.finalError ?? args.reproduceError, typeof args.finalError === "string" ? args.finalError : void 0);
86061
+ const isConfigIssue = heal.verdict === "config_issue" || heal.verdict === "app_bug" && anrEnv;
86062
+ const isAppBug = !anrEnv && (heal.verdict === "app_bug" || Boolean(oracleWeakening && heal.passed));
84915
86063
  const extraTags = new Set((heal.extraTags ?? []).filter((t) => typeof t === "string" && t.length > 0));
84916
86064
  if (secretLeak)
84917
86065
  extraTags.add("@needs-review");
86066
+ if (oracleWeakening && !anrEnv) {
86067
+ extraTags.add("@needs-review");
86068
+ extraTags.add("@bug-found");
86069
+ }
86070
+ if (anrEnv) {
86071
+ extraTags.delete("@bug-found");
86072
+ extraTags.add("@needs-review");
86073
+ }
84918
86074
  if (!heal.passed && NEEDS_REVIEW_VERDICTS.has(heal.verdict))
84919
86075
  extraTags.add("@needs-review");
84920
86076
  let status;
84921
86077
  if (secretLeak)
84922
86078
  status = "FAILED";
86079
+ else if (oracleWeakening && !anrEnv)
86080
+ status = "FAILED";
84923
86081
  else if (isVerifiedFix)
84924
86082
  status = "PASSED";
84925
86083
  else if (isConfigIssue)
@@ -84931,8 +86089,11 @@ var require_mobile_heal_postprocess = __commonJS({
84931
86089
  error2 = void 0;
84932
86090
  } else if (secretLeak) {
84933
86091
  error2 = `HEAL_SECRET_LEAK: healed steps contained a literal secret (${secretLeak}); patch dropped, tagged @needs-review`;
86092
+ } else if (oracleWeakening && !anrEnv) {
86093
+ error2 = `APP_BUG: heal attempted to drop strong oracle (${oracleWeakening}); patch refused`;
84934
86094
  } else if (isConfigIssue) {
84935
- error2 = `CONFIG_ISSUE: ${heal.healReason ?? args.finalError ?? args.reproduceError ?? "environment/rate-limit blocker"}`;
86095
+ const reason = heal.healReason ?? args.finalError ?? args.reproduceError ?? "environment/rate-limit blocker";
86096
+ error2 = anrEnv ? `CONFIG_ISSUE: ANR / System UI freeze (not a product assertion failure) \u2014 ${reason}` : `CONFIG_ISSUE: ${reason}`;
84936
86097
  } else if (isAppBug) {
84937
86098
  error2 = `APP_BUG: ${heal.healReason ?? args.finalError ?? args.reproduceError ?? "application defect found during heal"}`;
84938
86099
  } else if (heal.passed && heal.verdict === "fix" && !hasFix) {
@@ -84947,13 +86108,64 @@ var require_mobile_heal_postprocess = __commonJS({
84947
86108
  error: error2,
84948
86109
  extraTags: extraTags.size > 0 ? [...extraTags] : void 0,
84949
86110
  removeTags: heal.removeTags,
84950
- shouldPatch: isVerifiedFix && !secretLeak,
86111
+ shouldPatch: isVerifiedFix && !secretLeak && !oracleWeakening,
84951
86112
  secretLeak
84952
86113
  };
84953
86114
  }
84954
86115
  }
84955
86116
  });
84956
86117
 
86118
+ // ../runner-core/dist/services/mobile/network-oracle.js
86119
+ var require_network_oracle = __commonJS({
86120
+ "../runner-core/dist/services/mobile/network-oracle.js"(exports) {
86121
+ "use strict";
86122
+ Object.defineProperty(exports, "__esModule", { value: true });
86123
+ exports.evaluateNetworkSecondaryOracle = evaluateNetworkSecondaryOracle;
86124
+ exports.uiSuccessWithFailedWriteApi = uiSuccessWithFailedWriteApi;
86125
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
86126
+ function isWrite(method) {
86127
+ return WRITE_METHODS.has(method.toUpperCase());
86128
+ }
86129
+ function isServerOrClientError(status) {
86130
+ return status >= 400 && status < 600;
86131
+ }
86132
+ function isNoiseUrl(url) {
86133
+ return /\b(analytics|telemetry|sentry|bugsnag|crashlytics|facebook\.com\/tr|google-analytics|doubleclick|hotjar|segment\.io|amplitude|mixpanel)\b/i.test(url);
86134
+ }
86135
+ function evaluateNetworkSecondaryOracle(input) {
86136
+ const calls = input.apiCalls;
86137
+ if (!calls || calls.length === 0)
86138
+ return null;
86139
+ if (input.uiPathSucceeded === false)
86140
+ return null;
86141
+ for (const call of calls) {
86142
+ const method = String(call.method ?? "GET");
86143
+ const url = String(call.url ?? "");
86144
+ const status = typeof call.status === "number" ? call.status : 0;
86145
+ if (!isWrite(method))
86146
+ continue;
86147
+ if (!isServerOrClientError(status))
86148
+ continue;
86149
+ if (!url || isNoiseUrl(url))
86150
+ continue;
86151
+ if (status === 401 || status === 403)
86152
+ continue;
86153
+ return {
86154
+ kind: "write_api_failed",
86155
+ method: method.toUpperCase(),
86156
+ url: url.slice(0, 200),
86157
+ status,
86158
+ reason: `Write API ${method.toUpperCase()} ${url.slice(0, 120)} returned ${status} while UI path appeared successful`
86159
+ };
86160
+ }
86161
+ return null;
86162
+ }
86163
+ function uiSuccessWithFailedWriteApi(input) {
86164
+ return evaluateNetworkSecondaryOracle(input) !== null;
86165
+ }
86166
+ }
86167
+ });
86168
+
84957
86169
  // ../runner-core/dist/services/selector-registry-context.js
84958
86170
  var require_selector_registry_context = __commonJS({
84959
86171
  "../runner-core/dist/services/selector-registry-context.js"(exports) {
@@ -85321,6 +86533,10 @@ var require_mobile_heal_loop = __commonJS({
85321
86533
  var ai_retry_js_1 = require_ai_retry();
85322
86534
  var mobile_source_parser_js_1 = require_mobile_source_parser();
85323
86535
  var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
86536
+ var failure_triage_js_1 = require_failure_triage();
86537
+ var oracle_protection_js_1 = require_oracle_protection();
86538
+ var network_oracle_js_1 = require_network_oracle();
86539
+ var anr_env_js_1 = require_anr_env();
85324
86540
  var mobile_observation_js_1 = require_mobile_observation();
85325
86541
  var selector_registry_context_js_1 = require_selector_registry_context();
85326
86542
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
@@ -85332,18 +86548,24 @@ Reason over steps + error + tree + registry and return working healed steps. Do
85332
86548
  2. STEP RESULTS: which steps passed before the failure.
85333
86549
  3. TREE: does the current screen match what the failed step expects?
85334
86550
  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).
86551
+ 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.
85335
86552
  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.
85336
86553
  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.
85337
86554
  ## Response \u2014 STRICT JSON, exactly one verdict:
85338
86555
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
85339
- {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
85340
- {"verdict":"config_issue","reason":"429/rate limit/lockout/quota"}
86556
+ {"verdict":"app_bug","reason":"product defect with durable oracle evidence (wrong entity, validation bypass, wrong count) \u2014 NOT ANR/System UI"}
86557
+ {"verdict":"config_issue","reason":"429/rate limit/lockout/quota OR ANR/System UI freeze/device unresponsive"}
85341
86558
  {"verdict":"needs_device","reason":"cannot fix from a static tree; need live re-observation"}
85342
86559
  {"verdict":"prereq_unmet","reason":"required entity/state absent for the account"}
85343
86560
  {"verdict":"state_stale","reason":"leftover data blocks a baseline assertion"}
85344
86561
  {"verdict":"test_invalid","reason":"scenario cannot be made to pass"}
85345
86562
  {"verdict":"unhealable_dynamic","reason":"asserted value is inherently volatile (timestamps, counters, feeds) \u2014 no durable locator can exist; needs human review"}
85346
- For a fix, return the COMPLETE steps array (not a diff).`;
86563
+ For a fix, return the COMPLETE steps array (not a diff).
86564
+ ## Strong oracle protection (non-negotiable)
86565
+ - NEVER remove or downgrade assertStateDelta, captureState, or assertText outcome proof to go green.
86566
+ - NEVER replace a durable outcome assert with mere assertVisible of a tab/header.
86567
+ - You MAY fix locators, waits, and navigation around those oracles.
86568
+ - If the app truly violates the oracle on a healthy UI, return verdict "app_bug" instead of stripping the assert.`;
85347
86569
  var DEFAULT_SCRIPT_MAX_ATTEMPTS = 6;
85348
86570
  var RECOVERABLE_MAX = 3;
85349
86571
  var MAX_CONSECUTIVE_INVALID_VERDICTS = 3;
@@ -85373,7 +86595,9 @@ For a fix, return the COMPLETE steps array (not a diff).`;
85373
86595
  "home",
85374
86596
  "waitForElement",
85375
86597
  "assertVisible",
85376
- "assertText"
86598
+ "assertText",
86599
+ "captureState",
86600
+ "assertStateDelta"
85377
86601
  ]);
85378
86602
  var MAX_HEALED_STEPS = 300;
85379
86603
  var TERMINAL_VERDICTS = /* @__PURE__ */ new Set([
@@ -85400,6 +86624,28 @@ For a fix, return the COMPLETE steps array (not a diff).`;
85400
86624
  function isMobileHealVerdict(value) {
85401
86625
  return MOBILE_HEAL_VERDICTS.includes(value);
85402
86626
  }
86627
+ function normalizeMobileHealVerdict(raw) {
86628
+ const v = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
86629
+ if (isMobileHealVerdict(v))
86630
+ return v;
86631
+ if (v === "appbug" || v === "application_bug" || v === "bug_found")
86632
+ return "app_bug";
86633
+ if (v === "config" || v === "configuration_issue" || v === "env" || v === "environment")
86634
+ return "config_issue";
86635
+ if (v === "prereq" || v === "prerequisite" || v === "prereq_missing")
86636
+ return "prereq_unmet";
86637
+ if (v === "stale" || v === "stale_state")
86638
+ return "state_stale";
86639
+ if (v === "invalid_test" || v === "bad_test")
86640
+ return "test_invalid";
86641
+ if (v === "unhealable" || v === "dynamic")
86642
+ return "unhealable_dynamic";
86643
+ if (v === "need_device" || v === "needs_appium" || v === "needs_live")
86644
+ return "needs_device";
86645
+ if (v === "healed" || v === "ok" || v === "pass" || v === "passed")
86646
+ return "fix";
86647
+ return null;
86648
+ }
85403
86649
  function isLocatorNotFound(error2) {
85404
86650
  return /not found|no such element/i.test(error2 ?? "");
85405
86651
  }
@@ -85681,15 +86927,43 @@ For a fix, return the COMPLETE steps array (not a diff).`;
85681
86927
  const sub = extractBracesSubstring(cleaned);
85682
86928
  obj = sub ? safeJsonParse(sub) : void 0;
85683
86929
  }
85684
- if (!isRecord(obj))
86930
+ if (!isRecord(obj)) {
86931
+ const line = cleaned.match(/\bverdict\s*[:=]\s*["']?([a-zA-Z_ -]+)["']?/i);
86932
+ if (line) {
86933
+ const verdict2 = normalizeMobileHealVerdict(line[1]);
86934
+ if (verdict2) {
86935
+ return {
86936
+ verdict: verdict2,
86937
+ reason: cleaned.slice(0, 2e3)
86938
+ };
86939
+ }
86940
+ }
86941
+ const prefix = cleaned.match(/^\s*(APP_BUG|CONFIG_ISSUE|PREREQ_UNMET|TEST_INVALID|STATE_STALE)\s*:\s*(.*)$/im);
86942
+ if (prefix) {
86943
+ const map2 = {
86944
+ APP_BUG: "app_bug",
86945
+ CONFIG_ISSUE: "config_issue",
86946
+ PREREQ_UNMET: "prereq_unmet",
86947
+ TEST_INVALID: "test_invalid",
86948
+ STATE_STALE: "state_stale"
86949
+ };
86950
+ const verdict2 = map2[prefix[1].toUpperCase()];
86951
+ if (verdict2) {
86952
+ return { verdict: verdict2, reason: (prefix[2] || cleaned).slice(0, 2e3) };
86953
+ }
86954
+ }
85685
86955
  return null;
85686
- const rawVerdict = obj.verdict;
85687
- if (typeof rawVerdict !== "string" || !isMobileHealVerdict(rawVerdict))
86956
+ }
86957
+ const rawVerdict = obj.verdict ?? obj.Verdict ?? obj.status;
86958
+ if (typeof rawVerdict !== "string")
86959
+ return null;
86960
+ const verdict = normalizeMobileHealVerdict(rawVerdict);
86961
+ if (!verdict)
85688
86962
  return null;
85689
86963
  return {
85690
- verdict: rawVerdict,
86964
+ verdict,
85691
86965
  steps: coerceSteps(obj.steps),
85692
- reason: typeof obj.reason === "string" ? obj.reason : void 0,
86966
+ reason: typeof obj.reason === "string" ? obj.reason : typeof obj.rationale === "string" ? obj.rationale : typeof obj.message === "string" ? obj.message : void 0,
85693
86967
  confidence: typeof obj.confidence === "number" ? obj.confidence : void 0
85694
86968
  };
85695
86969
  }
@@ -86038,7 +87312,7 @@ ${args.directive}`);
86038
87312
  };
86039
87313
  }
86040
87314
  markRecoverableTurn();
86041
- pendingDirective = "Your last response was not valid JSON matching the verdict schema. Return exactly one JSON verdict object \u2014 no prose, no markdown fences.";
87315
+ pendingDirective = 'SCHEMA REPAIR: Your last response was not a valid heal verdict. Reply with ONLY one JSON object (no markdown fences, no prose) of the form:\n{"verdict":"<one of: fix|app_bug|config_issue|needs_device|prereq_unmet|state_stale|test_invalid|unhealable_dynamic>","reason":"<short string>","steps":[/* optional MobileTestStep[] when verdict is fix */],"confidence":0.0}\nUse lowercase snake_case for verdict. Do not invent other verdict values.';
86042
87316
  continue;
86043
87317
  }
86044
87318
  consecutiveInvalidVerdicts = 0;
@@ -86067,6 +87341,13 @@ ${args.directive}`);
86067
87341
  pendingDirective = 'You returned verdict "fix" but no usable steps array. Return the COMPLETE healed MobileTestStep array.';
86068
87342
  continue;
86069
87343
  }
87344
+ const oracleRejection = (0, oracle_protection_js_1.rejectOracleWeakeningPatch)(steps, healedSteps);
87345
+ if (oracleRejection) {
87346
+ markRecoverableTurn();
87347
+ lastVerdict = "fix";
87348
+ pendingDirective = `ORACLE PROTECTION: ${oracleRejection}. Keep all assertStateDelta/assertText/captureState outcome proof; fix locators/navigation only. If the app violates the oracle, return verdict "app_bug".`;
87349
+ continue;
87350
+ }
86070
87351
  if (activeFailingSource && isLocatorNotFound(activeError) && !isDrift(activeError)) {
86071
87352
  const failingSourceSnapshot = activeFailingSource;
86072
87353
  const missing = timedSync("device-op", `phase1-attempt-${attempt}-static-verify`, options.onProgress, () => {
@@ -86210,6 +87491,28 @@ ${args.directive}`);
86210
87491
  if (invalidAuthSetup) {
86211
87492
  return { passed: false, verdict: "test_invalid", healReason: invalidAuthSetup };
86212
87493
  }
87494
+ const networkBug = (0, network_oracle_js_1.uiSuccessWithFailedWriteApi)({
87495
+ apiCalls: ctx.apiCalls,
87496
+ uiPathSucceeded: ctx.uiPathSucceeded
87497
+ });
87498
+ const triage = (0, failure_triage_js_1.triageMobileFailure)({
87499
+ steps,
87500
+ error: ctx.error,
87501
+ logs: ctx.logs,
87502
+ tags: ctx.tags,
87503
+ failureCount: ctx.failureCount,
87504
+ uiSuccessWithFailedWriteApi: networkBug
87505
+ });
87506
+ options.onProgress?.(`Pre-heal triage: ${triage.verdict} \u2014 ${triage.reason}`);
87507
+ const terminal = (0, failure_triage_js_1.triageToHealTerminal)(triage);
87508
+ if (terminal) {
87509
+ return {
87510
+ passed: false,
87511
+ verdict: terminal.verdict,
87512
+ healReason: terminal.healReason,
87513
+ extraTags: terminal.extraTags
87514
+ };
87515
+ }
86213
87516
  const preflight = await tryRegistryPreflight(steps, target, ctx, options);
86214
87517
  if (preflight)
86215
87518
  return preflight;
@@ -86230,6 +87533,18 @@ ${args.directive}`);
86230
87533
  providerUnavailable: true
86231
87534
  };
86232
87535
  }
87536
+ if (script.verdict === "app_bug" && (0, anr_env_js_1.isAnrOrSystemUiEnvironmentFailure)(script.healReason ?? ctx.error, ctx.logs)) {
87537
+ return {
87538
+ passed: false,
87539
+ verdict: "config_issue",
87540
+ healedSteps: script.healedSteps,
87541
+ healedAppiumCode: script.healedAppiumCode,
87542
+ healReason: `ANR / System UI freeze (environment) \u2014 ${script.healReason ?? ctx.error ?? "device unresponsive"}`,
87543
+ confidence: script.confidence,
87544
+ extraTags: script.extraTags,
87545
+ removeTags: [...script.removeTags ?? [], "@bug-found"].filter((t, i, a) => a.indexOf(t) === i)
87546
+ };
87547
+ }
86233
87548
  const shouldEscalate = script.verdict === "needs_device" || script.verdict === "fix" && !script.passed;
86234
87549
  if (shouldEscalate && options.runLiveHeal) {
86235
87550
  try {
@@ -86459,6 +87774,11 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
86459
87774
  if (parsed.verdict !== "fix" || !parsed.steps)
86460
87775
  continue;
86461
87776
  const healedSteps = parsed.steps;
87777
+ const liveOracleRejection = (0, oracle_protection_js_1.rejectOracleWeakeningPatch)(args.steps, healedSteps);
87778
+ if (liveOracleRejection) {
87779
+ options.onProgress?.(`ORACLE PROTECTION (live): ${liveOracleRejection}`);
87780
+ continue;
87781
+ }
86462
87782
  let verify;
86463
87783
  try {
86464
87784
  verify = await timed("reexec", `phase2-pass-${pass}-verify`, options.onProgress, () => runNativeTest({ steps: healedSteps, target, testVariables }));
@@ -353981,7 +355301,7 @@ var require_package4 = __commonJS({
353981
355301
  "package.json"(exports, module) {
353982
355302
  module.exports = {
353983
355303
  name: "@validate.qa/runner",
353984
- version: "1.0.88",
355304
+ version: "1.0.90",
353985
355305
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353986
355306
  bin: {
353987
355307
  "validate-runner": "dist/cli.js"
@@ -354842,6 +356162,68 @@ async function waitForUiSettle(sessionPath, opts = {}) {
354842
356162
  }
354843
356163
  return { settled: false, lastFingerprint, frames };
354844
356164
  }
356165
+ var SYSTEM_UI_DISMISS_LABELS = [
356166
+ "OK",
356167
+ "Got it",
356168
+ "GOT IT",
356169
+ "Continue",
356170
+ "Close",
356171
+ "Dismiss",
356172
+ "I understand",
356173
+ "Accept",
356174
+ // Android 15+ / 16 KB page-size compatibility dialog primary action.
356175
+ "OK, got it"
356176
+ ];
356177
+ function extractElementId(value) {
356178
+ if (!value || typeof value !== "object") return null;
356179
+ const v = value;
356180
+ const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
356181
+ if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) return null;
356182
+ return elementId;
356183
+ }
356184
+ async function dismissBlockingSystemUi(sessionPath, platform3, opts = {}) {
356185
+ const maxDismisses = Math.max(1, Math.min(5, opts.maxDismisses ?? 3));
356186
+ const dismissed = [];
356187
+ for (let i = 0; i < maxDismisses; i++) {
356188
+ let tapped = false;
356189
+ for (const label of SYSTEM_UI_DISMISS_LABELS) {
356190
+ try {
356191
+ const strategies = platform3 === "ANDROID" ? [
356192
+ { using: "-android uiautomator", value: `new UiSelector().text("${label.replace(/"/g, '\\"')}")` },
356193
+ { using: "accessibility id", value: label }
356194
+ ] : [
356195
+ { using: "accessibility id", value: label },
356196
+ { using: "-ios predicate string", value: `label == "${label.replace(/"/g, '\\"')}" OR name == "${label.replace(/"/g, '\\"')}"` }
356197
+ ];
356198
+ for (const strategy of strategies) {
356199
+ try {
356200
+ const elResult = await appiumRequest(`${sessionPath}/element`, {
356201
+ method: "POST",
356202
+ body: JSON.stringify(strategy)
356203
+ });
356204
+ const elementId = extractElementId(readValue(elResult));
356205
+ if (!elementId) continue;
356206
+ await appiumRequest(`${sessionPath}/element/${elementId}/click`, {
356207
+ method: "POST",
356208
+ body: "{}"
356209
+ });
356210
+ dismissed.push(label);
356211
+ tapped = true;
356212
+ await waitForUiSettle(sessionPath, { timeoutMs: 1200, pollMs: 200, minStableFrames: 1 });
356213
+ break;
356214
+ } catch (err) {
356215
+ if (isAppiumDriverDeadError(err)) throw err;
356216
+ }
356217
+ }
356218
+ if (tapped) break;
356219
+ } catch (err) {
356220
+ if (isAppiumDriverDeadError(err)) throw err;
356221
+ }
356222
+ }
356223
+ if (!tapped) break;
356224
+ }
356225
+ return { dismissed, ok: dismissed.length > 0 };
356226
+ }
354845
356227
  async function waitForColdBootSettle(sessionPath) {
354846
356228
  await waitForUiSettle(sessionPath, {
354847
356229
  timeoutMs: MOBILE_COLD_BOOT_SETTLE_MS,
@@ -357582,6 +358964,107 @@ async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
357582
358964
  }
357583
358965
  log2(` Cold-boot wait timed out after ${timeoutMs}ms (last tree had ${lastCount} node(s)) \u2014 proceeding anyway.`);
357584
358966
  }
358967
+ async function dismissSystemUiAfterColdBoot(sessionId, platform3, log2) {
358968
+ try {
358969
+ const result = await dismissBlockingSystemUi(`/session/${sessionId}`, platform3);
358970
+ if (result.dismissed.length > 0) {
358971
+ log2(` Dismissed system UI: ${result.dismissed.join(" \u2192 ")}`);
358972
+ }
358973
+ } catch (err) {
358974
+ log2(` System UI dismiss skipped (${err instanceof Error ? err.message : String(err)})`);
358975
+ }
358976
+ }
358977
+ async function captureMobileStateValues(sessionId, kind) {
358978
+ const res = await wdRequest(`/session/${sessionId}/source`);
358979
+ const xml = typeof res?.value === "string" ? res.value : "";
358980
+ if (kind === "badge_count") {
358981
+ const nums = /* @__PURE__ */ new Set();
358982
+ for (const m of xml.matchAll(/\b(\d{1,6})\b/g)) {
358983
+ nums.add(m[1]);
358984
+ }
358985
+ const preferred = /* @__PURE__ */ new Set();
358986
+ for (const m of xml.matchAll(/(?:count|badge|open|unread|item|invoice|result|pending)[^>]{0,80}?\b(\d{1,6})\b/gi)) {
358987
+ preferred.add(m[1]);
358988
+ }
358989
+ for (const m of xml.matchAll(/\b(\d{1,6})\b[^>]{0,80}?(?:count|badge|open|unread|items?)/gi)) {
358990
+ preferred.add(m[1]);
358991
+ }
358992
+ return preferred.size > 0 ? [...preferred] : [...nums].slice(0, 20);
358993
+ }
358994
+ const labels = /* @__PURE__ */ new Set();
358995
+ const attrRe = /(?:text|content-desc|name|label)="([^"]{2,80})"/gi;
358996
+ for (const m of xml.matchAll(attrRe)) {
358997
+ const t = m[1].trim();
358998
+ if (t.length < 2 || t.length > 80) continue;
358999
+ if (/^(true|false|null|OK|Cancel)$/i.test(t)) continue;
359000
+ labels.add(t);
359001
+ }
359002
+ return [...labels].slice(0, 80);
359003
+ }
359004
+ function assertMobileStateDelta(before, after, expect, key, log2) {
359005
+ const beforeSet = new Set(before);
359006
+ const afterSet = new Set(after);
359007
+ const removed = before.filter((v) => !afterSet.has(v));
359008
+ const added = after.filter((v) => !beforeSet.has(v));
359009
+ log2(` assertStateDelta key=${key} expect=${expect} removed=${removed.length} added=${added.length}`);
359010
+ const exp = expect.toLowerCase().trim();
359011
+ if (exp === "changed" || exp === "any_change") {
359012
+ if (removed.length === 0 && added.length === 0) {
359013
+ throw new Error(`assertStateDelta(${key}): expected state to change, but capture is identical (${before.length} values)`);
359014
+ }
359015
+ return;
359016
+ }
359017
+ if (exp === "removed" || exp === "entity_removed" || exp.startsWith("removed:")) {
359018
+ const target = exp.startsWith("removed:") ? expect.slice("removed:".length).trim() : "";
359019
+ if (target) {
359020
+ if (!removed.includes(target) && afterSet.has(target)) {
359021
+ throw new Error(`assertStateDelta(${key}): expected "${target}" removed, but it is still present`);
359022
+ }
359023
+ if (!beforeSet.has(target)) {
359024
+ throw new Error(`assertStateDelta(${key}): expected "${target}" to have been in the prior capture`);
359025
+ }
359026
+ if (afterSet.has(target)) {
359027
+ throw new Error(`assertStateDelta(${key}): "${target}" was not removed`);
359028
+ }
359029
+ return;
359030
+ }
359031
+ if (removed.length === 0) {
359032
+ throw new Error(`assertStateDelta(${key}): expected at least one entity removed`);
359033
+ }
359034
+ return;
359035
+ }
359036
+ if (exp === "count_decreased" || exp === "count-1") {
359037
+ const b = before.map(Number).filter((n) => Number.isFinite(n));
359038
+ const a = after.map(Number).filter((n) => Number.isFinite(n));
359039
+ const bMax = b.length ? Math.max(...b) : NaN;
359040
+ const aMax = a.length ? Math.max(...a) : NaN;
359041
+ if (!Number.isFinite(bMax) || !Number.isFinite(aMax) || aMax >= bMax) {
359042
+ throw new Error(`assertStateDelta(${key}): expected count decrease (before max=${bMax}, after max=${aMax})`);
359043
+ }
359044
+ return;
359045
+ }
359046
+ if (exp === "count_increased" || exp === "count+1") {
359047
+ const b = before.map(Number).filter((n) => Number.isFinite(n));
359048
+ const a = after.map(Number).filter((n) => Number.isFinite(n));
359049
+ const bMax = b.length ? Math.max(...b) : NaN;
359050
+ const aMax = a.length ? Math.max(...a) : NaN;
359051
+ if (!Number.isFinite(bMax) || !Number.isFinite(aMax) || aMax <= bMax) {
359052
+ throw new Error(`assertStateDelta(${key}): expected count increase (before max=${bMax}, after max=${aMax})`);
359053
+ }
359054
+ return;
359055
+ }
359056
+ if (exp === "unchanged" || exp === "persisted") {
359057
+ if (removed.length > 0 || added.length > 0) {
359058
+ throw new Error(
359059
+ `assertStateDelta(${key}): expected persistence/unchanged, but removed=[${removed.slice(0, 5).join(", ")}] added=[${added.slice(0, 5).join(", ")}]`
359060
+ );
359061
+ }
359062
+ return;
359063
+ }
359064
+ if (removed.length === 0 && added.length === 0) {
359065
+ throw new Error(`assertStateDelta(${key}): expected change (expect=${expect}), state identical`);
359066
+ }
359067
+ }
357585
359068
  async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
357586
359069
  let boundary = null;
357587
359070
  try {
@@ -358727,6 +360210,7 @@ async function executeMobileTest(options) {
358727
360210
  }
358728
360211
  const stepResults = [];
358729
360212
  const screenshots = [];
360213
+ const stateBag = /* @__PURE__ */ new Map();
358730
360214
  let apiCalls = [];
358731
360215
  let sessionId = null;
358732
360216
  let selectedDevice;
@@ -358937,6 +360421,9 @@ async function executeMobileTest(options) {
358937
360421
  if (clearedAppDataBeforeLaunch) {
358938
360422
  await waitForColdBootContent(sessionId, log2);
358939
360423
  }
360424
+ if (options.target.launchMode !== "RESUME") {
360425
+ await dismissSystemUiAfterColdBoot(sessionId, options.target.platform, log2);
360426
+ }
358940
360427
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
358941
360428
  try {
358942
360429
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -359031,6 +360518,25 @@ async function executeMobileTest(options) {
359031
360518
  case "assertText":
359032
360519
  await executeAssertText(sessionId, step, runPlatform, isCrossPlatform);
359033
360520
  break;
360521
+ case "captureState": {
360522
+ const key = String(step.metadata?.stateKey ?? step.target?.primary ?? `state-${step.order}`);
360523
+ const kind = String(step.metadata?.stateKind ?? "list_ids");
360524
+ const values = await captureMobileStateValues(sessionId, kind);
360525
+ stateBag.set(key, { kind, values, capturedAt: Date.now() });
360526
+ log2(` captureState key=${key} kind=${kind} n=${values.length}`);
360527
+ break;
360528
+ }
360529
+ case "assertStateDelta": {
360530
+ const key = String(step.metadata?.stateKey ?? step.target?.primary ?? "");
360531
+ const expect = String(step.metadata?.expect ?? step.assertion ?? "changed");
360532
+ const prior = stateBag.get(key);
360533
+ if (!prior) {
360534
+ throw new Error(`assertStateDelta: no prior captureState for key "${key}"`);
360535
+ }
360536
+ const current = await captureMobileStateValues(sessionId, prior.kind);
360537
+ assertMobileStateDelta(prior.values, current, expect, key, log2);
360538
+ break;
360539
+ }
359034
360540
  default: {
359035
360541
  const _exhaustive = step.action;
359036
360542
  status = "failed";
@@ -359209,6 +360715,10 @@ async function createMobileDiscoverySession(target, options) {
359209
360715
  );
359210
360716
  }
359211
360717
  }
360718
+ try {
360719
+ await dismissBlockingSystemUi(`/session/${sessionId}`, target.platform);
360720
+ } catch {
360721
+ }
359212
360722
  let stopped = false;
359213
360723
  return {
359214
360724
  sessionId,
@@ -359346,6 +360856,8 @@ async function driveHarvestStep(sessionId, step, elementId, platform3) {
359346
360856
  case "waitForElement":
359347
360857
  case "assertVisible":
359348
360858
  case "assertText":
360859
+ case "captureState":
360860
+ case "assertStateDelta":
359349
360861
  return true;
359350
360862
  }
359351
360863
  }
@@ -359379,6 +360891,7 @@ async function harvestPlatformLocators(options) {
359379
360891
  try {
359380
360892
  options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
359381
360893
  await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
360894
+ await dismissSystemUiAfterColdBoot(session.sessionId, platform3, log2);
359382
360895
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
359383
360896
  await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
359384
360897
  }
@@ -359654,7 +361167,9 @@ function decideMobileDeviceGate(input) {
359654
361167
  }
359655
361168
  }
359656
361169
  const isHealForHeldTest = runMode === "heal" && !!testCaseId && healHoldTestCaseIds.has(testCaseId);
359657
- const reservedForHeals = isHealForHeldTest ? Math.max(0, healHoldTestCaseIds.size - 1) : healHoldTestCaseIds.size;
361170
+ const rawReservedForHeals = isHealForHeldTest ? Math.max(0, healHoldTestCaseIds.size - 1) : healHoldTestCaseIds.size;
361171
+ const maxHealReserve = capacity <= 1 ? capacity : Math.max(1, capacity - 1);
361172
+ const reservedForHeals = Math.min(rawReservedForHeals, maxHealReserve);
359658
361173
  if (activeMobileOps + reservedForHeals >= capacity) {
359659
361174
  const reason = reservedForHeals > 0 ? `capacity reserved for pending auto-heal: ${activeMobileOps}+${reservedForHeals}/${capacity}` : `mobile capacity reached: ${activeMobileOps}/${capacity} in use`;
359660
361175
  return { claim: false, disposition: "defer", reason };
@@ -361490,6 +363005,11 @@ var MobileBridgeDriver = class {
361490
363005
  } catch (err) {
361491
363006
  if (isRecoverableAppiumSessionFailure(err)) throw err;
361492
363007
  }
363008
+ try {
363009
+ await dismissBlockingSystemUi(this.sessionPath, this.platform);
363010
+ } catch (err) {
363011
+ if (isRecoverableAppiumSessionFailure(err) || isAppiumDriverDeadError(err)) throw err;
363012
+ }
361493
363013
  });
361494
363014
  }
361495
363015
  /**
@@ -361527,6 +363047,11 @@ var MobileBridgeDriver = class {
361527
363047
  } catch (err) {
361528
363048
  if (isRecoverableAppiumSessionFailure(err)) throw err;
361529
363049
  }
363050
+ try {
363051
+ await dismissBlockingSystemUi(this.sessionPath, this.platform);
363052
+ } catch (err) {
363053
+ if (isRecoverableAppiumSessionFailure(err) || isAppiumDriverDeadError(err)) throw err;
363054
+ }
361530
363055
  return true;
361531
363056
  } catch (err) {
361532
363057
  if (isRecoverableAppiumSessionFailure(err)) throw err;
@@ -364886,6 +366411,17 @@ ${finalVerifyResult.logs ?? ""}`;
364886
366411
  }
364887
366412
  };
364888
366413
  log.info("\u{1FA7A} Mobile AI heal \u2014 Phase 1 (script) \u2192 Phase 2 (live device, lazy)...");
366414
+ const tagList = Array.isArray(run2.tags) ? run2.tags : [];
366415
+ const failureCount = Math.max(
366416
+ 1,
366417
+ typeof run2.consecutiveFails === "number" && run2.consecutiveFails > 0 ? run2.consecutiveFails : tagList.includes("@bug-found") ? 2 : 1
366418
+ );
366419
+ const reproduceApiCalls = Array.isArray(nativeVerify.last?.apiCalls) ? nativeVerify.last.apiCalls.map((c) => ({
366420
+ method: typeof c.method === "string" ? c.method : void 0,
366421
+ url: typeof c.url === "string" ? c.url : void 0,
366422
+ status: typeof c.status === "number" ? c.status : void 0
366423
+ })) : void 0;
366424
+ const uiPathSucceeded = Array.isArray(reproduce.stepResults) && reproduce.stepResults.filter((s) => s.status === "passed").length >= Math.max(1, reproduce.stepResults.length - 1);
364889
366425
  const heal = await (0, import_runner_core14.executeMobileTestWithHealing)(
364890
366426
  mobileSteps,
364891
366427
  healTarget,
@@ -364894,7 +366430,11 @@ ${finalVerifyResult.logs ?? ""}`;
364894
366430
  logs: reproduce.logs,
364895
366431
  failingSource: reproduce.failingSource,
364896
366432
  testVariables: run2.testVariables,
364897
- testName: run2.testName
366433
+ testName: run2.testName,
366434
+ tags: tagList,
366435
+ failureCount,
366436
+ apiCalls: reproduceApiCalls,
366437
+ uiPathSucceeded
364898
366438
  },
364899
366439
  healerOptions
364900
366440
  );
@@ -364903,7 +366443,8 @@ ${finalVerifyResult.logs ?? ""}`;
364903
366443
  heal,
364904
366444
  finalError: finalRes?.error ?? null,
364905
366445
  reproduceError,
364906
- testVariables: run2.testVariables
366446
+ testVariables: run2.testVariables,
366447
+ originalSteps: mobileSteps
364907
366448
  });
364908
366449
  await postResult(opts.serverUrl, headers, run2.runId, {
364909
366450
  status: outcome.status,