@validate.qa/runner 1.0.38 → 1.0.40

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 +1233 -711
  2. package/dist/cli.mjs +1233 -711
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -270,7 +270,8 @@ var require_ai_provider = __commonJS({
270
270
  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "anthropic", description: "Anthropic - fast & intelligent, 200k ctx", contextWindow: 2e5 },
271
271
  { id: "nvidia-nemotron-nano", label: "NVIDIA Nemotron Nano", provider: "nvidia", description: "NVIDIA NIM - Nemotron 30B Nano, reasoning model, 128k ctx", contextWindow: 128e3 },
272
272
  { id: "nvidia-kimi-k2.6", label: "NVIDIA Kimi K2.6", provider: "nvidia", description: "NVIDIA NIM - Moonshot Kimi K2.6, multimodal coding and agentic model, 256k ctx", contextWindow: 256e3 },
273
- { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
273
+ { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 },
274
+ { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro", provider: "deepseek", description: "DeepSeek - V4 Pro, higher-quality reasoning tier, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
274
275
  ];
275
276
  var PROVIDER_DEFAULT_MODEL = {
276
277
  openai: "gpt-4o",
@@ -330,7 +331,8 @@ var require_ai_provider = __commonJS({
330
331
  "claude-sonnet-4-6": { strong: "claude-sonnet-4-6", fast: "claude-sonnet-4-6", agent: "claude-sonnet-4-6" },
331
332
  "nvidia-nemotron-nano": { strong: "nvidia/nemotron-3-nano-30b-a3b", fast: "nvidia/nemotron-3-nano-30b-a3b", agent: "nvidia/nemotron-3-nano-30b-a3b" },
332
333
  "nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" },
333
- "deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" }
334
+ "deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" },
335
+ "deepseek-v4-pro": { strong: "deepseek-v4-pro", fast: "deepseek-v4-pro", agent: "deepseek-v4-pro" }
334
336
  };
335
337
  var _requestModelStorage = new async_hooks_1.AsyncLocalStorage();
336
338
  function withModelContext(modelId, fn) {
@@ -392,7 +394,7 @@ var require_ai_provider = __commonJS({
392
394
  if (modelId === "nvidia/nemotron-3-nano-30b-a3b") {
393
395
  return { chat_template_kwargs: { enable_thinking: false } };
394
396
  }
395
- if (modelId === "deepseek-v4-flash") {
397
+ if (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro") {
396
398
  return { thinking: { type: "disabled" } };
397
399
  }
398
400
  return {};
@@ -3302,6 +3304,7 @@ var init_audit_pricing = __esm({
3302
3304
  "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4 },
3303
3305
  "MiniMax-M2": { input: 0.3, output: 1.2 },
3304
3306
  "deepseek-v4-flash": { input: 0.14, output: 0.28 },
3307
+ "deepseek-v4-pro": { input: 0.42, output: 0.84 },
3305
3308
  // Whisper bills per-minute, not per-token (OpenAI + Groq)
3306
3309
  "whisper-1": null,
3307
3310
  "whisper-large-v3": null,
@@ -5879,6 +5882,34 @@ var require_ai_queue = __commonJS({
5879
5882
  }
5880
5883
  });
5881
5884
 
5885
+ // ../runner-core/dist/services/mobile/mobile-session-keepalive.js
5886
+ var require_mobile_session_keepalive = __commonJS({
5887
+ "../runner-core/dist/services/mobile/mobile-session-keepalive.js"(exports2) {
5888
+ "use strict";
5889
+ Object.defineProperty(exports2, "__esModule", { value: true });
5890
+ exports2.MOBILE_KEEPALIVE_INTERVAL_MS = void 0;
5891
+ exports2.withMobileSessionKeepAlive = withMobileSessionKeepAlive;
5892
+ exports2.MOBILE_KEEPALIVE_INTERVAL_MS = (() => {
5893
+ const raw = Number(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS);
5894
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 12e4;
5895
+ })();
5896
+ async function withMobileSessionKeepAlive(driver, fn, intervalMs = exports2.MOBILE_KEEPALIVE_INTERVAL_MS) {
5897
+ if (intervalMs <= 0)
5898
+ return fn();
5899
+ const timer = setInterval(() => {
5900
+ void driver.windowSize().catch(() => {
5901
+ });
5902
+ }, intervalMs);
5903
+ timer.unref?.();
5904
+ try {
5905
+ return await fn();
5906
+ } finally {
5907
+ clearInterval(timer);
5908
+ }
5909
+ }
5910
+ }
5911
+ });
5912
+
5882
5913
  // ../runner-core/dist/services/mobile/mobile-observation.js
5883
5914
  var require_mobile_observation = __commonJS({
5884
5915
  "../runner-core/dist/services/mobile/mobile-observation.js"(exports2) {
@@ -6213,6 +6244,689 @@ var require_mobile_observation = __commonJS({
6213
6244
  }
6214
6245
  });
6215
6246
 
6247
+ // ../runner-core/dist/services/mobile/mobile-exploration-state.js
6248
+ var require_mobile_exploration_state = __commonJS({
6249
+ "../runner-core/dist/services/mobile/mobile-exploration-state.js"(exports2) {
6250
+ "use strict";
6251
+ Object.defineProperty(exports2, "__esModule", { value: true });
6252
+ exports2.MobileExplorationState = void 0;
6253
+ exports2.isDestructiveActionLabel = isDestructiveActionLabel;
6254
+ exports2.isDestructiveConfirmationInput = isDestructiveConfirmationInput;
6255
+ exports2.stableMobileControlIdentity = stableMobileControlIdentity;
6256
+ exports2.classifySafeMobileNavigationControl = classifySafeMobileNavigationControl;
6257
+ exports2.deriveScreenId = deriveScreenId;
6258
+ var mobile_observation_js_1 = require_mobile_observation();
6259
+ var DEFAULT_STALE_THRESHOLD = 4;
6260
+ var MAX_SCREEN_OBSERVATIONS = 50;
6261
+ var EDITABLE_CONTROL_ROLE_RE = /(?:android\.widget\.(?:EditText|AutoCompleteTextView)|XCUIElementType(?:TextField|SecureTextField|SearchField|TextView)|textbox|searchbox|combobox|textarea|select)$/i;
6262
+ var STATEFUL_CONTROL_ROLE_RE = /(?:Switch|CheckBox|Checkbox|RadioButton|radio|Slider|SeekBar|Stepper|PickerWheel|Toggle)$/i;
6263
+ var UNSAFE_OR_COMMIT_LABEL_RE = /\b(?:submit|save|confirm|delete|remove|archive|reset|disconnect|deactivate|destroy|purge|pay|purchase|buy|checkout|place\s+order|send|post|publish|upload|import|export|sign\s*out|log\s*out|logout|accept|agree|subscribe|unsubscribe|follow|unfollow|enable|disable|book|reserve|cancel|continue|next|previous|done|apply|retry|close|dismiss|sign\s*in|log\s*in|register)\b/i;
6264
+ var SAFE_MODAL_TRIGGER_LABEL_RE = /^(?:create|new|add|edit|invite|configure|manage|details|preview|share)$/i;
6265
+ var MENU_TRIGGER_LABEL_RE = /^(?:more|menu|options|actions|filter|filters|sort|columns|customize)$/i;
6266
+ var GENERIC_NAVIGATION_DESTINATION_LABEL_RE = /^(?:home|feed|dashboard|profile|settings|inbox|messages?|notifications?|projects?|about|privacy|terms|learn\s+more)$/i;
6267
+ var SYSTEM_BACK_CONTROL_LABEL_RE = /^(?:back|go\s+back|navigate\s+up|navigation\s+up|up)$/i;
6268
+ var SYSTEM_BACK_CONTROL_ID_RE = /^(?:action_bar_up|arrow_back|back_arrow|navigate_up|navigation_up|nav_up|toolbar_back|action_back|btn_back|button_back|back_button|back_btn|back_icon|icon_back)$/i;
6269
+ var DESTRUCTIVE_ACTION_LABEL_RE = /\b(?:deactivate|delete\s+account|delete\s+profile|close\s+account|cancel\s+account|remove\s+account|delete\s+(?:my\s+)?(?:data|history|everything|account)|delete\s+(?:all|forever|permanently)|permanently\s+(?:delete|remove|erase|deactivate)|erase\s+(?:all|everything|account|data)|wipe\s+(?:account|data|everything|all|device)|purge\s+(?:account|data|everything|all)|destroy\s+(?:account|data)|factory\s+reset|reset\s+(?:all\s+data|everything))\b/i;
6270
+ var DESTRUCTIVE_CONFIRMATION_INPUT_RE = /^(?:delete|delete\s+my\s+account|delete\s+account|delete\s+forever|permanently\s+delete|confirm\s+delete|erase|erase\s+all|wipe|deactivate|remove\s+account|close\s+account|yes,?\s*delete(?:\s+my\s+account)?)$/i;
6271
+ function isDestructiveActionLabel(label) {
6272
+ if (!label)
6273
+ return false;
6274
+ return DESTRUCTIVE_ACTION_LABEL_RE.test(normalizeControlIdentityPart(label));
6275
+ }
6276
+ function isDestructiveConfirmationInput(value) {
6277
+ if (!value)
6278
+ return false;
6279
+ return DESTRUCTIVE_CONFIRMATION_INPUT_RE.test(value.trim());
6280
+ }
6281
+ function normalizeControlIdentityPart(value) {
6282
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
6283
+ }
6284
+ function semanticControlAlias(label) {
6285
+ return `navigation-label:${normalizeControlIdentityPart(label)}`;
6286
+ }
6287
+ function localControlCoverageIdentity(screenId, identity) {
6288
+ return `screen:${screenId}:${identity}`;
6289
+ }
6290
+ function storedMobileControlIdentity(candidate) {
6291
+ return [
6292
+ candidate.identity,
6293
+ `kind:${candidate.kind}`,
6294
+ `label:${normalizeControlIdentityPart(candidate.label)}`
6295
+ ].join(":");
6296
+ }
6297
+ function isSystemBackNavigationControl(element, label) {
6298
+ if (SYSTEM_BACK_CONTROL_LABEL_RE.test(normalizeControlIdentityPart(label)))
6299
+ return true;
6300
+ return [element.accessibilityId, element.resourceId].filter((value) => Boolean(value?.trim())).some((value) => {
6301
+ const leaf = normalizeControlIdentityPart(value).split(/[/:.]/).filter(Boolean).at(-1) ?? "";
6302
+ return SYSTEM_BACK_CONTROL_ID_RE.test(leaf);
6303
+ });
6304
+ }
6305
+ function stableMobileControlIdentity(element) {
6306
+ const role = normalizeControlIdentityPart(element.role || "control");
6307
+ const accessibilityId = element.accessibilityId?.trim();
6308
+ if (accessibilityId)
6309
+ return `a11y:${role}:${normalizeControlIdentityPart(accessibilityId)}`;
6310
+ const resourceId = element.resourceId?.trim();
6311
+ if (resourceId)
6312
+ return `resource:${role}:${normalizeControlIdentityPart(resourceId)}`;
6313
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6314
+ if (label)
6315
+ return `semantic:${role}:${normalizeControlIdentityPart(label)}`;
6316
+ return null;
6317
+ }
6318
+ function classifySafeMobileNavigationControl(element) {
6319
+ if (!element.actionable || element.enabled === false)
6320
+ return null;
6321
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6322
+ const safetyIdentity = [label, element.accessibilityId, element.resourceId].filter(Boolean).join(" ");
6323
+ if (!label || UNSAFE_OR_COMMIT_LABEL_RE.test(safetyIdentity))
6324
+ return null;
6325
+ if (isSystemBackNavigationControl(element, label))
6326
+ return null;
6327
+ const role = element.role.trim();
6328
+ if (EDITABLE_CONTROL_ROLE_RE.test(role) || STATEFUL_CONTROL_ROLE_RE.test(role))
6329
+ return null;
6330
+ const linkLike = /(?:Link$|^a$)/i.test(role);
6331
+ let kind = null;
6332
+ if (/(?:TabBarItem|\btab\b)/i.test(role))
6333
+ kind = "tab";
6334
+ else if (/menuitem/i.test(role))
6335
+ kind = "menu-item";
6336
+ else if (linkLike && element.navigationProvenance)
6337
+ kind = "link";
6338
+ else if (linkLike)
6339
+ return null;
6340
+ else if (element.hasPopup && element.hasPopup.toLowerCase() !== "false")
6341
+ kind = "menu-trigger";
6342
+ else if (element.expanded === false)
6343
+ kind = "disclosure";
6344
+ else if (MENU_TRIGGER_LABEL_RE.test(label))
6345
+ kind = "menu-trigger";
6346
+ else if (SAFE_MODAL_TRIGGER_LABEL_RE.test(label))
6347
+ kind = "modal-trigger";
6348
+ else if (element.navigationProvenance)
6349
+ kind = "navigation-button";
6350
+ else if (GENERIC_NAVIGATION_DESTINATION_LABEL_RE.test(label))
6351
+ kind = "navigation-button";
6352
+ if (!kind)
6353
+ return null;
6354
+ const identity = stableMobileControlIdentity(element);
6355
+ return identity ? { identity, label, kind } : null;
6356
+ }
6357
+ function sharedMobileControlCoverageIdentity(element, candidate, semanticFamilyId) {
6358
+ if (!element.navigationProvenance)
6359
+ return void 0;
6360
+ const durableId = element.accessibilityId?.trim() ? `a11y:${normalizeControlIdentityPart(element.accessibilityId)}` : element.resourceId?.trim() ? `resource:${normalizeControlIdentityPart(element.resourceId)}` : "";
6361
+ if (!durableId && !semanticFamilyId)
6362
+ return void 0;
6363
+ return [
6364
+ "shared-navigation",
6365
+ element.navigationProvenance,
6366
+ !durableId && semanticFamilyId ? `family:${semanticFamilyId}` : "",
6367
+ candidate.kind,
6368
+ durableId || "semantic-only",
6369
+ normalizeControlIdentityPart(candidate.label)
6370
+ ].filter(Boolean).join(":");
6371
+ }
6372
+ function mobileNavigationRegion(element, windowSize) {
6373
+ if (!windowSize || windowSize.width <= 0 || windowSize.height <= 0)
6374
+ return "unknown";
6375
+ const centerX = (element.bounds.x + element.bounds.width / 2) / windowSize.width;
6376
+ const centerY = (element.bounds.y + element.bounds.height / 2) / windowSize.height;
6377
+ if (centerY >= 0.72)
6378
+ return "bottom";
6379
+ if (centerY <= 0.28)
6380
+ return "top";
6381
+ if (centerX <= 0.35)
6382
+ return "left";
6383
+ if (centerX >= 0.65)
6384
+ return "right";
6385
+ return "center";
6386
+ }
6387
+ function buildSemanticNavigationGroups(elements, windowSize) {
6388
+ const groups = /* @__PURE__ */ new Map();
6389
+ for (const element of elements) {
6390
+ if (!element.navigationProvenance)
6391
+ continue;
6392
+ const candidate = classifySafeMobileNavigationControl(element);
6393
+ if (!candidate)
6394
+ continue;
6395
+ const groupKey = `${element.navigationProvenance}:${mobileNavigationRegion(element, windowSize)}`;
6396
+ const labels = groups.get(groupKey) ?? /* @__PURE__ */ new Set();
6397
+ labels.add(`${candidate.kind}:${normalizeControlIdentityPart(candidate.label)}`);
6398
+ groups.set(groupKey, labels);
6399
+ }
6400
+ return new Map([...groups].filter(([, labels]) => labels.size >= 2));
6401
+ }
6402
+ function deriveScreenId(signal) {
6403
+ const namespace = signal.activity?.trim() || signal.bundleId?.trim();
6404
+ return namespace ? `${namespace}#${signal.screenHash}` : signal.screenHash;
6405
+ }
6406
+ function observedElementIdentity(element) {
6407
+ return (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(element);
6408
+ }
6409
+ function mergeObservedElements(previous, current) {
6410
+ const merged = [...current];
6411
+ const seen = new Set(current.map(observedElementIdentity));
6412
+ for (const element of previous) {
6413
+ const identity = observedElementIdentity(element);
6414
+ if (seen.has(identity))
6415
+ continue;
6416
+ seen.add(identity);
6417
+ merged.push(element);
6418
+ }
6419
+ return merged;
6420
+ }
6421
+ function sanitizeElementsForEvidence(elements) {
6422
+ return elements.map((element) => {
6423
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element);
6424
+ return {
6425
+ ...element,
6426
+ ...label ? { label } : { label: void 0 },
6427
+ text: void 0
6428
+ };
6429
+ });
6430
+ }
6431
+ var MobileExplorationState = class {
6432
+ /** screenId -> ScreenState (the mobile analog of routeState). */
6433
+ screens = /* @__PURE__ */ new Map();
6434
+ /** Observed directed transitions between screens. */
6435
+ transitions = [];
6436
+ /** Transition-announced screens that do not yet have a live observation. */
6437
+ frontier = /* @__PURE__ */ new Map();
6438
+ /** Safe navigation controls observed on each screen, keyed by stable identity. */
6439
+ safeControlsByScreen = /* @__PURE__ */ new Map();
6440
+ /** Screen-scoped coverage keys plus explicitly proven shared-navigation keys. */
6441
+ attemptedSafeControlCoverageIdentities = /* @__PURE__ */ new Set();
6442
+ successfulSafeControlAttemptCount = 0;
6443
+ /** Repeated id-less structural navigation families, grouped by region. */
6444
+ semanticNavigationFamilies = /* @__PURE__ */ new Map();
6445
+ nextSemanticNavigationFamilyId = 1;
6446
+ /** The screen-id of the most recently observed screen. */
6447
+ currentScreenId = null;
6448
+ /** Latest iteration recorded (drives stale detection + firstSeen stamps). */
6449
+ iteration = 0;
6450
+ /** Stale detector: progress mark (#screens + #total elements) at last advance. */
6451
+ lastProgressMark = 0;
6452
+ /** Consecutive iterations observed with no progress. */
6453
+ staleIterations = 0;
6454
+ staleThreshold;
6455
+ constructor(options = {}) {
6456
+ this.staleThreshold = typeof options.staleThreshold === "number" && options.staleThreshold > 0 ? Math.floor(options.staleThreshold) : DEFAULT_STALE_THRESHOLD;
6457
+ }
6458
+ // -------------------------------------------------------------------------
6459
+ // Observation recording
6460
+ // -------------------------------------------------------------------------
6461
+ /**
6462
+ * Returns true when `signal` identifies a screen this run has never observed
6463
+ * before. Pure read — does not mutate state.
6464
+ */
6465
+ isNewScreen(signal) {
6466
+ return !this.screens.has(deriveScreenId(signal));
6467
+ }
6468
+ /**
6469
+ * Record an observation of `screen` at `iteration`. Creates a `ScreenState`
6470
+ * on first sight (stamping `firstSeenIteration`), otherwise refreshes the
6471
+ * stored element set (latest-wins, mirroring the web reducer's PAGE_CAPTURED
6472
+ * element refresh). A driver observation already contains the structural UI
6473
+ * tree, elements, screenshot association, and screen identity, so it is
6474
+ * captured evidence immediately; the model's capture_screen call only adds
6475
+ * a friendly name/type. Returns the resolved screen-id.
6476
+ */
6477
+ recordObservation(signal, screen, iteration) {
6478
+ this.iteration = iteration;
6479
+ const screenId = deriveScreenId(signal);
6480
+ const evidenceElements = sanitizeElementsForEvidence(screen.elements);
6481
+ const existing = this.screens.get(screenId);
6482
+ if (existing) {
6483
+ this.screens.set(screenId, {
6484
+ ...existing,
6485
+ elements: mergeObservedElements(existing.elements, evidenceElements)
6486
+ });
6487
+ } else {
6488
+ this.screens.set(screenId, {
6489
+ screenId,
6490
+ firstSeenIteration: iteration,
6491
+ elements: evidenceElements,
6492
+ visited: true
6493
+ });
6494
+ }
6495
+ this.frontier.delete(screenId);
6496
+ const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
6497
+ const semanticNavigationGroups = buildSemanticNavigationGroups(evidenceElements, screen.windowSize);
6498
+ const semanticNavigationFamilyIds = /* @__PURE__ */ new Map();
6499
+ for (const [groupKey, labels] of semanticNavigationGroups) {
6500
+ semanticNavigationFamilyIds.set(groupKey, this.resolveSemanticNavigationFamilyId(groupKey, labels));
6501
+ }
6502
+ for (const element of evidenceElements) {
6503
+ const candidate = classifySafeMobileNavigationControl(element);
6504
+ if (!candidate)
6505
+ continue;
6506
+ const storedIdentity = storedMobileControlIdentity(candidate);
6507
+ const semanticGroupKey = element.navigationProvenance ? `${element.navigationProvenance}:${mobileNavigationRegion(element, screen.windowSize)}` : "";
6508
+ controls.set(storedIdentity, {
6509
+ ...candidate,
6510
+ identity: storedIdentity,
6511
+ sharedCoverageIdentity: sharedMobileControlCoverageIdentity(element, candidate, semanticNavigationFamilyIds.get(semanticGroupKey))
6512
+ });
6513
+ }
6514
+ if (controls.size > 0)
6515
+ this.safeControlsByScreen.set(screenId, controls);
6516
+ this.currentScreenId = screenId;
6517
+ this.updateStaleProgress();
6518
+ return screenId;
6519
+ }
6520
+ /**
6521
+ * Back-compatible acknowledgement from capture_screen. Observation already
6522
+ * marks structural evidence visited; this call only resolves a rare pending
6523
+ * transition frontier and preserves callers that annotate explicitly.
6524
+ */
6525
+ markVisited(screenId) {
6526
+ const state2 = this.screens.get(screenId);
6527
+ if (state2 && !state2.visited) {
6528
+ this.screens.set(screenId, { ...state2, visited: true });
6529
+ }
6530
+ this.frontier.delete(screenId);
6531
+ }
6532
+ /**
6533
+ * Record a confirmed tap against a safe navigation candidate. Callers invoke
6534
+ * this only after the driver reports success; recovery-unknown, stale-ref, and
6535
+ * failed actions must remain on the breadth frontier.
6536
+ */
6537
+ noteControlAttempt(screenId, element) {
6538
+ if (!screenId)
6539
+ return false;
6540
+ const candidate = classifySafeMobileNavigationControl(element);
6541
+ const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
6542
+ if (candidate) {
6543
+ const storedIdentity = storedMobileControlIdentity(candidate);
6544
+ const existing = controls.get(storedIdentity);
6545
+ controls.set(storedIdentity, {
6546
+ ...candidate,
6547
+ identity: storedIdentity,
6548
+ sharedCoverageIdentity: existing?.sharedCoverageIdentity ?? sharedMobileControlCoverageIdentity(element, candidate)
6549
+ });
6550
+ this.safeControlsByScreen.set(screenId, controls);
6551
+ }
6552
+ const tappedLabel = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6553
+ if (!tappedLabel)
6554
+ return false;
6555
+ const before = this.attemptedSafeControlCoverageIdentities.size;
6556
+ const alias = semanticControlAlias(tappedLabel);
6557
+ for (const control of controls.values()) {
6558
+ if (semanticControlAlias(control.label) === alias) {
6559
+ this.attemptedSafeControlCoverageIdentities.add(localControlCoverageIdentity(screenId, control.identity));
6560
+ if (control.sharedCoverageIdentity) {
6561
+ this.attemptedSafeControlCoverageIdentities.add(control.sharedCoverageIdentity);
6562
+ }
6563
+ }
6564
+ }
6565
+ if (this.attemptedSafeControlCoverageIdentities.size > before) {
6566
+ this.successfulSafeControlAttemptCount += 1;
6567
+ this.staleIterations = 0;
6568
+ return true;
6569
+ }
6570
+ return false;
6571
+ }
6572
+ /** Attach a human-friendly name / coarse type to a screen (capture_screen). */
6573
+ annotateScreen(screenId, annotation) {
6574
+ const state2 = this.screens.get(screenId);
6575
+ if (!state2)
6576
+ return;
6577
+ this.screens.set(screenId, {
6578
+ ...state2,
6579
+ screenName: annotation.screenName ?? state2.screenName,
6580
+ screenType: annotation.screenType ?? state2.screenType
6581
+ });
6582
+ }
6583
+ /** Attach structurally observed authentication provenance to a screen. */
6584
+ setScreenAuthEvidence(screenId, evidence) {
6585
+ const state2 = this.screens.get(screenId);
6586
+ if (!state2)
6587
+ return;
6588
+ this.screens.set(screenId, {
6589
+ ...state2,
6590
+ requiresAuth: evidence.requiresAuth,
6591
+ authProvenance: evidence.authProvenance,
6592
+ sessionState: { ...evidence.sessionState }
6593
+ });
6594
+ }
6595
+ /** Append a free-form observation note to a screen (observations tool). */
6596
+ addScreenObservation(screenId, note) {
6597
+ const state2 = this.screens.get(screenId);
6598
+ if (!state2)
6599
+ return;
6600
+ const normalized = note.trim();
6601
+ if (!normalized)
6602
+ return;
6603
+ const current = state2.observations ?? [];
6604
+ if (current.some((existing) => existing.trim().toLowerCase() === normalized.toLowerCase())) {
6605
+ return;
6606
+ }
6607
+ const observations = [...current, normalized].slice(-MAX_SCREEN_OBSERVATIONS);
6608
+ this.screens.set(screenId, { ...state2, observations });
6609
+ }
6610
+ /**
6611
+ * Record a directed transition between two screens. Both endpoints are
6612
+ * ensured to exist on the frontier if not yet observed (mirrors the web
6613
+ * reducer adding both ends of a TRANSITION_RECORDED to the frontier). The
6614
+ * `toScreenId` becomes the current screen when the transition starts from the
6615
+ * current screen.
6616
+ */
6617
+ recordTransition(transition) {
6618
+ let recordedTransition = transition;
6619
+ if (!transition.viaControlIdentity && transition.viaRef) {
6620
+ const sourceElement = this.screens.get(transition.fromScreenId)?.elements.find((element) => element.ref === transition.viaRef);
6621
+ if (sourceElement) {
6622
+ recordedTransition = {
6623
+ ...transition,
6624
+ viaControlIdentity: (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(sourceElement)
6625
+ };
6626
+ }
6627
+ }
6628
+ const alreadyRecorded = this.transitions.some((existing) => existing.fromScreenId === recordedTransition.fromScreenId && existing.toScreenId === recordedTransition.toScreenId && existing.action === recordedTransition.action);
6629
+ if (!alreadyRecorded) {
6630
+ this.transitions.push(recordedTransition);
6631
+ }
6632
+ for (const endpointId of [transition.fromScreenId, transition.toScreenId]) {
6633
+ if (!this.screens.has(endpointId) && !this.frontier.has(endpointId)) {
6634
+ this.frontier.set(endpointId, {
6635
+ screenId: endpointId,
6636
+ source: "transition",
6637
+ discoveredAtIteration: this.iteration
6638
+ });
6639
+ }
6640
+ }
6641
+ if (transition.fromScreenId === this.currentScreenId) {
6642
+ this.currentScreenId = transition.toScreenId;
6643
+ }
6644
+ }
6645
+ // -------------------------------------------------------------------------
6646
+ // Selectors
6647
+ // -------------------------------------------------------------------------
6648
+ /** The current screen-id (most recently observed), or null before any observation. */
6649
+ getCurrentScreenId() {
6650
+ return this.currentScreenId;
6651
+ }
6652
+ /** ScreenState for `screenId`, if known. */
6653
+ getScreen(screenId) {
6654
+ return this.screens.get(screenId);
6655
+ }
6656
+ /** Number of distinct screens observed this run. */
6657
+ getScreenCount() {
6658
+ return this.screens.size;
6659
+ }
6660
+ /** Number of screens backed by a live structural driver observation. */
6661
+ getVisitedScreenCount() {
6662
+ let total = 0;
6663
+ for (const state2 of this.screens.values()) {
6664
+ if (state2.visited)
6665
+ total += 1;
6666
+ }
6667
+ return total;
6668
+ }
6669
+ /** Total elements observed across all known screens (latest snapshot each). */
6670
+ getTotalElementCount() {
6671
+ let total = 0;
6672
+ for (const state2 of this.screens.values()) {
6673
+ total += state2.elements.length;
6674
+ }
6675
+ return total;
6676
+ }
6677
+ /**
6678
+ * Frontier screen-ids ranked by recency (most recently discovered first),
6679
+ * excluding any screen already visited. Mirrors the web `getRankedFrontier`
6680
+ * intent (filter visited, rank by recency) — without URL prefix similarity,
6681
+ * which has no mobile analog.
6682
+ */
6683
+ getFrontier() {
6684
+ return [...this.frontier.values()].filter((item) => {
6685
+ const state2 = this.screens.get(item.screenId);
6686
+ return !state2 || !state2.visited;
6687
+ }).sort((a, b) => b.discoveredAtIteration - a.discoveredAtIteration).map((item) => item.screenId);
6688
+ }
6689
+ /** Safe navigation candidates not yet attempted, deduped across shared chrome. */
6690
+ getUnexploredSafeNavigationCandidates() {
6691
+ const authenticatedProductObserved = [...this.screens.values()].some((screen) => screen.requiresAuth === true);
6692
+ const candidates = [];
6693
+ for (const [screenId, controls] of this.safeControlsByScreen) {
6694
+ const origin = this.screens.get(screenId);
6695
+ const originIsConfirmedPublicEntry = origin?.authProvenance === "public" || !origin?.authProvenance && origin?.requiresAuth !== true && /^(?:AUTH|LANDING)$/i.test(origin?.screenType ?? "");
6696
+ if (authenticatedProductObserved && originIsConfirmedPublicEntry)
6697
+ continue;
6698
+ for (const control of controls.values()) {
6699
+ const alias = semanticControlAlias(control.label);
6700
+ const coverageIdentity = control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity);
6701
+ if (this.attemptedSafeControlCoverageIdentities.has(coverageIdentity) || this.attemptedSafeControlCoverageIdentities.has(localControlCoverageIdentity(screenId, control.identity)))
6702
+ continue;
6703
+ const existing = candidates.find((candidate) => candidate.identity === coverageIdentity || candidate.alias === alias && candidate.screenIds.has(screenId));
6704
+ if (existing) {
6705
+ existing.screenIds.add(screenId);
6706
+ } else {
6707
+ candidates.push({
6708
+ identity: coverageIdentity,
6709
+ label: control.label,
6710
+ kind: control.kind,
6711
+ alias,
6712
+ screenIds: /* @__PURE__ */ new Set([screenId])
6713
+ });
6714
+ }
6715
+ }
6716
+ }
6717
+ return candidates.map((candidate) => ({
6718
+ identity: candidate.identity,
6719
+ label: candidate.label,
6720
+ kind: candidate.kind,
6721
+ screenIds: [...candidate.screenIds]
6722
+ })).sort((a, b) => {
6723
+ const aCurrent = this.currentScreenId !== null && a.screenIds.includes(this.currentScreenId);
6724
+ const bCurrent = this.currentScreenId !== null && b.screenIds.includes(this.currentScreenId);
6725
+ if (aCurrent !== bCurrent)
6726
+ return aCurrent ? -1 : 1;
6727
+ return b.screenIds.length - a.screenIds.length;
6728
+ });
6729
+ }
6730
+ /** Harness-owned survey completion floor, independent of model assertions. */
6731
+ getSurveyBreadthCoverage() {
6732
+ const unvisitedScreenIds = this.getFrontier();
6733
+ const unexploredControls = this.getUnexploredSafeNavigationCandidates();
6734
+ const complete = unvisitedScreenIds.length === 0 && unexploredControls.length === 0;
6735
+ const missing = [];
6736
+ if (unvisitedScreenIds.length > 0) {
6737
+ missing.push(`${unvisitedScreenIds.length} uncaptured screen(s)`);
6738
+ }
6739
+ if (unexploredControls.length > 0) {
6740
+ missing.push(`${unexploredControls.length} safe navigation control(s)`);
6741
+ }
6742
+ return {
6743
+ required: true,
6744
+ complete,
6745
+ unvisitedScreenIds,
6746
+ unexploredControls,
6747
+ attemptedControlCount: this.successfulSafeControlAttemptCount,
6748
+ ...missing.length > 0 ? { reason: `Missing ${missing.join(" and ")}.` } : {}
6749
+ };
6750
+ }
6751
+ /**
6752
+ * Monotonic structural progress token for adaptive survey leases. Frontier
6753
+ * cardinality alone is insufficient: attempting control A can reveal control
6754
+ * B and leave the remaining count unchanged even though real work happened.
6755
+ */
6756
+ getSurveyBreadthProgressMark() {
6757
+ const discoveredControlIdentities = /* @__PURE__ */ new Set();
6758
+ for (const [screenId, controls] of this.safeControlsByScreen) {
6759
+ for (const control of controls.values()) {
6760
+ discoveredControlIdentities.add(control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity));
6761
+ }
6762
+ }
6763
+ return [
6764
+ `screens:${this.screens.size}`,
6765
+ `attempts:${this.successfulSafeControlAttemptCount}`,
6766
+ `controls:${discoveredControlIdentities.size}`,
6767
+ `transitions:${this.transitions.length}`
6768
+ ].join("|");
6769
+ }
6770
+ resolveSemanticNavigationFamilyId(groupKey, labels) {
6771
+ const families = this.semanticNavigationFamilies.get(groupKey) ?? [];
6772
+ let best;
6773
+ let bestIntersection = 0;
6774
+ for (const family of families) {
6775
+ let intersection = 0;
6776
+ for (const label of labels) {
6777
+ if (family.labels.has(label))
6778
+ intersection += 1;
6779
+ }
6780
+ const overlap = intersection / Math.min(labels.size, family.labels.size);
6781
+ if (intersection >= 2 && overlap >= 0.6 && intersection > bestIntersection) {
6782
+ best = family;
6783
+ bestIntersection = intersection;
6784
+ }
6785
+ }
6786
+ if (best) {
6787
+ for (const label of labels)
6788
+ best.labels.add(label);
6789
+ return best.id;
6790
+ }
6791
+ const created = {
6792
+ id: `semantic-nav-family-${this.nextSemanticNavigationFamilyId++}`,
6793
+ labels: new Set(labels)
6794
+ };
6795
+ families.push(created);
6796
+ this.semanticNavigationFamilies.set(groupKey, families);
6797
+ return created.id;
6798
+ }
6799
+ /** Observed transitions (defensive copy). */
6800
+ getTransitions() {
6801
+ return [...this.transitions];
6802
+ }
6803
+ // -------------------------------------------------------------------------
6804
+ // Stale-progress detection (ported semantics from the web stale guard)
6805
+ // -------------------------------------------------------------------------
6806
+ /**
6807
+ * Progress mark: distinct screens weighted heavily, plus total elements seen.
6808
+ * A run advances this mark whenever it finds a new screen OR new elements;
6809
+ * the stale detector triggers when the mark stops advancing for
6810
+ * `staleThreshold` consecutive iterations.
6811
+ */
6812
+ getProgressMark() {
6813
+ return this.screens.size * 1e3 + this.transitions.length * 100 + this.successfulSafeControlAttemptCount * 10 + this.getTotalElementCount();
6814
+ }
6815
+ updateStaleProgress() {
6816
+ const mark = this.getProgressMark();
6817
+ if (mark > this.lastProgressMark) {
6818
+ this.lastProgressMark = mark;
6819
+ this.staleIterations = 0;
6820
+ } else {
6821
+ this.staleIterations += 1;
6822
+ }
6823
+ }
6824
+ /** Consecutive no-progress iterations observed so far. */
6825
+ getStaleIterations() {
6826
+ return this.staleIterations;
6827
+ }
6828
+ /**
6829
+ * True when the run has gone `staleThreshold` consecutive iterations with no
6830
+ * new screen and no new element — the signal the explorer uses to nudge or
6831
+ * finish (mirrors the web explorer's stale guard).
6832
+ */
6833
+ isStaleProgress() {
6834
+ return this.staleIterations >= this.staleThreshold;
6835
+ }
6836
+ // -------------------------------------------------------------------------
6837
+ // Planner evidence export
6838
+ // -------------------------------------------------------------------------
6839
+ /**
6840
+ * Snapshot the screen-graph as a plain object (frozen mobile-types
6841
+ * `MobileScreenGraph`). Defensive copies so callers cannot mutate internal
6842
+ * state.
6843
+ */
6844
+ toScreenGraph() {
6845
+ const screens = /* @__PURE__ */ new Map();
6846
+ for (const [id, state2] of this.screens) {
6847
+ screens.set(id, {
6848
+ ...state2,
6849
+ elements: [...state2.elements],
6850
+ observations: state2.observations ? [...state2.observations] : void 0,
6851
+ sessionState: state2.sessionState ? { ...state2.sessionState } : void 0
6852
+ });
6853
+ }
6854
+ return {
6855
+ screens,
6856
+ transitions: this.transitions.map((t) => ({
6857
+ fromScreenId: t.fromScreenId,
6858
+ toScreenId: t.toScreenId,
6859
+ viaRef: t.viaRef,
6860
+ viaControlIdentity: t.viaControlIdentity,
6861
+ action: t.action
6862
+ }))
6863
+ };
6864
+ }
6865
+ /**
6866
+ * Build the planner evidence shape: one `CollectedPageData` per screen, with
6867
+ * the screen-id carried in BOTH `route` (the on-the-wire positional field the
6868
+ * server keys screenshots by) AND the explicit `screenId` field (per the
6869
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
6870
+ * the screen's parsed actionable elements into the platform-neutral
6871
+ * `CollectedPageElement[]` shape the server persists as SiteElements — without
6872
+ * this, mobile screens land with zero elements (coverage always 0%, no element
6873
+ * intelligence). `triggersNavigation` is derived from the durable source
6874
+ * element identity captured when the transition happened (with ref-only
6875
+ * fallback for legacy transitions). Insertion order matches first-seen order.
6876
+ */
6877
+ buildInMemoryScreenMap() {
6878
+ const pages = [];
6879
+ const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
6880
+ const navRefsByScreen = /* @__PURE__ */ new Map();
6881
+ const navIdentitiesByScreen = /* @__PURE__ */ new Map();
6882
+ for (const transition of this.transitions) {
6883
+ if (transition.viaControlIdentity) {
6884
+ const set = navIdentitiesByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
6885
+ set.add(transition.viaControlIdentity);
6886
+ navIdentitiesByScreen.set(transition.fromScreenId, set);
6887
+ } else if (transition.viaRef) {
6888
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
6889
+ set.add(transition.viaRef);
6890
+ navRefsByScreen.set(transition.fromScreenId, set);
6891
+ }
6892
+ }
6893
+ for (const state2 of ordered) {
6894
+ const page = {
6895
+ route: state2.screenId,
6896
+ screenId: state2.screenId,
6897
+ pageType: state2.screenType ?? "UNKNOWN",
6898
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
6899
+ navigationRefs: navRefsByScreen.get(state2.screenId),
6900
+ navigationIdentities: navIdentitiesByScreen.get(state2.screenId)
6901
+ }),
6902
+ requiresAuth: state2.requiresAuth ?? false
6903
+ };
6904
+ if (state2.sessionState) {
6905
+ page.sessionState = { ...state2.sessionState };
6906
+ }
6907
+ if (state2.screenName) {
6908
+ page.title = state2.screenName;
6909
+ }
6910
+ if (state2.observations && state2.observations.length > 0) {
6911
+ page.observations = state2.observations.map((note) => ({
6912
+ action: "observe",
6913
+ outcome: note,
6914
+ outcomeType: "SUCCESS"
6915
+ }));
6916
+ }
6917
+ pages.push(page);
6918
+ }
6919
+ return pages;
6920
+ }
6921
+ /** Alias for `buildInMemoryScreenMap()` — name parity with the planner-evidence call site. */
6922
+ toScreenMap() {
6923
+ return this.buildInMemoryScreenMap();
6924
+ }
6925
+ };
6926
+ exports2.MobileExplorationState = MobileExplorationState;
6927
+ }
6928
+ });
6929
+
6216
6930
  // ../runner-core/dist/services/mobile/mobile-keyboard.js
6217
6931
  var require_mobile_keyboard = __commonJS({
6218
6932
  "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports2) {
@@ -63929,8 +64643,10 @@ var require_mobile_explorer = __commonJS({
63929
64643
  var ai_provider_js_1 = require_ai_provider();
63930
64644
  var ai_queue_js_1 = require_ai_queue();
63931
64645
  var ai_retry_js_1 = require_ai_retry();
64646
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
63932
64647
  var mobile_source_parser_js_1 = require_mobile_source_parser();
63933
64648
  var mobile_observation_js_1 = require_mobile_observation();
64649
+ var mobile_exploration_state_js_1 = require_mobile_exploration_state();
63934
64650
  var mobile_boundary_js_1 = require_mobile_boundary();
63935
64651
  var mobile_keyboard_js_1 = require_mobile_keyboard();
63936
64652
  var mobile_auth_coverage_js_1 = require_mobile_auth_coverage();
@@ -64288,6 +65004,10 @@ var require_mobile_explorer = __commonJS({
64288
65004
  return void 0;
64289
65005
  return normalized;
64290
65006
  }
65007
+ function destructiveActionRefusal(label, kind = "tap") {
65008
+ const what = kind === "confirmation" ? `Typing "${label}" is a destructive confirmation` : `Tapping "${label}" is an irreversible, high-stakes destructive action`;
65009
+ return `Refused: ${what} (account/data deletion, deactivation, wipe, or similar). Autonomous testing must never execute irreversible destruction on the account under test. Record it as an untested destructive flow in your summary and choose a non-destructive control instead.`;
65010
+ }
64291
65011
  function hybridElementForEvidence(element, credentials) {
64292
65012
  const id = safeHybridIdentity(element.id, credentials);
64293
65013
  const name = safeHybridIdentity(element.name, credentials);
@@ -64910,7 +65630,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
64910
65630
  3. After each navigation, capture_screen the destination and record_transition. After completing a meaningful flow (login, search, create), ${enableRecordJourney ? "call record_journey with the steps and expected outcome." : "continue exploring."}
64911
65631
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
64912
65632
  5. mobile_swipe to scroll; mobile_back to return.
64913
- 6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
65633
+ 6. Do NOT trigger irreversible or destructive side effects \u2014 real payments; sending messages/invites to others; and ESPECIALLY account/data deletion, deactivation, "delete account", factory reset, or any "type DELETE to confirm" gate. The runner hard-blocks destructive taps and confirmation keystrokes, so attempting them only wastes an iteration. Note such flows in your summary and explore elsewhere.
64914
65634
  7. Stay inside this app.${targetLine} If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
64915
65635
 
64916
65636
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -65433,7 +66153,7 @@ ${statePacket}` }
65433
66153
  const requestMessageCount = messages.length;
65434
66154
  const requestMessages = messages.slice();
65435
66155
  const callStart = Date.now();
65436
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
66156
+ const response = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(driver, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
65437
66157
  arm();
65438
66158
  return aiClient.chat.completions.create({
65439
66159
  model: agentModel,
@@ -65446,7 +66166,7 @@ ${statePacket}` }
65446
66166
  label: `mobile-explore-iter-${iteration}`,
65447
66167
  deferTimeoutUntilArmed: true,
65448
66168
  onRetry: (attempt, reason, delayMs) => log2(` \u26A0 mobile explore AI retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
65449
- });
66169
+ }));
65450
66170
  const iterDuration = Date.now() - callStart;
65451
66171
  if (!response?.choices?.[0]?.message) {
65452
66172
  throw new Error(`AI explore call returned no response (queue timeout or provider overload) at iteration ${iteration}`);
@@ -65775,6 +66495,11 @@ Exploration complete: ${summary}`);
65775
66495
  if (!driver.webTap) {
65776
66496
  return { ok: false, error: "This driver does not support hybrid WEBVIEW taps." };
65777
66497
  }
66498
+ const hybridTapLabel = hybridElementForEvidence(hybrid, redactionCredentials).label ?? "";
66499
+ if ((0, mobile_exploration_state_js_1.isDestructiveActionLabel)(hybridTapLabel)) {
66500
+ log2(` \u26A0 Refused destructive tap on "${hybridTapLabel}" (account/data-deletion class); noting instead.`);
66501
+ return { ok: false, error: destructiveActionRefusal(hybridTapLabel) };
66502
+ }
65778
66503
  const authSurfaceBeforeTap2 = currentAuthSurface();
65779
66504
  const fromScreenId2 = state2.getCurrentScreenId();
65780
66505
  const actionResult2 = await driver.webTap(hybrid.webviewContext, hybrid.css);
@@ -65805,6 +66530,11 @@ Exploration complete: ${summary}`);
65805
66530
  if (!resolved) {
65806
66531
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
65807
66532
  }
66533
+ const nativeTapLabel = resolved.element.label ?? resolved.element.text ?? resolved.element.accessibilityId ?? "";
66534
+ if ((0, mobile_exploration_state_js_1.isDestructiveActionLabel)(nativeTapLabel)) {
66535
+ log2(` \u26A0 Refused destructive tap on "${nativeTapLabel}" (account/data-deletion class); noting instead.`);
66536
+ return { ok: false, error: destructiveActionRefusal(nativeTapLabel) };
66537
+ }
65808
66538
  const fromScreenId = state2.getCurrentScreenId();
65809
66539
  const boundaryBeforeAction = currentBoundary();
65810
66540
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
@@ -65845,6 +66575,10 @@ Exploration complete: ${summary}`);
65845
66575
  // ----- Type -----
65846
66576
  case "mobile_type": {
65847
66577
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66578
+ if ((0, mobile_exploration_state_js_1.isDestructiveConfirmationInput)(value)) {
66579
+ log2(` \u26A0 Refused destructive confirmation input "${value}"; noting instead.`);
66580
+ return { ok: false, error: destructiveActionRefusal(value, "confirmation") };
66581
+ }
65848
66582
  const allowedLiteral = inbox ? inbox.state.email : "";
65849
66583
  for (const [key, secret] of Object.entries(credentials)) {
65850
66584
  if (!secret || secret.length < 4)
@@ -67511,6 +68245,7 @@ var require_discover_ai_planner = __commonJS({
67511
68245
  "use strict";
67512
68246
  Object.defineProperty(exports2, "__esModule", { value: true });
67513
68247
  exports2.SCENARIO_SYSTEM_PROMPT = void 0;
68248
+ exports2.getAIPlannerMaxTokens = getAIPlannerMaxTokens;
67514
68249
  exports2.getSnapshotConfidence = getSnapshotConfidence;
67515
68250
  exports2.compressSiteMap = compressSiteMap;
67516
68251
  exports2.formatEvidenceForPlanner = formatEvidenceForPlanner;
@@ -67522,10 +68257,25 @@ var require_discover_ai_planner = __commonJS({
67522
68257
  var ai_retry_js_1 = require_ai_retry();
67523
68258
  var strip_json_fences_js_1 = require_strip_json_fences();
67524
68259
  var scenario_plan_validator_js_1 = require_scenario_plan_validator();
67525
- var DEFAULT_AI_PLANNER_MAX_TOKENS = 8192;
67526
- var MINIMAX_AI_PLANNER_MAX_TOKENS = 16384;
68260
+ var GENEROUS_AI_PLANNER_MAX_TOKENS = 65536;
68261
+ var CONSERVATIVE_AI_PLANNER_MAX_TOKENS = 16384;
67527
68262
  function getAIPlannerMaxTokens(modelId) {
67528
- return (0, ai_provider_js_1.getProviderForModel)(modelId) === "minimax" ? MINIMAX_AI_PLANNER_MAX_TOKENS : DEFAULT_AI_PLANNER_MAX_TOKENS;
68263
+ switch ((0, ai_provider_js_1.getProviderForModel)(modelId)) {
68264
+ case "deepseek":
68265
+ // V4 output ceiling = 384K — verified safe
68266
+ case "minimax":
68267
+ return GENEROUS_AI_PLANNER_MAX_TOKENS;
68268
+ case "xai":
68269
+ // grok-build-0.1 ceiling unproven (Grok-Fast/3 = 16K) — DON'T risk a 400
68270
+ case "openai":
68271
+ // gpt-4o max output = 16384 → more would 400
68272
+ case "anthropic":
68273
+ // OpenAI-compat proxy — keep conservative
68274
+ case "nvidia":
68275
+ // NIM output limits vary — keep conservative
68276
+ default:
68277
+ return CONSERVATIVE_AI_PLANNER_MAX_TOKENS;
68278
+ }
67529
68279
  }
67530
68280
  var discover_planner_js_1 = require_discover_planner();
67531
68281
  function getSnapshotConfidence(sourceKind) {
@@ -68077,7 +68827,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
68077
68827
  4. **Multi-page journeys** - only when transitions were actually observed
68078
68828
  5. **Bug reports** - for observed 500 errors, crashes, or broken features
68079
68829
  6. **Steps are human-readable** - "Fill the email field", not "page.getByLabel('Email').fill()"
68080
- 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, links, fields, headings, tabs, dialogs, and menus. Expected outcomes should say what the user can now do or rely on (record saved, message sent, search results filtered, validation explained, dashboard accessible). Include durable routes or exact copy only when they are the real product outcome; do NOT list "heading visible", "button visible", "search input visible", or similar element inventory unless that exact control is the behavior under test.
68830
+ 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, links, fields, headings, tabs, dialogs, and menus. Expected outcomes should say what the user can now do or rely on (record saved, message sent, search results filtered, validation explained, dashboard accessible). Include durable routes or exact copy only when they are the real product outcome; do NOT list "heading visible", "button visible", "search input visible", or similar element inventory unless that exact control is the behavior under test. **Outcome-strength self-check (applies to EVERY scenario type):** before emitting a scenario, ask \u2014 would every expectedOutcome still pass if the primary action did nothing and only the page and its existing chrome rendered? If yes, it is too weak. Add at least one evidence-backed post-condition that would FAIL in that no-op case: specific result content or value, a created or removed entity, a changed count or status, persisted state after reload, handled validation copy, or a concrete session signal. A route alone suffices only when navigation or redirect is itself the behavior under test; a newly revealed control suffices only when revealing that control is the behavior under test. Never invent copy to satisfy this check \u2014 Rule 1 still governs what you may assert.
68081
68831
  8. **Auth-aware** - mark scenarios that need login. Use process.env.TEST_USER_EMAIL / PASSWORD
68082
68832
  9. **No duplicates** - check existing test names before adding
68083
68833
  10. **All tests start from the landing page** - every generated test navigates to \`/\` first and uses the app's own navigation (clicking links/buttons) to reach the target page. Steps must reflect this: say "Navigate to landing page and click Sign In" not "Navigate to /auth/login". Deep-linking to internal routes causes hydration failures in SPAs. \`targetPages\` lists the pages involved in the flow.
@@ -68129,7 +68879,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
68129
68879
  **YOUR responsibility:**
68130
68880
  - Scenario \`name\`, \`type\`, \`goal\`, \`prerequisites\`
68131
68881
  - \`steps\` written as user-language intent ("create a new conversation", "click the Submit button to log in"), naming observed UI labels where they help describe intent
68132
- - \`expectedOutcomes\` citing observed durable labels per Rule 12 (asserting on stable signals is encouraged)
68882
+ - \`expectedOutcomes\` expressing the evidence-backed product post-condition per Rule 7 (and Rule 11 for happy paths, plus Rule 12's concrete session signal for authentication happy paths); citing observed durable labels when asserting on stable signals is encouraged
68133
68883
 
68134
68884
  **NOT your responsibility:**
68135
68885
  - Exact Playwright selectors (\`getByRole(...)\`, \`getByText(...)\`, \`page.locator(...)\`, etc.) \u2014 live-generate picks locators against the live snapshot
@@ -68150,7 +68900,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
68150
68900
  - GOOD specificity: "create a new conversation by sending a message to a connection established earlier in this scenario"
68151
68901
  - BAD specificity: "click the button labeled exactly 'Send' in the bottom-right of the chat"
68152
68902
 
68153
- Citing observed labels in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
68903
+ Citing observed labels in \`expectedOutcomes\` is fine \u2014 that's asserting on stable product signals, not implementation specifics.
68154
68904
 
68155
68905
  ## Coverage Strategy
68156
68906
  - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 11) + 1 EDGE_CASE (empty/invalid submission)
@@ -68736,6 +69486,24 @@ Each entry is exactly one of:
68736
69486
  }
68737
69487
  async function runAIPlanner(siteMap, evidence, context, options) {
68738
69488
  const logs2 = [];
69489
+ const failurePlan = (kind, reason, legacyFallback2) => {
69490
+ const noFallback = context.mode === "survey" && process.env.DISCOVERY_PLANNER_NO_FALLBACK === "true";
69491
+ if (noFallback) {
69492
+ logs2.push(`Planner ${kind} \u2014 NO-FALLBACK: failing survey cleanly instead of fabricating slug features (${reason})`);
69493
+ return {
69494
+ scenarios: [],
69495
+ featureGroups: [],
69496
+ reasoning: "",
69497
+ logs: logs2,
69498
+ coverageGaps: [],
69499
+ plannerFailed: true,
69500
+ plannerFailureKind: kind,
69501
+ fallbackReason: reason
69502
+ };
69503
+ }
69504
+ return { ...legacyFallback2(), fallbackReason: reason };
69505
+ };
69506
+ let extraPlannerCallUsed = false;
68739
69507
  const compressed = compressSiteMap(siteMap);
68740
69508
  const agentModel = options.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
68741
69509
  const plannerMaxTokens = getAIPlannerMaxTokens(agentModel);
@@ -68771,7 +69539,7 @@ Each entry is exactly one of:
68771
69539
  } catch (err) {
68772
69540
  const message = err instanceof Error ? err.message : String(err);
68773
69541
  logs2.push(`AI planner failed after retries: ${message}`);
68774
- return { ...buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget), fallbackReason: (0, ai_retry_js_1.describeAIFailure)(err) };
69542
+ return failurePlan("transport", (0, ai_retry_js_1.describeAIFailure)(err), () => buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget));
68775
69543
  }
68776
69544
  try {
68777
69545
  options.onAICall?.({
@@ -68790,20 +69558,104 @@ Each entry is exactly one of:
68790
69558
  });
68791
69559
  } catch {
68792
69560
  }
68793
- const raw = response?.choices?.[0]?.message?.content;
68794
- if (!raw) {
69561
+ const choice = response?.choices?.[0];
69562
+ const raw = choice?.message?.content;
69563
+ const finishReason = (choice?.finish_reason ?? "").toString().toLowerCase();
69564
+ const completionTokens = response.usage?.completion_tokens;
69565
+ const TRUNCATION_FINISH = /* @__PURE__ */ new Set(["length", "max_tokens", "max_output_tokens", "token_limit"]);
69566
+ const definitelyTruncated = TRUNCATION_FINISH.has(finishReason);
69567
+ const atOrOverCap = completionTokens != null && completionTokens >= plannerMaxTokens;
69568
+ const blocked = finishReason === "content_filter" || finishReason === "tool_calls";
69569
+ const usage = response.usage;
69570
+ logs2.push(`AI planner: ${usage?.prompt_tokens ?? "?"} input, ${usage?.completion_tokens ?? "?"} output tokens (finish=${finishReason || "?"})`);
69571
+ const legacyFallback = () => buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget);
69572
+ if (blocked) {
69573
+ return failurePlan("blocked", "AI response was blocked (content_filter/tool_calls)", legacyFallback);
69574
+ }
69575
+ if (definitelyTruncated) {
69576
+ return failurePlan("truncated", "AI planner output truncated at max_tokens (finish_reason=length)", legacyFallback);
69577
+ }
69578
+ const trimmed = raw?.trim();
69579
+ if (!trimmed) {
68795
69580
  logs2.push("AI planner returned empty response");
68796
- return { ...buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget), fallbackReason: "AI returned an empty response" };
69581
+ return atOrOverCap ? failurePlan("truncated", "AI planner output truncated at max_tokens (empty body at cap)", legacyFallback) : failurePlan("empty", "AI returned an empty response", legacyFallback);
68797
69582
  }
68798
- const usage = response.usage;
68799
- logs2.push(`AI planner: ${usage?.prompt_tokens ?? "?"} input, ${usage?.completion_tokens ?? "?"} output tokens`);
68800
69583
  let aiOutput;
68801
69584
  try {
68802
- aiOutput = JSON.parse((0, strip_json_fences_js_1.stripJsonFences)(raw));
69585
+ aiOutput = JSON.parse((0, strip_json_fences_js_1.stripJsonFences)(trimmed));
68803
69586
  } catch (err) {
68804
69587
  const message = err instanceof Error ? err.message : String(err);
68805
69588
  logs2.push(`AI planner returned invalid JSON: ${message}`);
68806
- return { ...buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget), fallbackReason: "AI returned unreadable output" };
69589
+ if (atOrOverCap) {
69590
+ logs2.push("AI planner output at/over the token cap and unparseable \u2014 treating as truncated (skipping repair)");
69591
+ return failurePlan("truncated", "AI planner output truncated at max_tokens (unparseable at cap)", legacyFallback);
69592
+ }
69593
+ const repairPrompt = `Your previous response was not valid JSON (parser error: ${message}). Return ONLY the corrected JSON object \u2014 no prose, no markdown fences \u2014 matching the exact schema of your previous attempt.`;
69594
+ const repairStart = Date.now();
69595
+ let repairResponse = null;
69596
+ try {
69597
+ extraPlannerCallUsed = true;
69598
+ repairResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
69599
+ arm();
69600
+ return aiClient.chat.completions.create({
69601
+ model: agentModel,
69602
+ messages: [
69603
+ ...messages,
69604
+ { role: "assistant", content: raw },
69605
+ { role: "user", content: repairPrompt }
69606
+ ],
69607
+ max_tokens: plannerMaxTokens,
69608
+ temperature: 0.3,
69609
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
69610
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
69611
+ }, { signal });
69612
+ }), {
69613
+ label: "ai-planner-repair",
69614
+ deferTimeoutUntilArmed: true,
69615
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner repair call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
69616
+ });
69617
+ } catch (repairErr) {
69618
+ const repairMessage = repairErr instanceof Error ? repairErr.message : String(repairErr);
69619
+ logs2.push(`AI planner repair failed (${repairMessage})`);
69620
+ }
69621
+ if (repairResponse) {
69622
+ try {
69623
+ options.onAICall?.({
69624
+ iteration: 1,
69625
+ groupId: `ai-planner-${context.projectId.slice(0, 8)}`,
69626
+ callType: "discoveryAIPlannerRepair",
69627
+ model: agentModel,
69628
+ provider: (0, ai_provider_js_1.getProviderForModel)(agentModel),
69629
+ durationMs: Date.now() - repairStart,
69630
+ promptTokens: repairResponse.usage?.prompt_tokens,
69631
+ completionTokens: repairResponse.usage?.completion_tokens,
69632
+ totalTokens: repairResponse.usage?.total_tokens,
69633
+ assistantMessage: repairResponse.choices[0]?.message?.content,
69634
+ requestMessageCount: messages.length + 2,
69635
+ requestMessages: [
69636
+ ...messages,
69637
+ { role: "assistant", content: raw },
69638
+ { role: "user", content: repairPrompt }
69639
+ ]
69640
+ });
69641
+ } catch {
69642
+ }
69643
+ const repairedRaw = repairResponse.choices[0]?.message?.content?.trim();
69644
+ if (repairedRaw) {
69645
+ try {
69646
+ aiOutput = JSON.parse((0, strip_json_fences_js_1.stripJsonFences)(repairedRaw));
69647
+ } catch (repairParseErr) {
69648
+ const repairMessage = repairParseErr instanceof Error ? repairParseErr.message : String(repairParseErr);
69649
+ logs2.push(`AI planner repair returned invalid JSON (${repairMessage})`);
69650
+ return failurePlan("malformed", "AI returned unreadable output", legacyFallback);
69651
+ }
69652
+ } else {
69653
+ logs2.push("AI planner repair returned empty response");
69654
+ return failurePlan("malformed", "AI returned unreadable output", legacyFallback);
69655
+ }
69656
+ } else {
69657
+ return failurePlan("malformed", "AI returned unreadable output", legacyFallback);
69658
+ }
68807
69659
  }
68808
69660
  let { scenarios, featureGroups, warnings, coverageGaps } = validateAIPlannerOutput(aiOutput, siteMap, context.existingTestNames, context.testBudget, evidence.recordedJourneys, context.mode, context.focusAreas);
68809
69661
  logs2.push(`AI planner: ${scenarios.length} scenarios, ${featureGroups.length} feature groups (${warnings.length} warnings, ${coverageGaps.length} coverage gaps)`);
@@ -68813,7 +69665,7 @@ Each entry is exactly one of:
68813
69665
  let topLevelWarning;
68814
69666
  const totalV1 = scenarios.length + coverageGaps.length;
68815
69667
  const rejectionRateV1 = totalV1 > 0 ? coverageGaps.length / totalV1 : 0;
68816
- if (rejectionRateV1 > 0.5 && totalV1 >= 3) {
69668
+ if (rejectionRateV1 > 0.5 && totalV1 >= 3 && !extraPlannerCallUsed) {
68817
69669
  logs2.push(`AI planner: ${Math.round(rejectionRateV1 * 100)}% rejection rate (${coverageGaps.length}/${totalV1}) \u2014 running self-correction retry`);
68818
69670
  const retryContext = [
68819
69671
  `Your previous emission produced ${coverageGaps.length} coverage gaps out of ${totalV1} scenarios (${Math.round(rejectionRateV1 * 100)}% rejection rate).`,
@@ -68832,6 +69684,7 @@ Each entry is exactly one of:
68832
69684
  const retryStart = Date.now();
68833
69685
  let retryResponse = null;
68834
69686
  try {
69687
+ extraPlannerCallUsed = true;
68835
69688
  retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
68836
69689
  arm();
68837
69690
  return aiClient.chat.completions.create({
@@ -68931,6 +69784,22 @@ Each entry is exactly one of:
68931
69784
  };
68932
69785
  }
68933
69786
  if (context.mode === "survey" && featureGroups.length === 0 && siteMap.pages.length > 0) {
69787
+ const hasGroupWorthyEvidence = siteMap.pages.some((p) => (p.forms?.length ?? 0) > 0 || (p.elements?.length ?? 0) > 0);
69788
+ const noFallback = process.env.DISCOVERY_PLANNER_NO_FALLBACK === "true";
69789
+ if (noFallback && !hasGroupWorthyEvidence) {
69790
+ return {
69791
+ scenarios,
69792
+ featureGroups: [],
69793
+ reasoning: aiOutput.reasoning ?? "",
69794
+ logs: logs2,
69795
+ coverageGaps,
69796
+ warning: topLevelWarning,
69797
+ fallbackUsed: false
69798
+ };
69799
+ }
69800
+ if (noFallback) {
69801
+ return failurePlan("empty_groups", "AI produced no feature groups despite discovered content", () => buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget));
69802
+ }
68934
69803
  logs2.push("Survey AI returned 0 feature groups - falling back to deterministic grouping");
68935
69804
  const fallback = buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget);
68936
69805
  return {
@@ -70141,6 +71010,7 @@ var require_mobile_generator = __commonJS({
70141
71010
  var ai_provider_js_1 = require_ai_provider();
70142
71011
  var ai_queue_js_1 = require_ai_queue();
70143
71012
  var ai_retry_js_1 = require_ai_retry();
71013
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
70144
71014
  var mobile_source_parser_js_1 = require_mobile_source_parser();
70145
71015
  var mobile_source_parser_js_2 = require_mobile_source_parser();
70146
71016
  var mobile_observation_js_1 = require_mobile_observation();
@@ -70554,7 +71424,7 @@ var require_mobile_generator = __commonJS({
70554
71424
  - Re-snapshot before every assertion so the ref is fresh.
70555
71425
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
70556
71426
  - 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.
70557
- - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
71427
+ - 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.
70558
71428
  - 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.
70559
71429
  - 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.
70560
71430
  - 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.
@@ -71048,14 +71918,14 @@ ${statePacket}`, redactionCredentials) },
71048
71918
  const requestMessageCount = messages.length;
71049
71919
  const requestMessages = messages.slice();
71050
71920
  const callStart = Date.now();
71051
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
71921
+ const response = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(driver, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
71052
71922
  arm();
71053
71923
  return aiClient.chat.completions.create({ model: agentModel, messages, tools: GENERATE_TOOLS, tool_choice: "auto", max_tokens: 8e3 }, { signal });
71054
71924
  }), {
71055
71925
  label: `mobile-generate-${scenario.name.slice(0, 24)}-iter-${iteration}`,
71056
71926
  deferTimeoutUntilArmed: true,
71057
71927
  onRetry: (attempt, reason, delayMs) => log2(` \u26A0 mobile generate AI retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
71058
- });
71928
+ }));
71059
71929
  const iterDuration = Date.now() - callStart;
71060
71930
  if (!response?.choices?.[0]?.message) {
71061
71931
  throw new Error(`AI generate call returned no response at iteration ${iteration}`);
@@ -71667,6 +72537,21 @@ ${statePacket}`, redactionCredentials) },
71667
72537
  };
71668
72538
  }
71669
72539
  const metadata = metadataForElement(element, screenName(), platform3);
72540
+ const expectedNeedle = expected.trim().toLowerCase();
72541
+ const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
72542
+ const visibleText = (element.text ?? "").trim().toLowerCase();
72543
+ const isSelfLabelAssertion = expectedNeedle.length > 0 && identityValues.includes(expectedNeedle) && !visibleText.includes(expectedNeedle);
72544
+ if (isSelfLabelAssertion) {
72545
+ recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
72546
+ log2(` assert_text DOWNGRADED to assert_visible (tautological self-label "${expected}"): ${metadata.targetLabel}`);
72547
+ return {
72548
+ ok: true,
72549
+ asserted: "visible",
72550
+ downgraded: true,
72551
+ note: `"${expected}" is the element's own accessibility identity, not product copy it displays \u2014 recorded a visibility assertion instead. To prove the outcome, assert text the screen SHOWS (an error message, a status label, a field value), not the control's own name.`,
72552
+ target: metadata.targetLabel
72553
+ };
72554
+ }
71670
72555
  if (hasStableId(element)) {
71671
72556
  recordAction({ type: "ASSERT_TEXT", value: expected, selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
71672
72557
  log2(` assert_text: ${metadata.targetLabel} ~ "${expected}"`);
@@ -72360,7 +73245,13 @@ ${statePacket}`, redactionCredentials) },
72360
73245
  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;
72361
73246
  const test = {
72362
73247
  name: compiled.name,
72363
- description: recording.finish?.description ?? compiled.description,
73248
+ // Description priority: the model's live finish description, then the
73249
+ // planner's scenario goal (always a meaningful sentence describing what the
73250
+ // test proves), then the generic compiled placeholder. Without the goal
73251
+ // fallback a scenario whose finish_generate omitted a description shipped
73252
+ // with the contentless "<platform> native flow generated from a recorded
73253
+ // mobile session." string — see mobile-test-generation.ts's compiled default.
73254
+ description: recording.finish?.description?.trim() || scenario.goal?.trim() || compiled.description,
72364
73255
  framework: "APPIUM",
72365
73256
  appiumCode: compiled.appiumCode,
72366
73257
  steps: compiled.steps,
@@ -75037,6 +75928,118 @@ var require_har_stream = __commonJS({
75037
75928
  }
75038
75929
  });
75039
75930
 
75931
+ // ../runner-core/dist/services/api-auth-headers.js
75932
+ var require_api_auth_headers = __commonJS({
75933
+ "../runner-core/dist/services/api-auth-headers.js"(exports2) {
75934
+ "use strict";
75935
+ Object.defineProperty(exports2, "__esModule", { value: true });
75936
+ exports2.parseApiAuthHeaders = parseApiAuthHeaders;
75937
+ exports2.resolveApiAuthHeaders = resolveApiAuthHeaders;
75938
+ exports2.isAppOriginRequest = isAppOriginRequest;
75939
+ exports2.buildOriginScopedApiFixtures = buildOriginScopedApiFixtures;
75940
+ var HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
75941
+ function hasControlChar(v) {
75942
+ for (let i = 0; i < v.length; i++) {
75943
+ const code = v.charCodeAt(i);
75944
+ if (code < 32 || code === 127)
75945
+ return true;
75946
+ }
75947
+ return false;
75948
+ }
75949
+ function parseApiAuthHeaders(raw) {
75950
+ if (!raw || typeof raw !== "string")
75951
+ return void 0;
75952
+ const trimmed = raw.trim();
75953
+ if (!trimmed)
75954
+ return void 0;
75955
+ try {
75956
+ const parsed = JSON.parse(trimmed);
75957
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
75958
+ return void 0;
75959
+ const headers = {};
75960
+ for (const [k, v] of Object.entries(parsed)) {
75961
+ if (typeof k === "string" && HEADER_NAME_RE.test(k) && typeof v === "string" && !hasControlChar(v)) {
75962
+ headers[k] = v;
75963
+ }
75964
+ }
75965
+ return Object.keys(headers).length > 0 ? headers : void 0;
75966
+ } catch {
75967
+ return void 0;
75968
+ }
75969
+ }
75970
+ function resolveApiAuthHeaders(input) {
75971
+ if (input.explicit)
75972
+ return input.explicit;
75973
+ if (!input.isApiTest)
75974
+ return void 0;
75975
+ return parseApiAuthHeaders(input.apiAuthHeadersVar);
75976
+ }
75977
+ function isAppOriginRequest(url, baseUrl) {
75978
+ if (typeof url !== "string" || !baseUrl)
75979
+ return false;
75980
+ let appOrigin;
75981
+ try {
75982
+ appOrigin = new URL(baseUrl).origin;
75983
+ } catch {
75984
+ return false;
75985
+ }
75986
+ try {
75987
+ return new URL(url, baseUrl).origin === appOrigin;
75988
+ } catch {
75989
+ return false;
75990
+ }
75991
+ }
75992
+ function buildOriginScopedApiFixtures(headers, baseUrl) {
75993
+ let appOrigin;
75994
+ try {
75995
+ appOrigin = baseUrl ? new URL(baseUrl).origin : null;
75996
+ } catch {
75997
+ appOrigin = null;
75998
+ }
75999
+ return `export * from '@playwright/test';
76000
+ import { test as base } from '@playwright/test';
76001
+
76002
+ // Standing auth headers \u2014 attached ONLY to same-origin requests so the credential never leaks to
76003
+ // an off-origin host. Embedded at run time; this file lives in the ephemeral run dir.
76004
+ const STANDING_HEADERS = ${JSON.stringify(headers)};
76005
+ const APP_ORIGIN = ${JSON.stringify(appOrigin)};
76006
+ const BASE_URL = ${JSON.stringify(baseUrl)};
76007
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'fetch'];
76008
+
76009
+ function sameOrigin(url) {
76010
+ if (!APP_ORIGIN || typeof url !== 'string') return false;
76011
+ try { return new URL(url, BASE_URL).origin === APP_ORIGIN; } catch { return false; }
76012
+ }
76013
+
76014
+ export const test = base.extend({
76015
+ request: async ({ request }, use) => {
76016
+ const scoped = new Proxy(request, {
76017
+ get(target, prop) {
76018
+ const value = target[prop];
76019
+ // HTTP verbs: wrap to add standing headers on same-origin requests only.
76020
+ if (typeof prop === 'string' && METHODS.indexOf(prop) !== -1) {
76021
+ const orig = value.bind(target);
76022
+ return (url, options) => {
76023
+ options = options || {};
76024
+ return sameOrigin(url)
76025
+ ? orig(url, { ...options, headers: { ...STANDING_HEADERS, ...(options.headers || {}) } })
76026
+ : orig(url, options);
76027
+ };
76028
+ }
76029
+ // Every other METHOD must run with 'this' bound to the REAL context \u2014 an APIRequestContext
76030
+ // uses internal slots/private fields a Proxy can't forward, so calling with 'this'=proxy
76031
+ // would throw. Non-function props pass through untouched.
76032
+ return typeof value === 'function' ? value.bind(target) : value;
76033
+ },
76034
+ });
76035
+ await use(scoped);
76036
+ },
76037
+ });
76038
+ `;
76039
+ }
76040
+ }
76041
+ });
76042
+
75040
76043
  // ../runner-core/dist/services/playwright-native.js
75041
76044
  var require_playwright_native = __commonJS({
75042
76045
  "../runner-core/dist/services/playwright-native.js"(exports2) {
@@ -75079,7 +76082,7 @@ var require_playwright_native = __commonJS({
75079
76082
  };
75080
76083
  })();
75081
76084
  Object.defineProperty(exports2, "__esModule", { value: true });
75082
- exports2.scrubHarRequestBody = void 0;
76085
+ exports2.scrubHarRequestBody = exports2.buildOriginScopedApiFixtures = exports2.isAppOriginRequest = exports2.resolveApiAuthHeaders = exports2.parseApiAuthHeaders = void 0;
75083
76086
  exports2.executeTestViaNativePlaywright = executeTestViaNativePlaywright2;
75084
76087
  exports2.parseJsonResults = parseJsonResults2;
75085
76088
  exports2.parseHarToApiSummary = parseHarToApiSummary;
@@ -75091,6 +76094,20 @@ var require_playwright_native = __commonJS({
75091
76094
  var spawn_with_tree_kill_js_1 = require_spawn_with_tree_kill();
75092
76095
  var api_call_redaction_js_1 = require_api_call_redaction();
75093
76096
  var har_stream_js_1 = require_har_stream();
76097
+ var api_auth_headers_js_1 = require_api_auth_headers();
76098
+ var api_auth_headers_js_2 = require_api_auth_headers();
76099
+ Object.defineProperty(exports2, "parseApiAuthHeaders", { enumerable: true, get: function() {
76100
+ return api_auth_headers_js_2.parseApiAuthHeaders;
76101
+ } });
76102
+ Object.defineProperty(exports2, "resolveApiAuthHeaders", { enumerable: true, get: function() {
76103
+ return api_auth_headers_js_2.resolveApiAuthHeaders;
76104
+ } });
76105
+ Object.defineProperty(exports2, "isAppOriginRequest", { enumerable: true, get: function() {
76106
+ return api_auth_headers_js_2.isAppOriginRequest;
76107
+ } });
76108
+ Object.defineProperty(exports2, "buildOriginScopedApiFixtures", { enumerable: true, get: function() {
76109
+ return api_auth_headers_js_2.buildOriginScopedApiFixtures;
76110
+ } });
75094
76111
  var api_call_redaction_js_2 = require_api_call_redaction();
75095
76112
  Object.defineProperty(exports2, "scrubHarRequestBody", { enumerable: true, get: function() {
75096
76113
  return api_call_redaction_js_2.scrubHarRequestBody;
@@ -75100,7 +76117,7 @@ var require_playwright_native = __commonJS({
75100
76117
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
75101
76118
  var PW_CLI = require.resolve("@playwright/test/cli");
75102
76119
  async function executeTestViaNativePlaywright2(options) {
75103
- const { playwrightCode, baseUrl, testName, testVariables, storageStatePath, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
76120
+ const { playwrightCode, baseUrl, testName, testVariables, storageStatePath, extraHttpHeaders, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
75104
76121
  const logs2 = [];
75105
76122
  const screenshots = [];
75106
76123
  logs2.push(`\u{1F3AD} Native Playwright starting`);
@@ -75114,6 +76131,11 @@ var require_playwright_native = __commonJS({
75114
76131
  runDir = (0, path_1.join)(RUNS_DIR, (0, crypto_1.randomUUID)());
75115
76132
  await (0, promises_1.mkdir)(runDir, { recursive: true });
75116
76133
  const isApiTest2 = playwrightCode.includes("@api") || playwrightCode.includes("{ request }") && !playwrightCode.includes("{ page");
76134
+ const resolvedExtraHeaders = (0, api_auth_headers_js_1.resolveApiAuthHeaders)({
76135
+ explicit: extraHttpHeaders,
76136
+ isApiTest: isApiTest2,
76137
+ apiAuthHeadersVar: testVariables?.API_AUTH_HEADERS
76138
+ });
75117
76139
  const cssDisableSetup = `
75118
76140
  const __qaLiveBrowserStateFile = process.env.VALIDATE_LIVE_BROWSER_STATE_PATH;
75119
76141
  let __qaLiveBrowserInterval: ReturnType<typeof setInterval> | null = null;
@@ -75307,8 +76329,17 @@ test.afterEach(async ({ page }, testInfo) => {
75307
76329
  if (!importMatch) {
75308
76330
  logs2.push("\u26A0 Could not find @playwright/test import - animation disabling NOT injected");
75309
76331
  }
75310
- const instrumentedCode = isApiTest2 ? playwrightCode : importMatch ? playwrightCode.replace(importMatch[0], importMatch[0] + cssDisableSetup) : playwrightCode;
76332
+ const useScopedApiHeaders = isApiTest2 && !!resolvedExtraHeaders && !!importMatch && !!baseUrl;
76333
+ let instrumentedCode;
76334
+ if (isApiTest2) {
76335
+ instrumentedCode = useScopedApiHeaders ? playwrightCode.replace(importMatch[0], importMatch[0].replace(/from\s+['"]@playwright\/test['"]/, "from './qa-api-fixtures'")) : playwrightCode;
76336
+ } else {
76337
+ instrumentedCode = importMatch ? playwrightCode.replace(importMatch[0], importMatch[0] + cssDisableSetup) : playwrightCode;
76338
+ }
75311
76339
  await (0, promises_1.writeFile)((0, path_1.join)(runDir, "test.spec.ts"), instrumentedCode);
76340
+ if (useScopedApiHeaders && resolvedExtraHeaders) {
76341
+ await (0, promises_1.writeFile)((0, path_1.join)(runDir, "qa-api-fixtures.ts"), (0, api_auth_headers_js_1.buildOriginScopedApiFixtures)(resolvedExtraHeaders, baseUrl));
76342
+ }
75312
76343
  const headless = process.env.PLAYWRIGHT_HEADLESS === "true" || process.env.NODE_ENV === "production" || IS_CLOUD2;
75313
76344
  logs2.push(`\u2699\uFE0F Config: baseURL=${JSON.stringify(baseUrl)}, headless=${headless}${isApiTest2 ? ", API mode (no screenshot/video)" : ""}`);
75314
76345
  const absTestDir = runDir.replace(/\\/g, "/");
@@ -75322,6 +76353,7 @@ test.afterEach(async ({ page }, testInfo) => {
75322
76353
  const absHarPath = wantHar ? (0, path_1.join)(runDir, "session.har").replace(/\\/g, "/") : null;
75323
76354
  const recordHarLine = absHarPath ? `recordHar: { path: ${JSON.stringify(absHarPath)}, mode: 'full' },` : "";
75324
76355
  const storageStateLine = storageStatePath ? `storageState: ${JSON.stringify(storageStatePath.replace(/\\/g, "/"))},` : "";
76356
+ const extraHeadersLine = resolvedExtraHeaders && !useScopedApiHeaders && Object.keys(resolvedExtraHeaders).length > 0 ? `extraHTTPHeaders: ${JSON.stringify(resolvedExtraHeaders)},` : "";
75325
76357
  const launchArgs = IS_CLOUD2 ? "args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', '--no-zygote', '--disable-accelerated-2d-canvas', '--js-flags=--max-old-space-size=512']," : "args: ['--disable-blink-features=AutomationControlled'],";
75326
76358
  const config = `
75327
76359
  // Plain object export - no require('@playwright/test') needed.
@@ -75335,6 +76367,7 @@ module.exports = {
75335
76367
  use: {
75336
76368
  baseURL: ${JSON.stringify(baseUrl)},
75337
76369
  ${storageStateLine}
76370
+ ${extraHeadersLine}
75338
76371
  browserName: 'chromium',
75339
76372
  headless: ${headless},
75340
76373
  screenshot: ${isApiTest2 ? "'off'" : "'on'"},
@@ -75376,6 +76409,7 @@ module.exports = {
75376
76409
  // code. Forwarded (not allowlisted) so it stays a per-run toggle; absent → inert.
75377
76410
  ...process.env.SHARED_AUTH_INJECT ? { SHARED_AUTH_INJECT: process.env.SHARED_AUTH_INJECT } : {}
75378
76411
  };
76412
+ delete envWithIsolation.API_AUTH_HEADERS;
75379
76413
  logs2.push("\u{1F680} Spawning playwright test...");
75380
76414
  const stopLiveBrowserPolling = liveBrowserStatePath && liveBrowserHandle ? startLiveBrowserStatePolling(liveBrowserStatePath, liveBrowserHandle, onLiveBrowserSnapshot) : null;
75381
76415
  let playResult;
@@ -79227,6 +80261,34 @@ var require_anchor_harvester = __commonJS({
79227
80261
  }
79228
80262
  });
79229
80263
 
80264
+ // ../runner-core/dist/services/discovery-lint-flags.js
80265
+ var require_discovery_lint_flags = __commonJS({
80266
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports2) {
80267
+ "use strict";
80268
+ Object.defineProperty(exports2, "__esModule", { value: true });
80269
+ exports2.structuralLocatorLintMode = structuralLocatorLintMode;
80270
+ exports2.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
80271
+ exports2.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
80272
+ function strictOn(value) {
80273
+ return value === "true" || value === "1";
80274
+ }
80275
+ function structuralLocatorLintMode() {
80276
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
80277
+ if (v === "warn")
80278
+ return "warn";
80279
+ if (v === "true" || v === "1" || v === "enforce")
80280
+ return "enforce";
80281
+ return "off";
80282
+ }
80283
+ function isPwMethodPreflightEnabled() {
80284
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
80285
+ }
80286
+ function isVqaLoginDedupeEnabled() {
80287
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
80288
+ }
80289
+ }
80290
+ });
80291
+
79230
80292
  // ../runner-core/dist/services/auth-recipe.js
79231
80293
  var require_auth_recipe = __commonJS({
79232
80294
  "../runner-core/dist/services/auth-recipe.js"(exports2) {
@@ -79249,9 +80311,11 @@ var require_auth_recipe = __commonJS({
79249
80311
  exports2.extractAuthRegion = extractAuthRegion;
79250
80312
  exports2.stripAuthRegions = stripAuthRegions;
79251
80313
  exports2.ensureExpectImported = ensureExpectImported;
80314
+ exports2.ensureLoginCall = ensureLoginCall;
79252
80315
  exports2.upsertAuthBlock = upsertAuthBlock;
79253
80316
  exports2.buildHealAuthStamp = buildHealAuthStamp;
79254
80317
  var crypto_1 = require("crypto");
80318
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
79255
80319
  exports2.AUTH_MARKER_VERSION = "v1";
79256
80320
  var START_MARKER = `// >>> VALIDATE_QA_AUTH ${exports2.AUTH_MARKER_VERSION} \u2014 generated, do not edit >>>`;
79257
80321
  var END_MARKER = `// <<< VALIDATE_QA_AUTH ${exports2.AUTH_MARKER_VERSION} <<<`;
@@ -79515,10 +80579,13 @@ ${tail}`;
79515
80579
  var TEST_OPENER_RE = /test(?:\.(?:only|skip|fixme))?\s*\(\s*(['"`])(?:[^\\]|\\.)*?\1\s*,\s*(?:\{[\s\S]*?\}\s*,\s*)?async\s*\(\s*\{[^}]*\}[^)]*\)\s*=>\s*\{/;
79516
80580
  var LOGIN_CALL_RE = /await\s+__vqaLogin\s*\(\s*page\s*\)/;
79517
80581
  function ensureLoginCall(code) {
79518
- const matches = [...code.matchAll(new RegExp(TEST_OPENER_RE.source, "g"))];
79519
- if (matches.length === 0)
80582
+ if (code.match(new RegExp(TEST_OPENER_RE.source, "g")) === null)
79520
80583
  return code;
79521
80584
  let result = code;
80585
+ if ((0, discovery_lint_flags_js_1.isVqaLoginDedupeEnabled)()) {
80586
+ result = result.replace(/^[ \t]*await[ \t]+__vqaLogin[ \t]*\([ \t]*page[ \t]*\)[ \t]*;?[ \t]*\r?\n/gm, "");
80587
+ }
80588
+ const matches = [...result.matchAll(new RegExp(TEST_OPENER_RE.source, "g"))];
79522
80589
  for (let i = matches.length - 1; i >= 0; i--) {
79523
80590
  const m = matches[i];
79524
80591
  if (m.index === void 0)
@@ -83410,7 +84477,7 @@ var require_discover_generator = __commonJS({
83410
84477
  "../runner-core/dist/services/discover-generator.js"(exports2) {
83411
84478
  "use strict";
83412
84479
  Object.defineProperty(exports2, "__esModule", { value: true });
83413
- exports2.MIN_BUG_DESCRIPTION_LENGTH = exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
84480
+ exports2.MIN_BUG_DESCRIPTION_LENGTH = exports2.VALID_PLAYWRIGHT_GETBY_METHODS = exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
83414
84481
  exports2.filterMcpToolsForScenarioGen = filterMcpToolsForScenarioGen;
83415
84482
  exports2.detectScenarioSignals = detectScenarioSignals;
83416
84483
  exports2.scenarioStepsCoverAuth = scenarioStepsCoverAuth;
@@ -83433,6 +84500,7 @@ var require_discover_generator = __commonJS({
83433
84500
  exports2.upgradePositionalProvenLines = upgradePositionalProvenLines;
83434
84501
  exports2.synthesizeRefActionLines = synthesizeRefActionLines;
83435
84502
  exports2.renderSessionRecording = renderSessionRecording;
84503
+ exports2.scanFragileStructuralLocators = scanFragileStructuralLocators;
83436
84504
  exports2.validateFinalActionFidelity = validateFinalActionFidelity;
83437
84505
  exports2.resolveSuspectedBugVerdict = resolveSuspectedBugVerdict;
83438
84506
  exports2.buildScenarioSystemPrompt = buildScenarioSystemPrompt;
@@ -83448,6 +84516,7 @@ var require_discover_generator = __commonJS({
83448
84516
  var ai_provider_js_1 = require_ai_provider();
83449
84517
  var ai_queue_js_1 = require_ai_queue();
83450
84518
  var ai_retry_js_1 = require_ai_retry();
84519
+ var discover_ai_planner_js_1 = require_discover_ai_planner();
83451
84520
  var snapshot_parser_js_1 = require_snapshot_parser();
83452
84521
  var scrub_credentials_js_1 = require_scrub_credentials();
83453
84522
  var exploration_parser_js_1 = require_exploration_parser();
@@ -83460,6 +84529,7 @@ var require_discover_generator = __commonJS({
83460
84529
  var auth_recipe_js_1 = require_auth_recipe();
83461
84530
  var auth_replay_js_1 = require_auth_replay();
83462
84531
  var agentic_discovery_prompts_js_1 = require_agentic_discovery_prompts();
84532
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
83463
84533
  exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = /* @__PURE__ */ new Set([
83464
84534
  "browser_install",
83465
84535
  "browser_close",
@@ -84184,6 +85254,15 @@ ${elementContext}${formContext}${observationContext}${interactionContext}${lowCo
84184
85254
  return assembled.join(separator);
84185
85255
  }
84186
85256
  var SENSITIVE_ENV_KEY_RE = /email|password|token|secret|api[_-]?key|access[_-]?key|username|user(name)?|bearer|session|cookie|\bpat\b|refresh|jwt/i;
85257
+ exports2.VALID_PLAYWRIGHT_GETBY_METHODS = /* @__PURE__ */ new Set([
85258
+ "getByRole",
85259
+ "getByText",
85260
+ "getByLabel",
85261
+ "getByPlaceholder",
85262
+ "getByTestId",
85263
+ "getByAltText",
85264
+ "getByTitle"
85265
+ ]);
84187
85266
  var VALID_PLAYWRIGHT_MATCHERS = /* @__PURE__ */ new Set([
84188
85267
  // Locator assertions
84189
85268
  "toBeAttached",
@@ -84671,6 +85750,16 @@ ${testOpen}`;
84671
85750
  return false;
84672
85751
  }
84673
85752
  }
85753
+ if ((0, discovery_lint_flags_js_1.isPwMethodPreflightEnabled)()) {
85754
+ const getByCalls = codeForMatcherScan.match(/\.getBy[A-Za-z0-9_]*\s*\(/g) ?? [];
85755
+ for (const call of getByCalls) {
85756
+ const methodName = call.replace(/[\s(]+$/, "").slice(1);
85757
+ if (!exports2.VALID_PLAYWRIGHT_GETBY_METHODS.has(methodName)) {
85758
+ logs2?.push(` \u2717 Rejected "${t.name}": invalid Playwright locator method "${methodName}" (not a real getBy* API \u2014 likely hallucinated)`);
85759
+ return false;
85760
+ }
85761
+ }
85762
+ }
84674
85763
  if (code.includes("// TODO")) {
84675
85764
  logs2?.push(` \u26A0 "${t.name}" contains TODO comment \u2014 marking as unverified`);
84676
85765
  t.tags = t.tags.filter((tag) => tag !== "@verified");
@@ -85784,6 +86873,35 @@ ${testOpen}`;
85784
86873
  }
85785
86874
  return null;
85786
86875
  }
86876
+ var STRUCTURAL_ACTION_RE = /\.(?:click|dblclick|tap|fill|type|pressSequentially|press|selectOption|hover|check|uncheck|setChecked|setInputFiles|dragTo|focus|clear)\s*\(/;
86877
+ var POSITIONAL_PSEUDO_RE = /:(?:last-child|first-child|nth-child|nth-of-type|nth-last-child|nth-last-of-type)\b/i;
86878
+ var TABLE_TOKEN_RE = /\b(?:tr|td|th|tbody|thead)\b/i;
86879
+ var ROW_ANCHOR_RE = /getByRole\s*\(\s*['"`]row['"`]|getByTestId|data-testid|data-test\b|\[role\s*=\s*['"`]row['"`]\]/i;
86880
+ var LOCATOR_ARG_RE = /\.locator\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
86881
+ function scanFragileStructuralLocators(code) {
86882
+ const findings = [];
86883
+ const scanCode = code.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
86884
+ for (const rawLine of scanCode.split("\n")) {
86885
+ const line = rawLine.replace(/\/\/.*$/, "");
86886
+ if (!STRUCTURAL_ACTION_RE.test(line))
86887
+ continue;
86888
+ if (ROW_ANCHOR_RE.test(line))
86889
+ continue;
86890
+ LOCATOR_ARG_RE.lastIndex = 0;
86891
+ let m;
86892
+ while ((m = LOCATOR_ARG_RE.exec(line)) !== null) {
86893
+ const css = m[2];
86894
+ if (POSITIONAL_PSEUDO_RE.test(css) && TABLE_TOKEN_RE.test(css)) {
86895
+ findings.push({
86896
+ line: rawLine.trim().slice(0, 200),
86897
+ reason: `Positional table-row selector "${css.slice(0, 120)}" is not scoped to a row identity \u2014 it breaks when rows reorder, sort, filter, or a row is inserted. Scope through the row: page.getByRole('row', { name: '<a stable cell value in that row>' }).getByRole('button', { name: '<verb>' }).`
86898
+ });
86899
+ break;
86900
+ }
86901
+ }
86902
+ }
86903
+ return findings;
86904
+ }
85787
86905
  function validateFinalActionFidelity(playwrightCode, actionLedger, credentials, opts) {
85788
86906
  const ledgerSigs = [];
85789
86907
  for (const entry of actionLedger) {
@@ -86223,6 +87341,7 @@ ${tailBlock}`;
86223
87341
  let actionRevisionAttempted = false;
86224
87342
  let ambiguityRevisionAttempted = false;
86225
87343
  let strengthRevisionAttempted = false;
87344
+ let structuralRevisionAttempted = false;
86226
87345
  let lastRenderedLedgerEntryCount = 0;
86227
87346
  const successfulVerifications = [];
86228
87347
  const actionLedger = [];
@@ -86349,7 +87468,15 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
86349
87468
  tools: openaiTools,
86350
87469
  tool_choice: "auto",
86351
87470
  temperature: 0.2,
86352
- max_tokens: 8192
87471
+ // Shared provider-aware output ceiling (same helper the survey planner uses):
87472
+ // generous where the provider's max output is verified large (deepseek/minimax
87473
+ // 65536), conservative elsewhere (16384). The old flat 8192 truncated large
87474
+ // finish_generate emissions mid-JSON — the loop re-prompted on the parse error
87475
+ // (line ~4744) and, for a test that genuinely needs >8192 output tokens, could
87476
+ // never finish before the hard stop → zero tests for that scenario. It's a
87477
+ // ceiling, not a target (calls finish at finish_reason:'stop' well under it), so
87478
+ // this adds no per-call cost.
87479
+ max_tokens: (0, discover_ai_planner_js_1.getAIPlannerMaxTokens)(agentModel)
86353
87480
  }, { signal });
86354
87481
  }), {
86355
87482
  label: `scenario-gen-iter-${iteration}`,
@@ -86493,6 +87620,33 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
86493
87620
  });
86494
87621
  continue;
86495
87622
  }
87623
+ const structuralLintMode = (0, discovery_lint_flags_js_1.structuralLocatorLintMode)();
87624
+ const structuralFindings = structuralLintMode !== "off" ? scanFragileStructuralLocators(validated[0].playwrightCode) : [];
87625
+ if (structuralLintMode === "warn" && structuralFindings.length > 0) {
87626
+ logs2.push(` Structural-locator gate [warn]: ${structuralFindings.length} positional table-row selector(s) \u2014 ${structuralFindings.map((f) => sanitizeLedgerText(f.line)).join(" | ").slice(0, 300)}`);
87627
+ }
87628
+ if (structuralLintMode === "enforce" && structuralFindings.length > 0 && !structuralRevisionAttempted) {
87629
+ structuralRevisionAttempted = true;
87630
+ const renderedLedger = actionLedger.length > 0 ? renderSessionRecording(actionLedger, credentials) : "";
87631
+ if (renderedLedger)
87632
+ lastRenderedLedgerEntryCount = actionLedger.length;
87633
+ logs2.push(` Structural-locator gate: ${structuralFindings.length} positional table-row selector(s) found \u2014 requesting one revision (recording attached: ${renderedLedger ? "yes" : "no"})`);
87634
+ const structuralPayload = {
87635
+ acknowledged: false,
87636
+ valid: false,
87637
+ revisionRequired: true,
87638
+ reason: "fragile_structural_locator",
87639
+ instruction: "One or more locators below select a table row by POSITION (tr:last-child, tr:nth-child, td:nth-child) instead of by identity. Positional row selectors break the moment rows are reordered, sorted, filtered, or a row is inserted.\nFor EACH flagged locator:\n1. Scope through the row IDENTITY: page.getByRole('row', { name: '<a stable cell value in that row \u2014 an id, name, email>' }).getByRole('button', { name: '<verb>' }).\n2. If the row genuinely has no stable text, use a data-testid on the row if one exists.\n3. Keep every other line exactly as-is. Submit the COMPLETE corrected test and call finish_generate again.",
87640
+ offendingLocators: structuralFindings.map((f) => ({
87641
+ line: sanitizeLedgerText(f.line),
87642
+ reason: f.reason
87643
+ }))
87644
+ };
87645
+ if (renderedLedger)
87646
+ structuralPayload.sessionRecording = renderedLedger;
87647
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(structuralPayload) });
87648
+ continue;
87649
+ }
86496
87650
  const ambiguityGateEval = evaluateAmbiguityGate(validated[0].playwrightCode, ambiguityRevisionAttempted, iteration < MAX_SCENARIO_ITERATIONS);
86497
87651
  const ambiguousLocators = ambiguityGateEval.findings;
86498
87652
  if (ambiguityGateEval.fire) {
@@ -86589,12 +87743,16 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86589
87743
  const strengthSeverity = strengthVerdict?.severity ?? "none";
86590
87744
  const hasResidualViolations = qualityCheck.offendingLines.length > 0;
86591
87745
  const hasResidualActionDrift = actionCheck.offendingActions.length > 0;
87746
+ const hasResidualStructural = structuralLintMode === "enforce" && structuralFindings.length > 0;
86592
87747
  if (hasResidualViolations) {
86593
87748
  logs2.push(` Assertion quality gate: violations remain after revision \u2014 downgrading to @needs-review`);
86594
87749
  }
86595
87750
  if (hasResidualActionDrift) {
86596
87751
  logs2.push(` Action fidelity gate: ${actionCheck.offendingActions.length} action drift(s) remain after revision \u2014 downgrading to @needs-review`);
86597
87752
  }
87753
+ if (hasResidualStructural) {
87754
+ logs2.push(` Structural-locator gate: ${structuralFindings.length} positional table-row selector(s) remain after revision \u2014 downgrading to @needs-review`);
87755
+ }
86598
87756
  let actualVerified = verified;
86599
87757
  if (verified && verifyCallCount === 0) {
86600
87758
  actualVerified = false;
@@ -86647,6 +87805,16 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86647
87805
  residual: ambiguousLocators.length,
86648
87806
  lines: ambiguousLocators.map((a) => sanitizeLedgerText(a.line))
86649
87807
  },
87808
+ // G2 — omitted entirely when off so the off-path healingContext is
87809
+ // byte-identical; present in warn (measure) + enforce (act) modes.
87810
+ ...structuralLintMode !== "off" ? {
87811
+ structuralGate: {
87812
+ mode: structuralLintMode,
87813
+ fired: structuralRevisionAttempted,
87814
+ residual: structuralFindings.length,
87815
+ lines: structuralFindings.map((f) => sanitizeLedgerText(f.line))
87816
+ }
87817
+ } : {},
86650
87818
  // Durable marker the server promote-seam (failure-analyzer.checkTestStability)
86651
87819
  // reads to decide ACTIVE vs QUARANTINED(@bug-found) vs hold-DRAFT. Omitted when
86652
87820
  // the gate errored (fail-open) so a missing marker is treated as "promote".
@@ -86672,7 +87840,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86672
87840
  verified: actualVerified,
86673
87841
  healingContext: { ...baseHealingContext, gateActivity }
86674
87842
  };
86675
- if ((hasResidualViolations || hasResidualActionDrift || hasUncoveredOutcomes) && !result.tags.includes("@needs-review")) {
87843
+ if ((hasResidualViolations || hasResidualActionDrift || hasUncoveredOutcomes || hasResidualStructural) && !result.tags.includes("@needs-review")) {
86676
87844
  result.tags.push("@needs-review");
86677
87845
  }
86678
87846
  if (actualVerified) {
@@ -320906,675 +322074,6 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
320906
322074
  }
320907
322075
  });
320908
322076
 
320909
- // ../runner-core/dist/services/mobile/mobile-exploration-state.js
320910
- var require_mobile_exploration_state = __commonJS({
320911
- "../runner-core/dist/services/mobile/mobile-exploration-state.js"(exports2) {
320912
- "use strict";
320913
- Object.defineProperty(exports2, "__esModule", { value: true });
320914
- exports2.MobileExplorationState = void 0;
320915
- exports2.stableMobileControlIdentity = stableMobileControlIdentity;
320916
- exports2.classifySafeMobileNavigationControl = classifySafeMobileNavigationControl;
320917
- exports2.deriveScreenId = deriveScreenId;
320918
- var mobile_observation_js_1 = require_mobile_observation();
320919
- var DEFAULT_STALE_THRESHOLD = 4;
320920
- var MAX_SCREEN_OBSERVATIONS = 50;
320921
- var EDITABLE_CONTROL_ROLE_RE = /(?:android\.widget\.(?:EditText|AutoCompleteTextView)|XCUIElementType(?:TextField|SecureTextField|SearchField|TextView)|textbox|searchbox|combobox|textarea|select)$/i;
320922
- var STATEFUL_CONTROL_ROLE_RE = /(?:Switch|CheckBox|Checkbox|RadioButton|radio|Slider|SeekBar|Stepper|PickerWheel|Toggle)$/i;
320923
- var UNSAFE_OR_COMMIT_LABEL_RE = /\b(?:submit|save|confirm|delete|remove|archive|reset|disconnect|deactivate|destroy|purge|pay|purchase|buy|checkout|place\s+order|send|post|publish|upload|import|export|sign\s*out|log\s*out|logout|accept|agree|subscribe|unsubscribe|follow|unfollow|enable|disable|book|reserve|cancel|continue|next|previous|done|apply|retry|close|dismiss|sign\s*in|log\s*in|register)\b/i;
320924
- var SAFE_MODAL_TRIGGER_LABEL_RE = /^(?:create|new|add|edit|invite|configure|manage|details|preview|share)$/i;
320925
- var MENU_TRIGGER_LABEL_RE = /^(?:more|menu|options|actions|filter|filters|sort|columns|customize)$/i;
320926
- var GENERIC_NAVIGATION_DESTINATION_LABEL_RE = /^(?:home|feed|dashboard|profile|settings|inbox|messages?|notifications?|projects?|about|privacy|terms|learn\s+more)$/i;
320927
- var SYSTEM_BACK_CONTROL_LABEL_RE = /^(?:back|go\s+back|navigate\s+up|navigation\s+up|up)$/i;
320928
- var SYSTEM_BACK_CONTROL_ID_RE = /^(?:action_bar_up|arrow_back|back_arrow|navigate_up|navigation_up|nav_up|toolbar_back|action_back|btn_back|button_back|back_button|back_btn|back_icon|icon_back)$/i;
320929
- function normalizeControlIdentityPart(value) {
320930
- return value.trim().replace(/\s+/g, " ").toLowerCase();
320931
- }
320932
- function semanticControlAlias(label) {
320933
- return `navigation-label:${normalizeControlIdentityPart(label)}`;
320934
- }
320935
- function localControlCoverageIdentity(screenId, identity) {
320936
- return `screen:${screenId}:${identity}`;
320937
- }
320938
- function storedMobileControlIdentity(candidate) {
320939
- return [
320940
- candidate.identity,
320941
- `kind:${candidate.kind}`,
320942
- `label:${normalizeControlIdentityPart(candidate.label)}`
320943
- ].join(":");
320944
- }
320945
- function isSystemBackNavigationControl(element, label) {
320946
- if (SYSTEM_BACK_CONTROL_LABEL_RE.test(normalizeControlIdentityPart(label)))
320947
- return true;
320948
- return [element.accessibilityId, element.resourceId].filter((value) => Boolean(value?.trim())).some((value) => {
320949
- const leaf = normalizeControlIdentityPart(value).split(/[/:.]/).filter(Boolean).at(-1) ?? "";
320950
- return SYSTEM_BACK_CONTROL_ID_RE.test(leaf);
320951
- });
320952
- }
320953
- function stableMobileControlIdentity(element) {
320954
- const role = normalizeControlIdentityPart(element.role || "control");
320955
- const accessibilityId = element.accessibilityId?.trim();
320956
- if (accessibilityId)
320957
- return `a11y:${role}:${normalizeControlIdentityPart(accessibilityId)}`;
320958
- const resourceId = element.resourceId?.trim();
320959
- if (resourceId)
320960
- return `resource:${role}:${normalizeControlIdentityPart(resourceId)}`;
320961
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320962
- if (label)
320963
- return `semantic:${role}:${normalizeControlIdentityPart(label)}`;
320964
- return null;
320965
- }
320966
- function classifySafeMobileNavigationControl(element) {
320967
- if (!element.actionable || element.enabled === false)
320968
- return null;
320969
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320970
- const safetyIdentity = [label, element.accessibilityId, element.resourceId].filter(Boolean).join(" ");
320971
- if (!label || UNSAFE_OR_COMMIT_LABEL_RE.test(safetyIdentity))
320972
- return null;
320973
- if (isSystemBackNavigationControl(element, label))
320974
- return null;
320975
- const role = element.role.trim();
320976
- if (EDITABLE_CONTROL_ROLE_RE.test(role) || STATEFUL_CONTROL_ROLE_RE.test(role))
320977
- return null;
320978
- const linkLike = /(?:Link$|^a$)/i.test(role);
320979
- let kind = null;
320980
- if (/(?:TabBarItem|\btab\b)/i.test(role))
320981
- kind = "tab";
320982
- else if (/menuitem/i.test(role))
320983
- kind = "menu-item";
320984
- else if (linkLike && element.navigationProvenance)
320985
- kind = "link";
320986
- else if (linkLike)
320987
- return null;
320988
- else if (element.hasPopup && element.hasPopup.toLowerCase() !== "false")
320989
- kind = "menu-trigger";
320990
- else if (element.expanded === false)
320991
- kind = "disclosure";
320992
- else if (MENU_TRIGGER_LABEL_RE.test(label))
320993
- kind = "menu-trigger";
320994
- else if (SAFE_MODAL_TRIGGER_LABEL_RE.test(label))
320995
- kind = "modal-trigger";
320996
- else if (element.navigationProvenance)
320997
- kind = "navigation-button";
320998
- else if (GENERIC_NAVIGATION_DESTINATION_LABEL_RE.test(label))
320999
- kind = "navigation-button";
321000
- if (!kind)
321001
- return null;
321002
- const identity = stableMobileControlIdentity(element);
321003
- return identity ? { identity, label, kind } : null;
321004
- }
321005
- function sharedMobileControlCoverageIdentity(element, candidate, semanticFamilyId) {
321006
- if (!element.navigationProvenance)
321007
- return void 0;
321008
- const durableId = element.accessibilityId?.trim() ? `a11y:${normalizeControlIdentityPart(element.accessibilityId)}` : element.resourceId?.trim() ? `resource:${normalizeControlIdentityPart(element.resourceId)}` : "";
321009
- if (!durableId && !semanticFamilyId)
321010
- return void 0;
321011
- return [
321012
- "shared-navigation",
321013
- element.navigationProvenance,
321014
- !durableId && semanticFamilyId ? `family:${semanticFamilyId}` : "",
321015
- candidate.kind,
321016
- durableId || "semantic-only",
321017
- normalizeControlIdentityPart(candidate.label)
321018
- ].filter(Boolean).join(":");
321019
- }
321020
- function mobileNavigationRegion(element, windowSize) {
321021
- if (!windowSize || windowSize.width <= 0 || windowSize.height <= 0)
321022
- return "unknown";
321023
- const centerX = (element.bounds.x + element.bounds.width / 2) / windowSize.width;
321024
- const centerY = (element.bounds.y + element.bounds.height / 2) / windowSize.height;
321025
- if (centerY >= 0.72)
321026
- return "bottom";
321027
- if (centerY <= 0.28)
321028
- return "top";
321029
- if (centerX <= 0.35)
321030
- return "left";
321031
- if (centerX >= 0.65)
321032
- return "right";
321033
- return "center";
321034
- }
321035
- function buildSemanticNavigationGroups(elements, windowSize) {
321036
- const groups = /* @__PURE__ */ new Map();
321037
- for (const element of elements) {
321038
- if (!element.navigationProvenance)
321039
- continue;
321040
- const candidate = classifySafeMobileNavigationControl(element);
321041
- if (!candidate)
321042
- continue;
321043
- const groupKey = `${element.navigationProvenance}:${mobileNavigationRegion(element, windowSize)}`;
321044
- const labels = groups.get(groupKey) ?? /* @__PURE__ */ new Set();
321045
- labels.add(`${candidate.kind}:${normalizeControlIdentityPart(candidate.label)}`);
321046
- groups.set(groupKey, labels);
321047
- }
321048
- return new Map([...groups].filter(([, labels]) => labels.size >= 2));
321049
- }
321050
- function deriveScreenId(signal) {
321051
- const namespace = signal.activity?.trim() || signal.bundleId?.trim();
321052
- return namespace ? `${namespace}#${signal.screenHash}` : signal.screenHash;
321053
- }
321054
- function observedElementIdentity(element) {
321055
- return (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(element);
321056
- }
321057
- function mergeObservedElements(previous, current) {
321058
- const merged = [...current];
321059
- const seen = new Set(current.map(observedElementIdentity));
321060
- for (const element of previous) {
321061
- const identity = observedElementIdentity(element);
321062
- if (seen.has(identity))
321063
- continue;
321064
- seen.add(identity);
321065
- merged.push(element);
321066
- }
321067
- return merged;
321068
- }
321069
- function sanitizeElementsForEvidence(elements) {
321070
- return elements.map((element) => {
321071
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element);
321072
- return {
321073
- ...element,
321074
- ...label ? { label } : { label: void 0 },
321075
- text: void 0
321076
- };
321077
- });
321078
- }
321079
- var MobileExplorationState = class {
321080
- /** screenId -> ScreenState (the mobile analog of routeState). */
321081
- screens = /* @__PURE__ */ new Map();
321082
- /** Observed directed transitions between screens. */
321083
- transitions = [];
321084
- /** Transition-announced screens that do not yet have a live observation. */
321085
- frontier = /* @__PURE__ */ new Map();
321086
- /** Safe navigation controls observed on each screen, keyed by stable identity. */
321087
- safeControlsByScreen = /* @__PURE__ */ new Map();
321088
- /** Screen-scoped coverage keys plus explicitly proven shared-navigation keys. */
321089
- attemptedSafeControlCoverageIdentities = /* @__PURE__ */ new Set();
321090
- successfulSafeControlAttemptCount = 0;
321091
- /** Repeated id-less structural navigation families, grouped by region. */
321092
- semanticNavigationFamilies = /* @__PURE__ */ new Map();
321093
- nextSemanticNavigationFamilyId = 1;
321094
- /** The screen-id of the most recently observed screen. */
321095
- currentScreenId = null;
321096
- /** Latest iteration recorded (drives stale detection + firstSeen stamps). */
321097
- iteration = 0;
321098
- /** Stale detector: progress mark (#screens + #total elements) at last advance. */
321099
- lastProgressMark = 0;
321100
- /** Consecutive iterations observed with no progress. */
321101
- staleIterations = 0;
321102
- staleThreshold;
321103
- constructor(options = {}) {
321104
- this.staleThreshold = typeof options.staleThreshold === "number" && options.staleThreshold > 0 ? Math.floor(options.staleThreshold) : DEFAULT_STALE_THRESHOLD;
321105
- }
321106
- // -------------------------------------------------------------------------
321107
- // Observation recording
321108
- // -------------------------------------------------------------------------
321109
- /**
321110
- * Returns true when `signal` identifies a screen this run has never observed
321111
- * before. Pure read — does not mutate state.
321112
- */
321113
- isNewScreen(signal) {
321114
- return !this.screens.has(deriveScreenId(signal));
321115
- }
321116
- /**
321117
- * Record an observation of `screen` at `iteration`. Creates a `ScreenState`
321118
- * on first sight (stamping `firstSeenIteration`), otherwise refreshes the
321119
- * stored element set (latest-wins, mirroring the web reducer's PAGE_CAPTURED
321120
- * element refresh). A driver observation already contains the structural UI
321121
- * tree, elements, screenshot association, and screen identity, so it is
321122
- * captured evidence immediately; the model's capture_screen call only adds
321123
- * a friendly name/type. Returns the resolved screen-id.
321124
- */
321125
- recordObservation(signal, screen, iteration) {
321126
- this.iteration = iteration;
321127
- const screenId = deriveScreenId(signal);
321128
- const evidenceElements = sanitizeElementsForEvidence(screen.elements);
321129
- const existing = this.screens.get(screenId);
321130
- if (existing) {
321131
- this.screens.set(screenId, {
321132
- ...existing,
321133
- elements: mergeObservedElements(existing.elements, evidenceElements)
321134
- });
321135
- } else {
321136
- this.screens.set(screenId, {
321137
- screenId,
321138
- firstSeenIteration: iteration,
321139
- elements: evidenceElements,
321140
- visited: true
321141
- });
321142
- }
321143
- this.frontier.delete(screenId);
321144
- const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
321145
- const semanticNavigationGroups = buildSemanticNavigationGroups(evidenceElements, screen.windowSize);
321146
- const semanticNavigationFamilyIds = /* @__PURE__ */ new Map();
321147
- for (const [groupKey, labels] of semanticNavigationGroups) {
321148
- semanticNavigationFamilyIds.set(groupKey, this.resolveSemanticNavigationFamilyId(groupKey, labels));
321149
- }
321150
- for (const element of evidenceElements) {
321151
- const candidate = classifySafeMobileNavigationControl(element);
321152
- if (!candidate)
321153
- continue;
321154
- const storedIdentity = storedMobileControlIdentity(candidate);
321155
- const semanticGroupKey = element.navigationProvenance ? `${element.navigationProvenance}:${mobileNavigationRegion(element, screen.windowSize)}` : "";
321156
- controls.set(storedIdentity, {
321157
- ...candidate,
321158
- identity: storedIdentity,
321159
- sharedCoverageIdentity: sharedMobileControlCoverageIdentity(element, candidate, semanticNavigationFamilyIds.get(semanticGroupKey))
321160
- });
321161
- }
321162
- if (controls.size > 0)
321163
- this.safeControlsByScreen.set(screenId, controls);
321164
- this.currentScreenId = screenId;
321165
- this.updateStaleProgress();
321166
- return screenId;
321167
- }
321168
- /**
321169
- * Back-compatible acknowledgement from capture_screen. Observation already
321170
- * marks structural evidence visited; this call only resolves a rare pending
321171
- * transition frontier and preserves callers that annotate explicitly.
321172
- */
321173
- markVisited(screenId) {
321174
- const state2 = this.screens.get(screenId);
321175
- if (state2 && !state2.visited) {
321176
- this.screens.set(screenId, { ...state2, visited: true });
321177
- }
321178
- this.frontier.delete(screenId);
321179
- }
321180
- /**
321181
- * Record a confirmed tap against a safe navigation candidate. Callers invoke
321182
- * this only after the driver reports success; recovery-unknown, stale-ref, and
321183
- * failed actions must remain on the breadth frontier.
321184
- */
321185
- noteControlAttempt(screenId, element) {
321186
- if (!screenId)
321187
- return false;
321188
- const candidate = classifySafeMobileNavigationControl(element);
321189
- const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
321190
- if (candidate) {
321191
- const storedIdentity = storedMobileControlIdentity(candidate);
321192
- const existing = controls.get(storedIdentity);
321193
- controls.set(storedIdentity, {
321194
- ...candidate,
321195
- identity: storedIdentity,
321196
- sharedCoverageIdentity: existing?.sharedCoverageIdentity ?? sharedMobileControlCoverageIdentity(element, candidate)
321197
- });
321198
- this.safeControlsByScreen.set(screenId, controls);
321199
- }
321200
- const tappedLabel = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
321201
- if (!tappedLabel)
321202
- return false;
321203
- const before = this.attemptedSafeControlCoverageIdentities.size;
321204
- const alias = semanticControlAlias(tappedLabel);
321205
- for (const control of controls.values()) {
321206
- if (semanticControlAlias(control.label) === alias) {
321207
- this.attemptedSafeControlCoverageIdentities.add(localControlCoverageIdentity(screenId, control.identity));
321208
- if (control.sharedCoverageIdentity) {
321209
- this.attemptedSafeControlCoverageIdentities.add(control.sharedCoverageIdentity);
321210
- }
321211
- }
321212
- }
321213
- if (this.attemptedSafeControlCoverageIdentities.size > before) {
321214
- this.successfulSafeControlAttemptCount += 1;
321215
- this.staleIterations = 0;
321216
- return true;
321217
- }
321218
- return false;
321219
- }
321220
- /** Attach a human-friendly name / coarse type to a screen (capture_screen). */
321221
- annotateScreen(screenId, annotation) {
321222
- const state2 = this.screens.get(screenId);
321223
- if (!state2)
321224
- return;
321225
- this.screens.set(screenId, {
321226
- ...state2,
321227
- screenName: annotation.screenName ?? state2.screenName,
321228
- screenType: annotation.screenType ?? state2.screenType
321229
- });
321230
- }
321231
- /** Attach structurally observed authentication provenance to a screen. */
321232
- setScreenAuthEvidence(screenId, evidence) {
321233
- const state2 = this.screens.get(screenId);
321234
- if (!state2)
321235
- return;
321236
- this.screens.set(screenId, {
321237
- ...state2,
321238
- requiresAuth: evidence.requiresAuth,
321239
- authProvenance: evidence.authProvenance,
321240
- sessionState: { ...evidence.sessionState }
321241
- });
321242
- }
321243
- /** Append a free-form observation note to a screen (observations tool). */
321244
- addScreenObservation(screenId, note) {
321245
- const state2 = this.screens.get(screenId);
321246
- if (!state2)
321247
- return;
321248
- const normalized = note.trim();
321249
- if (!normalized)
321250
- return;
321251
- const current = state2.observations ?? [];
321252
- if (current.some((existing) => existing.trim().toLowerCase() === normalized.toLowerCase())) {
321253
- return;
321254
- }
321255
- const observations = [...current, normalized].slice(-MAX_SCREEN_OBSERVATIONS);
321256
- this.screens.set(screenId, { ...state2, observations });
321257
- }
321258
- /**
321259
- * Record a directed transition between two screens. Both endpoints are
321260
- * ensured to exist on the frontier if not yet observed (mirrors the web
321261
- * reducer adding both ends of a TRANSITION_RECORDED to the frontier). The
321262
- * `toScreenId` becomes the current screen when the transition starts from the
321263
- * current screen.
321264
- */
321265
- recordTransition(transition) {
321266
- let recordedTransition = transition;
321267
- if (!transition.viaControlIdentity && transition.viaRef) {
321268
- const sourceElement = this.screens.get(transition.fromScreenId)?.elements.find((element) => element.ref === transition.viaRef);
321269
- if (sourceElement) {
321270
- recordedTransition = {
321271
- ...transition,
321272
- viaControlIdentity: (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(sourceElement)
321273
- };
321274
- }
321275
- }
321276
- const alreadyRecorded = this.transitions.some((existing) => existing.fromScreenId === recordedTransition.fromScreenId && existing.toScreenId === recordedTransition.toScreenId && existing.action === recordedTransition.action);
321277
- if (!alreadyRecorded) {
321278
- this.transitions.push(recordedTransition);
321279
- }
321280
- for (const endpointId of [transition.fromScreenId, transition.toScreenId]) {
321281
- if (!this.screens.has(endpointId) && !this.frontier.has(endpointId)) {
321282
- this.frontier.set(endpointId, {
321283
- screenId: endpointId,
321284
- source: "transition",
321285
- discoveredAtIteration: this.iteration
321286
- });
321287
- }
321288
- }
321289
- if (transition.fromScreenId === this.currentScreenId) {
321290
- this.currentScreenId = transition.toScreenId;
321291
- }
321292
- }
321293
- // -------------------------------------------------------------------------
321294
- // Selectors
321295
- // -------------------------------------------------------------------------
321296
- /** The current screen-id (most recently observed), or null before any observation. */
321297
- getCurrentScreenId() {
321298
- return this.currentScreenId;
321299
- }
321300
- /** ScreenState for `screenId`, if known. */
321301
- getScreen(screenId) {
321302
- return this.screens.get(screenId);
321303
- }
321304
- /** Number of distinct screens observed this run. */
321305
- getScreenCount() {
321306
- return this.screens.size;
321307
- }
321308
- /** Number of screens backed by a live structural driver observation. */
321309
- getVisitedScreenCount() {
321310
- let total = 0;
321311
- for (const state2 of this.screens.values()) {
321312
- if (state2.visited)
321313
- total += 1;
321314
- }
321315
- return total;
321316
- }
321317
- /** Total elements observed across all known screens (latest snapshot each). */
321318
- getTotalElementCount() {
321319
- let total = 0;
321320
- for (const state2 of this.screens.values()) {
321321
- total += state2.elements.length;
321322
- }
321323
- return total;
321324
- }
321325
- /**
321326
- * Frontier screen-ids ranked by recency (most recently discovered first),
321327
- * excluding any screen already visited. Mirrors the web `getRankedFrontier`
321328
- * intent (filter visited, rank by recency) — without URL prefix similarity,
321329
- * which has no mobile analog.
321330
- */
321331
- getFrontier() {
321332
- return [...this.frontier.values()].filter((item) => {
321333
- const state2 = this.screens.get(item.screenId);
321334
- return !state2 || !state2.visited;
321335
- }).sort((a, b) => b.discoveredAtIteration - a.discoveredAtIteration).map((item) => item.screenId);
321336
- }
321337
- /** Safe navigation candidates not yet attempted, deduped across shared chrome. */
321338
- getUnexploredSafeNavigationCandidates() {
321339
- const authenticatedProductObserved = [...this.screens.values()].some((screen) => screen.requiresAuth === true);
321340
- const candidates = [];
321341
- for (const [screenId, controls] of this.safeControlsByScreen) {
321342
- const origin = this.screens.get(screenId);
321343
- const originIsConfirmedPublicEntry = origin?.authProvenance === "public" || !origin?.authProvenance && origin?.requiresAuth !== true && /^(?:AUTH|LANDING)$/i.test(origin?.screenType ?? "");
321344
- if (authenticatedProductObserved && originIsConfirmedPublicEntry)
321345
- continue;
321346
- for (const control of controls.values()) {
321347
- const alias = semanticControlAlias(control.label);
321348
- const coverageIdentity = control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity);
321349
- if (this.attemptedSafeControlCoverageIdentities.has(coverageIdentity) || this.attemptedSafeControlCoverageIdentities.has(localControlCoverageIdentity(screenId, control.identity)))
321350
- continue;
321351
- const existing = candidates.find((candidate) => candidate.identity === coverageIdentity || candidate.alias === alias && candidate.screenIds.has(screenId));
321352
- if (existing) {
321353
- existing.screenIds.add(screenId);
321354
- } else {
321355
- candidates.push({
321356
- identity: coverageIdentity,
321357
- label: control.label,
321358
- kind: control.kind,
321359
- alias,
321360
- screenIds: /* @__PURE__ */ new Set([screenId])
321361
- });
321362
- }
321363
- }
321364
- }
321365
- return candidates.map((candidate) => ({
321366
- identity: candidate.identity,
321367
- label: candidate.label,
321368
- kind: candidate.kind,
321369
- screenIds: [...candidate.screenIds]
321370
- })).sort((a, b) => {
321371
- const aCurrent = this.currentScreenId !== null && a.screenIds.includes(this.currentScreenId);
321372
- const bCurrent = this.currentScreenId !== null && b.screenIds.includes(this.currentScreenId);
321373
- if (aCurrent !== bCurrent)
321374
- return aCurrent ? -1 : 1;
321375
- return b.screenIds.length - a.screenIds.length;
321376
- });
321377
- }
321378
- /** Harness-owned survey completion floor, independent of model assertions. */
321379
- getSurveyBreadthCoverage() {
321380
- const unvisitedScreenIds = this.getFrontier();
321381
- const unexploredControls = this.getUnexploredSafeNavigationCandidates();
321382
- const complete = unvisitedScreenIds.length === 0 && unexploredControls.length === 0;
321383
- const missing = [];
321384
- if (unvisitedScreenIds.length > 0) {
321385
- missing.push(`${unvisitedScreenIds.length} uncaptured screen(s)`);
321386
- }
321387
- if (unexploredControls.length > 0) {
321388
- missing.push(`${unexploredControls.length} safe navigation control(s)`);
321389
- }
321390
- return {
321391
- required: true,
321392
- complete,
321393
- unvisitedScreenIds,
321394
- unexploredControls,
321395
- attemptedControlCount: this.successfulSafeControlAttemptCount,
321396
- ...missing.length > 0 ? { reason: `Missing ${missing.join(" and ")}.` } : {}
321397
- };
321398
- }
321399
- /**
321400
- * Monotonic structural progress token for adaptive survey leases. Frontier
321401
- * cardinality alone is insufficient: attempting control A can reveal control
321402
- * B and leave the remaining count unchanged even though real work happened.
321403
- */
321404
- getSurveyBreadthProgressMark() {
321405
- const discoveredControlIdentities = /* @__PURE__ */ new Set();
321406
- for (const [screenId, controls] of this.safeControlsByScreen) {
321407
- for (const control of controls.values()) {
321408
- discoveredControlIdentities.add(control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity));
321409
- }
321410
- }
321411
- return [
321412
- `screens:${this.screens.size}`,
321413
- `attempts:${this.successfulSafeControlAttemptCount}`,
321414
- `controls:${discoveredControlIdentities.size}`,
321415
- `transitions:${this.transitions.length}`
321416
- ].join("|");
321417
- }
321418
- resolveSemanticNavigationFamilyId(groupKey, labels) {
321419
- const families = this.semanticNavigationFamilies.get(groupKey) ?? [];
321420
- let best;
321421
- let bestIntersection = 0;
321422
- for (const family of families) {
321423
- let intersection = 0;
321424
- for (const label of labels) {
321425
- if (family.labels.has(label))
321426
- intersection += 1;
321427
- }
321428
- const overlap = intersection / Math.min(labels.size, family.labels.size);
321429
- if (intersection >= 2 && overlap >= 0.6 && intersection > bestIntersection) {
321430
- best = family;
321431
- bestIntersection = intersection;
321432
- }
321433
- }
321434
- if (best) {
321435
- for (const label of labels)
321436
- best.labels.add(label);
321437
- return best.id;
321438
- }
321439
- const created = {
321440
- id: `semantic-nav-family-${this.nextSemanticNavigationFamilyId++}`,
321441
- labels: new Set(labels)
321442
- };
321443
- families.push(created);
321444
- this.semanticNavigationFamilies.set(groupKey, families);
321445
- return created.id;
321446
- }
321447
- /** Observed transitions (defensive copy). */
321448
- getTransitions() {
321449
- return [...this.transitions];
321450
- }
321451
- // -------------------------------------------------------------------------
321452
- // Stale-progress detection (ported semantics from the web stale guard)
321453
- // -------------------------------------------------------------------------
321454
- /**
321455
- * Progress mark: distinct screens weighted heavily, plus total elements seen.
321456
- * A run advances this mark whenever it finds a new screen OR new elements;
321457
- * the stale detector triggers when the mark stops advancing for
321458
- * `staleThreshold` consecutive iterations.
321459
- */
321460
- getProgressMark() {
321461
- return this.screens.size * 1e3 + this.transitions.length * 100 + this.successfulSafeControlAttemptCount * 10 + this.getTotalElementCount();
321462
- }
321463
- updateStaleProgress() {
321464
- const mark = this.getProgressMark();
321465
- if (mark > this.lastProgressMark) {
321466
- this.lastProgressMark = mark;
321467
- this.staleIterations = 0;
321468
- } else {
321469
- this.staleIterations += 1;
321470
- }
321471
- }
321472
- /** Consecutive no-progress iterations observed so far. */
321473
- getStaleIterations() {
321474
- return this.staleIterations;
321475
- }
321476
- /**
321477
- * True when the run has gone `staleThreshold` consecutive iterations with no
321478
- * new screen and no new element — the signal the explorer uses to nudge or
321479
- * finish (mirrors the web explorer's stale guard).
321480
- */
321481
- isStaleProgress() {
321482
- return this.staleIterations >= this.staleThreshold;
321483
- }
321484
- // -------------------------------------------------------------------------
321485
- // Planner evidence export
321486
- // -------------------------------------------------------------------------
321487
- /**
321488
- * Snapshot the screen-graph as a plain object (frozen mobile-types
321489
- * `MobileScreenGraph`). Defensive copies so callers cannot mutate internal
321490
- * state.
321491
- */
321492
- toScreenGraph() {
321493
- const screens = /* @__PURE__ */ new Map();
321494
- for (const [id, state2] of this.screens) {
321495
- screens.set(id, {
321496
- ...state2,
321497
- elements: [...state2.elements],
321498
- observations: state2.observations ? [...state2.observations] : void 0,
321499
- sessionState: state2.sessionState ? { ...state2.sessionState } : void 0
321500
- });
321501
- }
321502
- return {
321503
- screens,
321504
- transitions: this.transitions.map((t) => ({
321505
- fromScreenId: t.fromScreenId,
321506
- toScreenId: t.toScreenId,
321507
- viaRef: t.viaRef,
321508
- viaControlIdentity: t.viaControlIdentity,
321509
- action: t.action
321510
- }))
321511
- };
321512
- }
321513
- /**
321514
- * Build the planner evidence shape: one `CollectedPageData` per screen, with
321515
- * the screen-id carried in BOTH `route` (the on-the-wire positional field the
321516
- * server keys screenshots by) AND the explicit `screenId` field (per the
321517
- * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
321518
- * the screen's parsed actionable elements into the platform-neutral
321519
- * `CollectedPageElement[]` shape the server persists as SiteElements — without
321520
- * this, mobile screens land with zero elements (coverage always 0%, no element
321521
- * intelligence). `triggersNavigation` is derived from the durable source
321522
- * element identity captured when the transition happened (with ref-only
321523
- * fallback for legacy transitions). Insertion order matches first-seen order.
321524
- */
321525
- buildInMemoryScreenMap() {
321526
- const pages = [];
321527
- const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
321528
- const navRefsByScreen = /* @__PURE__ */ new Map();
321529
- const navIdentitiesByScreen = /* @__PURE__ */ new Map();
321530
- for (const transition of this.transitions) {
321531
- if (transition.viaControlIdentity) {
321532
- const set = navIdentitiesByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
321533
- set.add(transition.viaControlIdentity);
321534
- navIdentitiesByScreen.set(transition.fromScreenId, set);
321535
- } else if (transition.viaRef) {
321536
- const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
321537
- set.add(transition.viaRef);
321538
- navRefsByScreen.set(transition.fromScreenId, set);
321539
- }
321540
- }
321541
- for (const state2 of ordered) {
321542
- const page = {
321543
- route: state2.screenId,
321544
- screenId: state2.screenId,
321545
- pageType: state2.screenType ?? "UNKNOWN",
321546
- elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
321547
- navigationRefs: navRefsByScreen.get(state2.screenId),
321548
- navigationIdentities: navIdentitiesByScreen.get(state2.screenId)
321549
- }),
321550
- requiresAuth: state2.requiresAuth ?? false
321551
- };
321552
- if (state2.sessionState) {
321553
- page.sessionState = { ...state2.sessionState };
321554
- }
321555
- if (state2.screenName) {
321556
- page.title = state2.screenName;
321557
- }
321558
- if (state2.observations && state2.observations.length > 0) {
321559
- page.observations = state2.observations.map((note) => ({
321560
- action: "observe",
321561
- outcome: note,
321562
- outcomeType: "SUCCESS"
321563
- }));
321564
- }
321565
- pages.push(page);
321566
- }
321567
- return pages;
321568
- }
321569
- /** Alias for `buildInMemoryScreenMap()` — name parity with the planner-evidence call site. */
321570
- toScreenMap() {
321571
- return this.buildInMemoryScreenMap();
321572
- }
321573
- };
321574
- exports2.MobileExplorationState = MobileExplorationState;
321575
- }
321576
- });
321577
-
321578
322077
  // ../runner-core/dist/services/mobile/mobile-discovery-agent.js
321579
322078
  var require_mobile_discovery_agent = __commonJS({
321580
322079
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
@@ -321588,6 +322087,7 @@ var require_mobile_discovery_agent = __commonJS({
321588
322087
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
321589
322088
  var mobile_discovery_auth_js_1 = require_mobile_discovery_auth();
321590
322089
  var mobile_driver_errors_js_1 = require_mobile_driver_errors();
322090
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
321591
322091
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
321592
322092
  var discover_planner_js_1 = require_discover_planner();
321593
322093
  function checkpointErrorMessage(err) {
@@ -321870,7 +322370,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321870
322370
  };
321871
322371
  }
321872
322372
  if (isSurvey && explore.authCoverage && !explore.authCoverage.complete) {
321873
- throw new Error("Mobile survey authentication coverage is incomplete; planning was skipped. " + (explore.authCoverage.reason ?? "No authenticated product screen was proven."));
322373
+ log2("Mobile survey continuing to planning with incomplete authenticated coverage: " + (explore.authCoverage.reason ?? "No authenticated product screen was proven."));
321874
322374
  }
321875
322375
  if (isSurvey && explore.breadthCoverage && !explore.breadthCoverage.complete) {
321876
322376
  log2("Mobile survey continuing to planning with residual breadth gaps: " + (explore.breadthCoverage.reason ?? "Unvisited screens or safe navigation controls remain."));
@@ -321888,7 +322388,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321888
322388
  const graph = state2.toScreenGraph();
321889
322389
  const screenMap = state2.buildInMemoryScreenMap();
321890
322390
  const testBudget = (0, discover_planner_js_1.computeTestBudget)(explore.exploreStats.pagesVisited, ctx.focusAreas);
321891
- const planResult = await runPlan({
322391
+ const planResult = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(driver, () => runPlan({
321892
322392
  graph,
321893
322393
  mode,
321894
322394
  projectId: ctx.projectId,
@@ -321912,7 +322412,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321912
322412
  }, {
321913
322413
  modelOverride: ctx.planModel ?? ctx.generateModel,
321914
322414
  onAICall
321915
- });
322415
+ }));
321916
322416
  logs2.push(...planResult.logs);
321917
322417
  log2(`
321918
322418
  Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.length} feature groups (budget: ${testBudget})`);
@@ -322131,6 +322631,9 @@ Fatal error: ${msg}`);
322131
322631
  if (explore.breadthCoverage && !explore.breadthCoverage.complete) {
322132
322632
  warnings.push("Discovery retained the captured mobile screen graph with residual navigation gaps: " + (explore.breadthCoverage.reason ?? "some observed controls could not be revisited."));
322133
322633
  }
322634
+ if (explore.authCoverage && !explore.authCoverage.complete) {
322635
+ warnings.push("Discovery retained the captured mobile screen graph with incomplete authenticated coverage: " + (explore.authCoverage.reason ?? "no authenticated product screen was proven."));
322636
+ }
322134
322637
  if (explore.truncated) {
322135
322638
  const reasonLabel = explore.truncatedReason === "rate_limit" ? "AI rate-limit" : explore.truncatedReason === "timeout" ? "AI timeout" : "AI error";
322136
322639
  warnings.push(`Discovery may be incomplete: exploration aborted by ${reasonLabel}. Re-run after the limit clears for a complete screen graph.`);
@@ -322767,6 +323270,7 @@ Health: ${surveyHealth.totalIssues} issue(s) - ${surveyHealth.consoleErrors.leng
322767
323270
  if (surveyAuthRecipe) {
322768
323271
  logs2.push(`Auth-prologue: captured + verified login recipe ${surveyAuthRecipe.recipeHash} during survey \u2014 persisting for reuse on later runs`);
322769
323272
  }
323273
+ const plannerFailedError = planResult.plannerFailed ? `Survey planning failed (${planResult.plannerFailureKind ?? "unknown"}): ${planResult.fallbackReason ?? "planner did not produce a usable plan"}. Explore evidence was saved; re-run the survey to retry planning.` : void 0;
322770
323274
  return {
322771
323275
  observedOrigins: mergeObservedOrigins(exploreResult.observedOrigins, loginObservedOrigins),
322772
323276
  resolvedAppOrigin: surveyAuthRecipe?.appOrigin ?? void 0,
@@ -322782,7 +323286,8 @@ Health: ${surveyHealth.totalIssues} issue(s) - ${surveyHealth.consoleErrors.leng
322782
323286
  exploreTruncatedReason: exploreResult.truncatedReason,
322783
323287
  fallbackUsed: planResult.fallbackUsed ?? false,
322784
323288
  fallbackReason: planResult.fallbackReason,
322785
- featureGroups,
323289
+ error: plannerFailedError,
323290
+ featureGroups: plannerFailedError ? void 0 : featureGroups,
322786
323291
  apiCalls: discoveryApiCalls.length > 0 ? discoveryApiCalls : void 0,
322787
323292
  healthObservations: surveyHealth,
322788
323293
  exploreFinalMessages: exploreResult.finalMessages,
@@ -336640,7 +337145,7 @@ var require_package4 = __commonJS({
336640
337145
  "package.json"(exports2, module2) {
336641
337146
  module2.exports = {
336642
337147
  name: "@validate.qa/runner",
336643
- version: "1.0.38",
337148
+ version: "1.0.40",
336644
337149
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
336645
337150
  bin: {
336646
337151
  "validate-runner": "dist/cli.js"
@@ -339895,8 +340400,9 @@ async function activateTargetApp(sessionId, platform3, appId) {
339895
340400
  const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
339896
340401
  await executeMobileScript(sessionId, "mobile: activateApp", args);
339897
340402
  }
340403
+ var APP_TERMINATE_TIMEOUT_MS = 5e3;
339898
340404
  async function terminateTargetApp(sessionId, platform3, appId) {
339899
- const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
340405
+ const args = platform3 === "IOS" ? [{ bundleId: appId, timeout: APP_TERMINATE_TIMEOUT_MS }] : [{ appId, timeout: APP_TERMINATE_TIMEOUT_MS }];
339900
340406
  await executeMobileScript(sessionId, "mobile: terminateApp", args);
339901
340407
  }
339902
340408
  async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
@@ -342907,7 +343413,11 @@ var MobileBridgeDriver = class {
342907
343413
  async relaunch() {
342908
343414
  await this.withSessionRecovery("app relaunch", async () => {
342909
343415
  const args = this.appLifecycleArgs();
342910
- await this.executeScript("mobile: terminateApp", [args]);
343416
+ try {
343417
+ await this.executeScript("mobile: terminateApp", [{ ...args, timeout: 5e3 }]);
343418
+ } catch (err) {
343419
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
343420
+ }
342911
343421
  await this.executeScript("mobile: activateApp", [args]);
342912
343422
  try {
342913
343423
  await waitForColdBootSettle(this.sessionPath);
@@ -345452,10 +345962,22 @@ ${finalVerifyResult.logs ?? ""}`;
345452
345962
  onPlanReady
345453
345963
  });
345454
345964
  } finally {
345965
+ const TEARDOWN_STEP_TIMEOUT = /* @__PURE__ */ Symbol("vqa-teardown-timeout");
345966
+ const timeboxed = async (step) => Promise.race([
345967
+ step,
345968
+ new Promise((resolve2) => {
345969
+ const t = setTimeout(() => resolve2(TEARDOWN_STEP_TIMEOUT), 45e3);
345970
+ t.unref?.();
345971
+ })
345972
+ ]);
345455
345973
  const activeCapture = capture;
345456
345974
  if (activeCapture) {
345457
345975
  try {
345458
- const apiCalls = await activeCapture.stop();
345976
+ const stopped = await timeboxed(activeCapture.stop());
345977
+ if (stopped === TEARDOWN_STEP_TIMEOUT) {
345978
+ throw new Error("capture stop timed out after 45s \u2014 continuing teardown without apiCalls");
345979
+ }
345980
+ const apiCalls = stopped;
345459
345981
  const captureHealth = activeCapture.health();
345460
345982
  if (discoveryResult) {
345461
345983
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
@@ -345476,7 +345998,7 @@ ${finalVerifyResult.logs ?? ""}`;
345476
345998
  if (session) {
345477
345999
  detachMirrorSession(session.deviceId);
345478
346000
  try {
345479
- await session.stop();
346001
+ await timeboxed(session.stop());
345480
346002
  } catch {
345481
346003
  }
345482
346004
  }