@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.mjs CHANGED
@@ -275,7 +275,8 @@ var require_ai_provider = __commonJS({
275
275
  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "anthropic", description: "Anthropic - fast & intelligent, 200k ctx", contextWindow: 2e5 },
276
276
  { id: "nvidia-nemotron-nano", label: "NVIDIA Nemotron Nano", provider: "nvidia", description: "NVIDIA NIM - Nemotron 30B Nano, reasoning model, 128k ctx", contextWindow: 128e3 },
277
277
  { 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 },
278
- { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
278
+ { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 },
279
+ { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro", provider: "deepseek", description: "DeepSeek - V4 Pro, higher-quality reasoning tier, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
279
280
  ];
280
281
  var PROVIDER_DEFAULT_MODEL = {
281
282
  openai: "gpt-4o",
@@ -335,7 +336,8 @@ var require_ai_provider = __commonJS({
335
336
  "claude-sonnet-4-6": { strong: "claude-sonnet-4-6", fast: "claude-sonnet-4-6", agent: "claude-sonnet-4-6" },
336
337
  "nvidia-nemotron-nano": { strong: "nvidia/nemotron-3-nano-30b-a3b", fast: "nvidia/nemotron-3-nano-30b-a3b", agent: "nvidia/nemotron-3-nano-30b-a3b" },
337
338
  "nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" },
338
- "deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" }
339
+ "deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" },
340
+ "deepseek-v4-pro": { strong: "deepseek-v4-pro", fast: "deepseek-v4-pro", agent: "deepseek-v4-pro" }
339
341
  };
340
342
  var _requestModelStorage = new async_hooks_1.AsyncLocalStorage();
341
343
  function withModelContext(modelId, fn) {
@@ -397,7 +399,7 @@ var require_ai_provider = __commonJS({
397
399
  if (modelId === "nvidia/nemotron-3-nano-30b-a3b") {
398
400
  return { chat_template_kwargs: { enable_thinking: false } };
399
401
  }
400
- if (modelId === "deepseek-v4-flash") {
402
+ if (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro") {
401
403
  return { thinking: { type: "disabled" } };
402
404
  }
403
405
  return {};
@@ -3307,6 +3309,7 @@ var init_audit_pricing = __esm({
3307
3309
  "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4 },
3308
3310
  "MiniMax-M2": { input: 0.3, output: 1.2 },
3309
3311
  "deepseek-v4-flash": { input: 0.14, output: 0.28 },
3312
+ "deepseek-v4-pro": { input: 0.42, output: 0.84 },
3310
3313
  // Whisper bills per-minute, not per-token (OpenAI + Groq)
3311
3314
  "whisper-1": null,
3312
3315
  "whisper-large-v3": null,
@@ -5884,6 +5887,34 @@ var require_ai_queue = __commonJS({
5884
5887
  }
5885
5888
  });
5886
5889
 
5890
+ // ../runner-core/dist/services/mobile/mobile-session-keepalive.js
5891
+ var require_mobile_session_keepalive = __commonJS({
5892
+ "../runner-core/dist/services/mobile/mobile-session-keepalive.js"(exports) {
5893
+ "use strict";
5894
+ Object.defineProperty(exports, "__esModule", { value: true });
5895
+ exports.MOBILE_KEEPALIVE_INTERVAL_MS = void 0;
5896
+ exports.withMobileSessionKeepAlive = withMobileSessionKeepAlive;
5897
+ exports.MOBILE_KEEPALIVE_INTERVAL_MS = (() => {
5898
+ const raw = Number(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS);
5899
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 12e4;
5900
+ })();
5901
+ async function withMobileSessionKeepAlive(driver, fn, intervalMs = exports.MOBILE_KEEPALIVE_INTERVAL_MS) {
5902
+ if (intervalMs <= 0)
5903
+ return fn();
5904
+ const timer = setInterval(() => {
5905
+ void driver.windowSize().catch(() => {
5906
+ });
5907
+ }, intervalMs);
5908
+ timer.unref?.();
5909
+ try {
5910
+ return await fn();
5911
+ } finally {
5912
+ clearInterval(timer);
5913
+ }
5914
+ }
5915
+ }
5916
+ });
5917
+
5887
5918
  // ../runner-core/dist/services/mobile/mobile-observation.js
5888
5919
  var require_mobile_observation = __commonJS({
5889
5920
  "../runner-core/dist/services/mobile/mobile-observation.js"(exports) {
@@ -6218,6 +6249,689 @@ var require_mobile_observation = __commonJS({
6218
6249
  }
6219
6250
  });
6220
6251
 
6252
+ // ../runner-core/dist/services/mobile/mobile-exploration-state.js
6253
+ var require_mobile_exploration_state = __commonJS({
6254
+ "../runner-core/dist/services/mobile/mobile-exploration-state.js"(exports) {
6255
+ "use strict";
6256
+ Object.defineProperty(exports, "__esModule", { value: true });
6257
+ exports.MobileExplorationState = void 0;
6258
+ exports.isDestructiveActionLabel = isDestructiveActionLabel;
6259
+ exports.isDestructiveConfirmationInput = isDestructiveConfirmationInput;
6260
+ exports.stableMobileControlIdentity = stableMobileControlIdentity;
6261
+ exports.classifySafeMobileNavigationControl = classifySafeMobileNavigationControl;
6262
+ exports.deriveScreenId = deriveScreenId;
6263
+ var mobile_observation_js_1 = require_mobile_observation();
6264
+ var DEFAULT_STALE_THRESHOLD = 4;
6265
+ var MAX_SCREEN_OBSERVATIONS = 50;
6266
+ var EDITABLE_CONTROL_ROLE_RE = /(?:android\.widget\.(?:EditText|AutoCompleteTextView)|XCUIElementType(?:TextField|SecureTextField|SearchField|TextView)|textbox|searchbox|combobox|textarea|select)$/i;
6267
+ var STATEFUL_CONTROL_ROLE_RE = /(?:Switch|CheckBox|Checkbox|RadioButton|radio|Slider|SeekBar|Stepper|PickerWheel|Toggle)$/i;
6268
+ 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;
6269
+ var SAFE_MODAL_TRIGGER_LABEL_RE = /^(?:create|new|add|edit|invite|configure|manage|details|preview|share)$/i;
6270
+ var MENU_TRIGGER_LABEL_RE = /^(?:more|menu|options|actions|filter|filters|sort|columns|customize)$/i;
6271
+ var GENERIC_NAVIGATION_DESTINATION_LABEL_RE = /^(?:home|feed|dashboard|profile|settings|inbox|messages?|notifications?|projects?|about|privacy|terms|learn\s+more)$/i;
6272
+ var SYSTEM_BACK_CONTROL_LABEL_RE = /^(?:back|go\s+back|navigate\s+up|navigation\s+up|up)$/i;
6273
+ 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;
6274
+ 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;
6275
+ 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;
6276
+ function isDestructiveActionLabel(label) {
6277
+ if (!label)
6278
+ return false;
6279
+ return DESTRUCTIVE_ACTION_LABEL_RE.test(normalizeControlIdentityPart(label));
6280
+ }
6281
+ function isDestructiveConfirmationInput(value) {
6282
+ if (!value)
6283
+ return false;
6284
+ return DESTRUCTIVE_CONFIRMATION_INPUT_RE.test(value.trim());
6285
+ }
6286
+ function normalizeControlIdentityPart(value) {
6287
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
6288
+ }
6289
+ function semanticControlAlias(label) {
6290
+ return `navigation-label:${normalizeControlIdentityPart(label)}`;
6291
+ }
6292
+ function localControlCoverageIdentity(screenId, identity) {
6293
+ return `screen:${screenId}:${identity}`;
6294
+ }
6295
+ function storedMobileControlIdentity(candidate) {
6296
+ return [
6297
+ candidate.identity,
6298
+ `kind:${candidate.kind}`,
6299
+ `label:${normalizeControlIdentityPart(candidate.label)}`
6300
+ ].join(":");
6301
+ }
6302
+ function isSystemBackNavigationControl(element, label) {
6303
+ if (SYSTEM_BACK_CONTROL_LABEL_RE.test(normalizeControlIdentityPart(label)))
6304
+ return true;
6305
+ return [element.accessibilityId, element.resourceId].filter((value) => Boolean(value?.trim())).some((value) => {
6306
+ const leaf = normalizeControlIdentityPart(value).split(/[/:.]/).filter(Boolean).at(-1) ?? "";
6307
+ return SYSTEM_BACK_CONTROL_ID_RE.test(leaf);
6308
+ });
6309
+ }
6310
+ function stableMobileControlIdentity(element) {
6311
+ const role = normalizeControlIdentityPart(element.role || "control");
6312
+ const accessibilityId = element.accessibilityId?.trim();
6313
+ if (accessibilityId)
6314
+ return `a11y:${role}:${normalizeControlIdentityPart(accessibilityId)}`;
6315
+ const resourceId = element.resourceId?.trim();
6316
+ if (resourceId)
6317
+ return `resource:${role}:${normalizeControlIdentityPart(resourceId)}`;
6318
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6319
+ if (label)
6320
+ return `semantic:${role}:${normalizeControlIdentityPart(label)}`;
6321
+ return null;
6322
+ }
6323
+ function classifySafeMobileNavigationControl(element) {
6324
+ if (!element.actionable || element.enabled === false)
6325
+ return null;
6326
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6327
+ const safetyIdentity = [label, element.accessibilityId, element.resourceId].filter(Boolean).join(" ");
6328
+ if (!label || UNSAFE_OR_COMMIT_LABEL_RE.test(safetyIdentity))
6329
+ return null;
6330
+ if (isSystemBackNavigationControl(element, label))
6331
+ return null;
6332
+ const role = element.role.trim();
6333
+ if (EDITABLE_CONTROL_ROLE_RE.test(role) || STATEFUL_CONTROL_ROLE_RE.test(role))
6334
+ return null;
6335
+ const linkLike = /(?:Link$|^a$)/i.test(role);
6336
+ let kind = null;
6337
+ if (/(?:TabBarItem|\btab\b)/i.test(role))
6338
+ kind = "tab";
6339
+ else if (/menuitem/i.test(role))
6340
+ kind = "menu-item";
6341
+ else if (linkLike && element.navigationProvenance)
6342
+ kind = "link";
6343
+ else if (linkLike)
6344
+ return null;
6345
+ else if (element.hasPopup && element.hasPopup.toLowerCase() !== "false")
6346
+ kind = "menu-trigger";
6347
+ else if (element.expanded === false)
6348
+ kind = "disclosure";
6349
+ else if (MENU_TRIGGER_LABEL_RE.test(label))
6350
+ kind = "menu-trigger";
6351
+ else if (SAFE_MODAL_TRIGGER_LABEL_RE.test(label))
6352
+ kind = "modal-trigger";
6353
+ else if (element.navigationProvenance)
6354
+ kind = "navigation-button";
6355
+ else if (GENERIC_NAVIGATION_DESTINATION_LABEL_RE.test(label))
6356
+ kind = "navigation-button";
6357
+ if (!kind)
6358
+ return null;
6359
+ const identity = stableMobileControlIdentity(element);
6360
+ return identity ? { identity, label, kind } : null;
6361
+ }
6362
+ function sharedMobileControlCoverageIdentity(element, candidate, semanticFamilyId) {
6363
+ if (!element.navigationProvenance)
6364
+ return void 0;
6365
+ const durableId = element.accessibilityId?.trim() ? `a11y:${normalizeControlIdentityPart(element.accessibilityId)}` : element.resourceId?.trim() ? `resource:${normalizeControlIdentityPart(element.resourceId)}` : "";
6366
+ if (!durableId && !semanticFamilyId)
6367
+ return void 0;
6368
+ return [
6369
+ "shared-navigation",
6370
+ element.navigationProvenance,
6371
+ !durableId && semanticFamilyId ? `family:${semanticFamilyId}` : "",
6372
+ candidate.kind,
6373
+ durableId || "semantic-only",
6374
+ normalizeControlIdentityPart(candidate.label)
6375
+ ].filter(Boolean).join(":");
6376
+ }
6377
+ function mobileNavigationRegion(element, windowSize) {
6378
+ if (!windowSize || windowSize.width <= 0 || windowSize.height <= 0)
6379
+ return "unknown";
6380
+ const centerX = (element.bounds.x + element.bounds.width / 2) / windowSize.width;
6381
+ const centerY = (element.bounds.y + element.bounds.height / 2) / windowSize.height;
6382
+ if (centerY >= 0.72)
6383
+ return "bottom";
6384
+ if (centerY <= 0.28)
6385
+ return "top";
6386
+ if (centerX <= 0.35)
6387
+ return "left";
6388
+ if (centerX >= 0.65)
6389
+ return "right";
6390
+ return "center";
6391
+ }
6392
+ function buildSemanticNavigationGroups(elements, windowSize) {
6393
+ const groups = /* @__PURE__ */ new Map();
6394
+ for (const element of elements) {
6395
+ if (!element.navigationProvenance)
6396
+ continue;
6397
+ const candidate = classifySafeMobileNavigationControl(element);
6398
+ if (!candidate)
6399
+ continue;
6400
+ const groupKey = `${element.navigationProvenance}:${mobileNavigationRegion(element, windowSize)}`;
6401
+ const labels = groups.get(groupKey) ?? /* @__PURE__ */ new Set();
6402
+ labels.add(`${candidate.kind}:${normalizeControlIdentityPart(candidate.label)}`);
6403
+ groups.set(groupKey, labels);
6404
+ }
6405
+ return new Map([...groups].filter(([, labels]) => labels.size >= 2));
6406
+ }
6407
+ function deriveScreenId(signal) {
6408
+ const namespace = signal.activity?.trim() || signal.bundleId?.trim();
6409
+ return namespace ? `${namespace}#${signal.screenHash}` : signal.screenHash;
6410
+ }
6411
+ function observedElementIdentity(element) {
6412
+ return (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(element);
6413
+ }
6414
+ function mergeObservedElements(previous, current) {
6415
+ const merged = [...current];
6416
+ const seen = new Set(current.map(observedElementIdentity));
6417
+ for (const element of previous) {
6418
+ const identity = observedElementIdentity(element);
6419
+ if (seen.has(identity))
6420
+ continue;
6421
+ seen.add(identity);
6422
+ merged.push(element);
6423
+ }
6424
+ return merged;
6425
+ }
6426
+ function sanitizeElementsForEvidence(elements) {
6427
+ return elements.map((element) => {
6428
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element);
6429
+ return {
6430
+ ...element,
6431
+ ...label ? { label } : { label: void 0 },
6432
+ text: void 0
6433
+ };
6434
+ });
6435
+ }
6436
+ var MobileExplorationState = class {
6437
+ /** screenId -> ScreenState (the mobile analog of routeState). */
6438
+ screens = /* @__PURE__ */ new Map();
6439
+ /** Observed directed transitions between screens. */
6440
+ transitions = [];
6441
+ /** Transition-announced screens that do not yet have a live observation. */
6442
+ frontier = /* @__PURE__ */ new Map();
6443
+ /** Safe navigation controls observed on each screen, keyed by stable identity. */
6444
+ safeControlsByScreen = /* @__PURE__ */ new Map();
6445
+ /** Screen-scoped coverage keys plus explicitly proven shared-navigation keys. */
6446
+ attemptedSafeControlCoverageIdentities = /* @__PURE__ */ new Set();
6447
+ successfulSafeControlAttemptCount = 0;
6448
+ /** Repeated id-less structural navigation families, grouped by region. */
6449
+ semanticNavigationFamilies = /* @__PURE__ */ new Map();
6450
+ nextSemanticNavigationFamilyId = 1;
6451
+ /** The screen-id of the most recently observed screen. */
6452
+ currentScreenId = null;
6453
+ /** Latest iteration recorded (drives stale detection + firstSeen stamps). */
6454
+ iteration = 0;
6455
+ /** Stale detector: progress mark (#screens + #total elements) at last advance. */
6456
+ lastProgressMark = 0;
6457
+ /** Consecutive iterations observed with no progress. */
6458
+ staleIterations = 0;
6459
+ staleThreshold;
6460
+ constructor(options = {}) {
6461
+ this.staleThreshold = typeof options.staleThreshold === "number" && options.staleThreshold > 0 ? Math.floor(options.staleThreshold) : DEFAULT_STALE_THRESHOLD;
6462
+ }
6463
+ // -------------------------------------------------------------------------
6464
+ // Observation recording
6465
+ // -------------------------------------------------------------------------
6466
+ /**
6467
+ * Returns true when `signal` identifies a screen this run has never observed
6468
+ * before. Pure read — does not mutate state.
6469
+ */
6470
+ isNewScreen(signal) {
6471
+ return !this.screens.has(deriveScreenId(signal));
6472
+ }
6473
+ /**
6474
+ * Record an observation of `screen` at `iteration`. Creates a `ScreenState`
6475
+ * on first sight (stamping `firstSeenIteration`), otherwise refreshes the
6476
+ * stored element set (latest-wins, mirroring the web reducer's PAGE_CAPTURED
6477
+ * element refresh). A driver observation already contains the structural UI
6478
+ * tree, elements, screenshot association, and screen identity, so it is
6479
+ * captured evidence immediately; the model's capture_screen call only adds
6480
+ * a friendly name/type. Returns the resolved screen-id.
6481
+ */
6482
+ recordObservation(signal, screen, iteration) {
6483
+ this.iteration = iteration;
6484
+ const screenId = deriveScreenId(signal);
6485
+ const evidenceElements = sanitizeElementsForEvidence(screen.elements);
6486
+ const existing = this.screens.get(screenId);
6487
+ if (existing) {
6488
+ this.screens.set(screenId, {
6489
+ ...existing,
6490
+ elements: mergeObservedElements(existing.elements, evidenceElements)
6491
+ });
6492
+ } else {
6493
+ this.screens.set(screenId, {
6494
+ screenId,
6495
+ firstSeenIteration: iteration,
6496
+ elements: evidenceElements,
6497
+ visited: true
6498
+ });
6499
+ }
6500
+ this.frontier.delete(screenId);
6501
+ const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
6502
+ const semanticNavigationGroups = buildSemanticNavigationGroups(evidenceElements, screen.windowSize);
6503
+ const semanticNavigationFamilyIds = /* @__PURE__ */ new Map();
6504
+ for (const [groupKey, labels] of semanticNavigationGroups) {
6505
+ semanticNavigationFamilyIds.set(groupKey, this.resolveSemanticNavigationFamilyId(groupKey, labels));
6506
+ }
6507
+ for (const element of evidenceElements) {
6508
+ const candidate = classifySafeMobileNavigationControl(element);
6509
+ if (!candidate)
6510
+ continue;
6511
+ const storedIdentity = storedMobileControlIdentity(candidate);
6512
+ const semanticGroupKey = element.navigationProvenance ? `${element.navigationProvenance}:${mobileNavigationRegion(element, screen.windowSize)}` : "";
6513
+ controls.set(storedIdentity, {
6514
+ ...candidate,
6515
+ identity: storedIdentity,
6516
+ sharedCoverageIdentity: sharedMobileControlCoverageIdentity(element, candidate, semanticNavigationFamilyIds.get(semanticGroupKey))
6517
+ });
6518
+ }
6519
+ if (controls.size > 0)
6520
+ this.safeControlsByScreen.set(screenId, controls);
6521
+ this.currentScreenId = screenId;
6522
+ this.updateStaleProgress();
6523
+ return screenId;
6524
+ }
6525
+ /**
6526
+ * Back-compatible acknowledgement from capture_screen. Observation already
6527
+ * marks structural evidence visited; this call only resolves a rare pending
6528
+ * transition frontier and preserves callers that annotate explicitly.
6529
+ */
6530
+ markVisited(screenId) {
6531
+ const state2 = this.screens.get(screenId);
6532
+ if (state2 && !state2.visited) {
6533
+ this.screens.set(screenId, { ...state2, visited: true });
6534
+ }
6535
+ this.frontier.delete(screenId);
6536
+ }
6537
+ /**
6538
+ * Record a confirmed tap against a safe navigation candidate. Callers invoke
6539
+ * this only after the driver reports success; recovery-unknown, stale-ref, and
6540
+ * failed actions must remain on the breadth frontier.
6541
+ */
6542
+ noteControlAttempt(screenId, element) {
6543
+ if (!screenId)
6544
+ return false;
6545
+ const candidate = classifySafeMobileNavigationControl(element);
6546
+ const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
6547
+ if (candidate) {
6548
+ const storedIdentity = storedMobileControlIdentity(candidate);
6549
+ const existing = controls.get(storedIdentity);
6550
+ controls.set(storedIdentity, {
6551
+ ...candidate,
6552
+ identity: storedIdentity,
6553
+ sharedCoverageIdentity: existing?.sharedCoverageIdentity ?? sharedMobileControlCoverageIdentity(element, candidate)
6554
+ });
6555
+ this.safeControlsByScreen.set(screenId, controls);
6556
+ }
6557
+ const tappedLabel = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6558
+ if (!tappedLabel)
6559
+ return false;
6560
+ const before = this.attemptedSafeControlCoverageIdentities.size;
6561
+ const alias = semanticControlAlias(tappedLabel);
6562
+ for (const control of controls.values()) {
6563
+ if (semanticControlAlias(control.label) === alias) {
6564
+ this.attemptedSafeControlCoverageIdentities.add(localControlCoverageIdentity(screenId, control.identity));
6565
+ if (control.sharedCoverageIdentity) {
6566
+ this.attemptedSafeControlCoverageIdentities.add(control.sharedCoverageIdentity);
6567
+ }
6568
+ }
6569
+ }
6570
+ if (this.attemptedSafeControlCoverageIdentities.size > before) {
6571
+ this.successfulSafeControlAttemptCount += 1;
6572
+ this.staleIterations = 0;
6573
+ return true;
6574
+ }
6575
+ return false;
6576
+ }
6577
+ /** Attach a human-friendly name / coarse type to a screen (capture_screen). */
6578
+ annotateScreen(screenId, annotation) {
6579
+ const state2 = this.screens.get(screenId);
6580
+ if (!state2)
6581
+ return;
6582
+ this.screens.set(screenId, {
6583
+ ...state2,
6584
+ screenName: annotation.screenName ?? state2.screenName,
6585
+ screenType: annotation.screenType ?? state2.screenType
6586
+ });
6587
+ }
6588
+ /** Attach structurally observed authentication provenance to a screen. */
6589
+ setScreenAuthEvidence(screenId, evidence) {
6590
+ const state2 = this.screens.get(screenId);
6591
+ if (!state2)
6592
+ return;
6593
+ this.screens.set(screenId, {
6594
+ ...state2,
6595
+ requiresAuth: evidence.requiresAuth,
6596
+ authProvenance: evidence.authProvenance,
6597
+ sessionState: { ...evidence.sessionState }
6598
+ });
6599
+ }
6600
+ /** Append a free-form observation note to a screen (observations tool). */
6601
+ addScreenObservation(screenId, note) {
6602
+ const state2 = this.screens.get(screenId);
6603
+ if (!state2)
6604
+ return;
6605
+ const normalized = note.trim();
6606
+ if (!normalized)
6607
+ return;
6608
+ const current = state2.observations ?? [];
6609
+ if (current.some((existing) => existing.trim().toLowerCase() === normalized.toLowerCase())) {
6610
+ return;
6611
+ }
6612
+ const observations = [...current, normalized].slice(-MAX_SCREEN_OBSERVATIONS);
6613
+ this.screens.set(screenId, { ...state2, observations });
6614
+ }
6615
+ /**
6616
+ * Record a directed transition between two screens. Both endpoints are
6617
+ * ensured to exist on the frontier if not yet observed (mirrors the web
6618
+ * reducer adding both ends of a TRANSITION_RECORDED to the frontier). The
6619
+ * `toScreenId` becomes the current screen when the transition starts from the
6620
+ * current screen.
6621
+ */
6622
+ recordTransition(transition) {
6623
+ let recordedTransition = transition;
6624
+ if (!transition.viaControlIdentity && transition.viaRef) {
6625
+ const sourceElement = this.screens.get(transition.fromScreenId)?.elements.find((element) => element.ref === transition.viaRef);
6626
+ if (sourceElement) {
6627
+ recordedTransition = {
6628
+ ...transition,
6629
+ viaControlIdentity: (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(sourceElement)
6630
+ };
6631
+ }
6632
+ }
6633
+ const alreadyRecorded = this.transitions.some((existing) => existing.fromScreenId === recordedTransition.fromScreenId && existing.toScreenId === recordedTransition.toScreenId && existing.action === recordedTransition.action);
6634
+ if (!alreadyRecorded) {
6635
+ this.transitions.push(recordedTransition);
6636
+ }
6637
+ for (const endpointId of [transition.fromScreenId, transition.toScreenId]) {
6638
+ if (!this.screens.has(endpointId) && !this.frontier.has(endpointId)) {
6639
+ this.frontier.set(endpointId, {
6640
+ screenId: endpointId,
6641
+ source: "transition",
6642
+ discoveredAtIteration: this.iteration
6643
+ });
6644
+ }
6645
+ }
6646
+ if (transition.fromScreenId === this.currentScreenId) {
6647
+ this.currentScreenId = transition.toScreenId;
6648
+ }
6649
+ }
6650
+ // -------------------------------------------------------------------------
6651
+ // Selectors
6652
+ // -------------------------------------------------------------------------
6653
+ /** The current screen-id (most recently observed), or null before any observation. */
6654
+ getCurrentScreenId() {
6655
+ return this.currentScreenId;
6656
+ }
6657
+ /** ScreenState for `screenId`, if known. */
6658
+ getScreen(screenId) {
6659
+ return this.screens.get(screenId);
6660
+ }
6661
+ /** Number of distinct screens observed this run. */
6662
+ getScreenCount() {
6663
+ return this.screens.size;
6664
+ }
6665
+ /** Number of screens backed by a live structural driver observation. */
6666
+ getVisitedScreenCount() {
6667
+ let total = 0;
6668
+ for (const state2 of this.screens.values()) {
6669
+ if (state2.visited)
6670
+ total += 1;
6671
+ }
6672
+ return total;
6673
+ }
6674
+ /** Total elements observed across all known screens (latest snapshot each). */
6675
+ getTotalElementCount() {
6676
+ let total = 0;
6677
+ for (const state2 of this.screens.values()) {
6678
+ total += state2.elements.length;
6679
+ }
6680
+ return total;
6681
+ }
6682
+ /**
6683
+ * Frontier screen-ids ranked by recency (most recently discovered first),
6684
+ * excluding any screen already visited. Mirrors the web `getRankedFrontier`
6685
+ * intent (filter visited, rank by recency) — without URL prefix similarity,
6686
+ * which has no mobile analog.
6687
+ */
6688
+ getFrontier() {
6689
+ return [...this.frontier.values()].filter((item) => {
6690
+ const state2 = this.screens.get(item.screenId);
6691
+ return !state2 || !state2.visited;
6692
+ }).sort((a, b) => b.discoveredAtIteration - a.discoveredAtIteration).map((item) => item.screenId);
6693
+ }
6694
+ /** Safe navigation candidates not yet attempted, deduped across shared chrome. */
6695
+ getUnexploredSafeNavigationCandidates() {
6696
+ const authenticatedProductObserved = [...this.screens.values()].some((screen) => screen.requiresAuth === true);
6697
+ const candidates = [];
6698
+ for (const [screenId, controls] of this.safeControlsByScreen) {
6699
+ const origin = this.screens.get(screenId);
6700
+ const originIsConfirmedPublicEntry = origin?.authProvenance === "public" || !origin?.authProvenance && origin?.requiresAuth !== true && /^(?:AUTH|LANDING)$/i.test(origin?.screenType ?? "");
6701
+ if (authenticatedProductObserved && originIsConfirmedPublicEntry)
6702
+ continue;
6703
+ for (const control of controls.values()) {
6704
+ const alias = semanticControlAlias(control.label);
6705
+ const coverageIdentity = control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity);
6706
+ if (this.attemptedSafeControlCoverageIdentities.has(coverageIdentity) || this.attemptedSafeControlCoverageIdentities.has(localControlCoverageIdentity(screenId, control.identity)))
6707
+ continue;
6708
+ const existing = candidates.find((candidate) => candidate.identity === coverageIdentity || candidate.alias === alias && candidate.screenIds.has(screenId));
6709
+ if (existing) {
6710
+ existing.screenIds.add(screenId);
6711
+ } else {
6712
+ candidates.push({
6713
+ identity: coverageIdentity,
6714
+ label: control.label,
6715
+ kind: control.kind,
6716
+ alias,
6717
+ screenIds: /* @__PURE__ */ new Set([screenId])
6718
+ });
6719
+ }
6720
+ }
6721
+ }
6722
+ return candidates.map((candidate) => ({
6723
+ identity: candidate.identity,
6724
+ label: candidate.label,
6725
+ kind: candidate.kind,
6726
+ screenIds: [...candidate.screenIds]
6727
+ })).sort((a, b) => {
6728
+ const aCurrent = this.currentScreenId !== null && a.screenIds.includes(this.currentScreenId);
6729
+ const bCurrent = this.currentScreenId !== null && b.screenIds.includes(this.currentScreenId);
6730
+ if (aCurrent !== bCurrent)
6731
+ return aCurrent ? -1 : 1;
6732
+ return b.screenIds.length - a.screenIds.length;
6733
+ });
6734
+ }
6735
+ /** Harness-owned survey completion floor, independent of model assertions. */
6736
+ getSurveyBreadthCoverage() {
6737
+ const unvisitedScreenIds = this.getFrontier();
6738
+ const unexploredControls = this.getUnexploredSafeNavigationCandidates();
6739
+ const complete = unvisitedScreenIds.length === 0 && unexploredControls.length === 0;
6740
+ const missing = [];
6741
+ if (unvisitedScreenIds.length > 0) {
6742
+ missing.push(`${unvisitedScreenIds.length} uncaptured screen(s)`);
6743
+ }
6744
+ if (unexploredControls.length > 0) {
6745
+ missing.push(`${unexploredControls.length} safe navigation control(s)`);
6746
+ }
6747
+ return {
6748
+ required: true,
6749
+ complete,
6750
+ unvisitedScreenIds,
6751
+ unexploredControls,
6752
+ attemptedControlCount: this.successfulSafeControlAttemptCount,
6753
+ ...missing.length > 0 ? { reason: `Missing ${missing.join(" and ")}.` } : {}
6754
+ };
6755
+ }
6756
+ /**
6757
+ * Monotonic structural progress token for adaptive survey leases. Frontier
6758
+ * cardinality alone is insufficient: attempting control A can reveal control
6759
+ * B and leave the remaining count unchanged even though real work happened.
6760
+ */
6761
+ getSurveyBreadthProgressMark() {
6762
+ const discoveredControlIdentities = /* @__PURE__ */ new Set();
6763
+ for (const [screenId, controls] of this.safeControlsByScreen) {
6764
+ for (const control of controls.values()) {
6765
+ discoveredControlIdentities.add(control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity));
6766
+ }
6767
+ }
6768
+ return [
6769
+ `screens:${this.screens.size}`,
6770
+ `attempts:${this.successfulSafeControlAttemptCount}`,
6771
+ `controls:${discoveredControlIdentities.size}`,
6772
+ `transitions:${this.transitions.length}`
6773
+ ].join("|");
6774
+ }
6775
+ resolveSemanticNavigationFamilyId(groupKey, labels) {
6776
+ const families = this.semanticNavigationFamilies.get(groupKey) ?? [];
6777
+ let best;
6778
+ let bestIntersection = 0;
6779
+ for (const family of families) {
6780
+ let intersection = 0;
6781
+ for (const label of labels) {
6782
+ if (family.labels.has(label))
6783
+ intersection += 1;
6784
+ }
6785
+ const overlap = intersection / Math.min(labels.size, family.labels.size);
6786
+ if (intersection >= 2 && overlap >= 0.6 && intersection > bestIntersection) {
6787
+ best = family;
6788
+ bestIntersection = intersection;
6789
+ }
6790
+ }
6791
+ if (best) {
6792
+ for (const label of labels)
6793
+ best.labels.add(label);
6794
+ return best.id;
6795
+ }
6796
+ const created = {
6797
+ id: `semantic-nav-family-${this.nextSemanticNavigationFamilyId++}`,
6798
+ labels: new Set(labels)
6799
+ };
6800
+ families.push(created);
6801
+ this.semanticNavigationFamilies.set(groupKey, families);
6802
+ return created.id;
6803
+ }
6804
+ /** Observed transitions (defensive copy). */
6805
+ getTransitions() {
6806
+ return [...this.transitions];
6807
+ }
6808
+ // -------------------------------------------------------------------------
6809
+ // Stale-progress detection (ported semantics from the web stale guard)
6810
+ // -------------------------------------------------------------------------
6811
+ /**
6812
+ * Progress mark: distinct screens weighted heavily, plus total elements seen.
6813
+ * A run advances this mark whenever it finds a new screen OR new elements;
6814
+ * the stale detector triggers when the mark stops advancing for
6815
+ * `staleThreshold` consecutive iterations.
6816
+ */
6817
+ getProgressMark() {
6818
+ return this.screens.size * 1e3 + this.transitions.length * 100 + this.successfulSafeControlAttemptCount * 10 + this.getTotalElementCount();
6819
+ }
6820
+ updateStaleProgress() {
6821
+ const mark = this.getProgressMark();
6822
+ if (mark > this.lastProgressMark) {
6823
+ this.lastProgressMark = mark;
6824
+ this.staleIterations = 0;
6825
+ } else {
6826
+ this.staleIterations += 1;
6827
+ }
6828
+ }
6829
+ /** Consecutive no-progress iterations observed so far. */
6830
+ getStaleIterations() {
6831
+ return this.staleIterations;
6832
+ }
6833
+ /**
6834
+ * True when the run has gone `staleThreshold` consecutive iterations with no
6835
+ * new screen and no new element — the signal the explorer uses to nudge or
6836
+ * finish (mirrors the web explorer's stale guard).
6837
+ */
6838
+ isStaleProgress() {
6839
+ return this.staleIterations >= this.staleThreshold;
6840
+ }
6841
+ // -------------------------------------------------------------------------
6842
+ // Planner evidence export
6843
+ // -------------------------------------------------------------------------
6844
+ /**
6845
+ * Snapshot the screen-graph as a plain object (frozen mobile-types
6846
+ * `MobileScreenGraph`). Defensive copies so callers cannot mutate internal
6847
+ * state.
6848
+ */
6849
+ toScreenGraph() {
6850
+ const screens = /* @__PURE__ */ new Map();
6851
+ for (const [id, state2] of this.screens) {
6852
+ screens.set(id, {
6853
+ ...state2,
6854
+ elements: [...state2.elements],
6855
+ observations: state2.observations ? [...state2.observations] : void 0,
6856
+ sessionState: state2.sessionState ? { ...state2.sessionState } : void 0
6857
+ });
6858
+ }
6859
+ return {
6860
+ screens,
6861
+ transitions: this.transitions.map((t) => ({
6862
+ fromScreenId: t.fromScreenId,
6863
+ toScreenId: t.toScreenId,
6864
+ viaRef: t.viaRef,
6865
+ viaControlIdentity: t.viaControlIdentity,
6866
+ action: t.action
6867
+ }))
6868
+ };
6869
+ }
6870
+ /**
6871
+ * Build the planner evidence shape: one `CollectedPageData` per screen, with
6872
+ * the screen-id carried in BOTH `route` (the on-the-wire positional field the
6873
+ * server keys screenshots by) AND the explicit `screenId` field (per the
6874
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
6875
+ * the screen's parsed actionable elements into the platform-neutral
6876
+ * `CollectedPageElement[]` shape the server persists as SiteElements — without
6877
+ * this, mobile screens land with zero elements (coverage always 0%, no element
6878
+ * intelligence). `triggersNavigation` is derived from the durable source
6879
+ * element identity captured when the transition happened (with ref-only
6880
+ * fallback for legacy transitions). Insertion order matches first-seen order.
6881
+ */
6882
+ buildInMemoryScreenMap() {
6883
+ const pages = [];
6884
+ const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
6885
+ const navRefsByScreen = /* @__PURE__ */ new Map();
6886
+ const navIdentitiesByScreen = /* @__PURE__ */ new Map();
6887
+ for (const transition of this.transitions) {
6888
+ if (transition.viaControlIdentity) {
6889
+ const set = navIdentitiesByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
6890
+ set.add(transition.viaControlIdentity);
6891
+ navIdentitiesByScreen.set(transition.fromScreenId, set);
6892
+ } else if (transition.viaRef) {
6893
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
6894
+ set.add(transition.viaRef);
6895
+ navRefsByScreen.set(transition.fromScreenId, set);
6896
+ }
6897
+ }
6898
+ for (const state2 of ordered) {
6899
+ const page = {
6900
+ route: state2.screenId,
6901
+ screenId: state2.screenId,
6902
+ pageType: state2.screenType ?? "UNKNOWN",
6903
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
6904
+ navigationRefs: navRefsByScreen.get(state2.screenId),
6905
+ navigationIdentities: navIdentitiesByScreen.get(state2.screenId)
6906
+ }),
6907
+ requiresAuth: state2.requiresAuth ?? false
6908
+ };
6909
+ if (state2.sessionState) {
6910
+ page.sessionState = { ...state2.sessionState };
6911
+ }
6912
+ if (state2.screenName) {
6913
+ page.title = state2.screenName;
6914
+ }
6915
+ if (state2.observations && state2.observations.length > 0) {
6916
+ page.observations = state2.observations.map((note) => ({
6917
+ action: "observe",
6918
+ outcome: note,
6919
+ outcomeType: "SUCCESS"
6920
+ }));
6921
+ }
6922
+ pages.push(page);
6923
+ }
6924
+ return pages;
6925
+ }
6926
+ /** Alias for `buildInMemoryScreenMap()` — name parity with the planner-evidence call site. */
6927
+ toScreenMap() {
6928
+ return this.buildInMemoryScreenMap();
6929
+ }
6930
+ };
6931
+ exports.MobileExplorationState = MobileExplorationState;
6932
+ }
6933
+ });
6934
+
6221
6935
  // ../runner-core/dist/services/mobile/mobile-keyboard.js
6222
6936
  var require_mobile_keyboard = __commonJS({
6223
6937
  "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports) {
@@ -63934,8 +64648,10 @@ var require_mobile_explorer = __commonJS({
63934
64648
  var ai_provider_js_1 = require_ai_provider();
63935
64649
  var ai_queue_js_1 = require_ai_queue();
63936
64650
  var ai_retry_js_1 = require_ai_retry();
64651
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
63937
64652
  var mobile_source_parser_js_1 = require_mobile_source_parser();
63938
64653
  var mobile_observation_js_1 = require_mobile_observation();
64654
+ var mobile_exploration_state_js_1 = require_mobile_exploration_state();
63939
64655
  var mobile_boundary_js_1 = require_mobile_boundary();
63940
64656
  var mobile_keyboard_js_1 = require_mobile_keyboard();
63941
64657
  var mobile_auth_coverage_js_1 = require_mobile_auth_coverage();
@@ -64293,6 +65009,10 @@ var require_mobile_explorer = __commonJS({
64293
65009
  return void 0;
64294
65010
  return normalized;
64295
65011
  }
65012
+ function destructiveActionRefusal(label, kind = "tap") {
65013
+ const what = kind === "confirmation" ? `Typing "${label}" is a destructive confirmation` : `Tapping "${label}" is an irreversible, high-stakes destructive action`;
65014
+ 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.`;
65015
+ }
64296
65016
  function hybridElementForEvidence(element, credentials) {
64297
65017
  const id = safeHybridIdentity(element.id, credentials);
64298
65018
  const name = safeHybridIdentity(element.name, credentials);
@@ -64915,7 +65635,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
64915
65635
  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."}
64916
65636
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
64917
65637
  5. mobile_swipe to scroll; mobile_back to return.
64918
- 6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
65638
+ 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.
64919
65639
  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.
64920
65640
 
64921
65641
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -65438,7 +66158,7 @@ ${statePacket}` }
65438
66158
  const requestMessageCount = messages.length;
65439
66159
  const requestMessages = messages.slice();
65440
66160
  const callStart = Date.now();
65441
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
66161
+ 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)(() => {
65442
66162
  arm();
65443
66163
  return aiClient.chat.completions.create({
65444
66164
  model: agentModel,
@@ -65451,7 +66171,7 @@ ${statePacket}` }
65451
66171
  label: `mobile-explore-iter-${iteration}`,
65452
66172
  deferTimeoutUntilArmed: true,
65453
66173
  onRetry: (attempt, reason, delayMs) => log2(` \u26A0 mobile explore AI retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
65454
- });
66174
+ }));
65455
66175
  const iterDuration = Date.now() - callStart;
65456
66176
  if (!response?.choices?.[0]?.message) {
65457
66177
  throw new Error(`AI explore call returned no response (queue timeout or provider overload) at iteration ${iteration}`);
@@ -65780,6 +66500,11 @@ Exploration complete: ${summary}`);
65780
66500
  if (!driver.webTap) {
65781
66501
  return { ok: false, error: "This driver does not support hybrid WEBVIEW taps." };
65782
66502
  }
66503
+ const hybridTapLabel = hybridElementForEvidence(hybrid, redactionCredentials).label ?? "";
66504
+ if ((0, mobile_exploration_state_js_1.isDestructiveActionLabel)(hybridTapLabel)) {
66505
+ log2(` \u26A0 Refused destructive tap on "${hybridTapLabel}" (account/data-deletion class); noting instead.`);
66506
+ return { ok: false, error: destructiveActionRefusal(hybridTapLabel) };
66507
+ }
65783
66508
  const authSurfaceBeforeTap2 = currentAuthSurface();
65784
66509
  const fromScreenId2 = state2.getCurrentScreenId();
65785
66510
  const actionResult2 = await driver.webTap(hybrid.webviewContext, hybrid.css);
@@ -65810,6 +66535,11 @@ Exploration complete: ${summary}`);
65810
66535
  if (!resolved) {
65811
66536
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
65812
66537
  }
66538
+ const nativeTapLabel = resolved.element.label ?? resolved.element.text ?? resolved.element.accessibilityId ?? "";
66539
+ if ((0, mobile_exploration_state_js_1.isDestructiveActionLabel)(nativeTapLabel)) {
66540
+ log2(` \u26A0 Refused destructive tap on "${nativeTapLabel}" (account/data-deletion class); noting instead.`);
66541
+ return { ok: false, error: destructiveActionRefusal(nativeTapLabel) };
66542
+ }
65813
66543
  const fromScreenId = state2.getCurrentScreenId();
65814
66544
  const boundaryBeforeAction = currentBoundary();
65815
66545
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
@@ -65850,6 +66580,10 @@ Exploration complete: ${summary}`);
65850
66580
  // ----- Type -----
65851
66581
  case "mobile_type": {
65852
66582
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66583
+ if ((0, mobile_exploration_state_js_1.isDestructiveConfirmationInput)(value)) {
66584
+ log2(` \u26A0 Refused destructive confirmation input "${value}"; noting instead.`);
66585
+ return { ok: false, error: destructiveActionRefusal(value, "confirmation") };
66586
+ }
65853
66587
  const allowedLiteral = inbox ? inbox.state.email : "";
65854
66588
  for (const [key, secret] of Object.entries(credentials)) {
65855
66589
  if (!secret || secret.length < 4)
@@ -67516,6 +68250,7 @@ var require_discover_ai_planner = __commonJS({
67516
68250
  "use strict";
67517
68251
  Object.defineProperty(exports, "__esModule", { value: true });
67518
68252
  exports.SCENARIO_SYSTEM_PROMPT = void 0;
68253
+ exports.getAIPlannerMaxTokens = getAIPlannerMaxTokens;
67519
68254
  exports.getSnapshotConfidence = getSnapshotConfidence;
67520
68255
  exports.compressSiteMap = compressSiteMap;
67521
68256
  exports.formatEvidenceForPlanner = formatEvidenceForPlanner;
@@ -67527,10 +68262,25 @@ var require_discover_ai_planner = __commonJS({
67527
68262
  var ai_retry_js_1 = require_ai_retry();
67528
68263
  var strip_json_fences_js_1 = require_strip_json_fences();
67529
68264
  var scenario_plan_validator_js_1 = require_scenario_plan_validator();
67530
- var DEFAULT_AI_PLANNER_MAX_TOKENS = 8192;
67531
- var MINIMAX_AI_PLANNER_MAX_TOKENS = 16384;
68265
+ var GENEROUS_AI_PLANNER_MAX_TOKENS = 65536;
68266
+ var CONSERVATIVE_AI_PLANNER_MAX_TOKENS = 16384;
67532
68267
  function getAIPlannerMaxTokens(modelId) {
67533
- return (0, ai_provider_js_1.getProviderForModel)(modelId) === "minimax" ? MINIMAX_AI_PLANNER_MAX_TOKENS : DEFAULT_AI_PLANNER_MAX_TOKENS;
68268
+ switch ((0, ai_provider_js_1.getProviderForModel)(modelId)) {
68269
+ case "deepseek":
68270
+ // V4 output ceiling = 384K — verified safe
68271
+ case "minimax":
68272
+ return GENEROUS_AI_PLANNER_MAX_TOKENS;
68273
+ case "xai":
68274
+ // grok-build-0.1 ceiling unproven (Grok-Fast/3 = 16K) — DON'T risk a 400
68275
+ case "openai":
68276
+ // gpt-4o max output = 16384 → more would 400
68277
+ case "anthropic":
68278
+ // OpenAI-compat proxy — keep conservative
68279
+ case "nvidia":
68280
+ // NIM output limits vary — keep conservative
68281
+ default:
68282
+ return CONSERVATIVE_AI_PLANNER_MAX_TOKENS;
68283
+ }
67534
68284
  }
67535
68285
  var discover_planner_js_1 = require_discover_planner();
67536
68286
  function getSnapshotConfidence(sourceKind) {
@@ -68082,7 +68832,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
68082
68832
  4. **Multi-page journeys** - only when transitions were actually observed
68083
68833
  5. **Bug reports** - for observed 500 errors, crashes, or broken features
68084
68834
  6. **Steps are human-readable** - "Fill the email field", not "page.getByLabel('Email').fill()"
68085
- 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.
68835
+ 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.
68086
68836
  8. **Auth-aware** - mark scenarios that need login. Use process.env.TEST_USER_EMAIL / PASSWORD
68087
68837
  9. **No duplicates** - check existing test names before adding
68088
68838
  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.
@@ -68134,7 +68884,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
68134
68884
  **YOUR responsibility:**
68135
68885
  - Scenario \`name\`, \`type\`, \`goal\`, \`prerequisites\`
68136
68886
  - \`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
68137
- - \`expectedOutcomes\` citing observed durable labels per Rule 12 (asserting on stable signals is encouraged)
68887
+ - \`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
68138
68888
 
68139
68889
  **NOT your responsibility:**
68140
68890
  - Exact Playwright selectors (\`getByRole(...)\`, \`getByText(...)\`, \`page.locator(...)\`, etc.) \u2014 live-generate picks locators against the live snapshot
@@ -68155,7 +68905,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
68155
68905
  - GOOD specificity: "create a new conversation by sending a message to a connection established earlier in this scenario"
68156
68906
  - BAD specificity: "click the button labeled exactly 'Send' in the bottom-right of the chat"
68157
68907
 
68158
- Citing observed labels in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
68908
+ Citing observed labels in \`expectedOutcomes\` is fine \u2014 that's asserting on stable product signals, not implementation specifics.
68159
68909
 
68160
68910
  ## Coverage Strategy
68161
68911
  - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 11) + 1 EDGE_CASE (empty/invalid submission)
@@ -68741,6 +69491,24 @@ Each entry is exactly one of:
68741
69491
  }
68742
69492
  async function runAIPlanner(siteMap, evidence, context, options) {
68743
69493
  const logs2 = [];
69494
+ const failurePlan = (kind, reason, legacyFallback2) => {
69495
+ const noFallback = context.mode === "survey" && process.env.DISCOVERY_PLANNER_NO_FALLBACK === "true";
69496
+ if (noFallback) {
69497
+ logs2.push(`Planner ${kind} \u2014 NO-FALLBACK: failing survey cleanly instead of fabricating slug features (${reason})`);
69498
+ return {
69499
+ scenarios: [],
69500
+ featureGroups: [],
69501
+ reasoning: "",
69502
+ logs: logs2,
69503
+ coverageGaps: [],
69504
+ plannerFailed: true,
69505
+ plannerFailureKind: kind,
69506
+ fallbackReason: reason
69507
+ };
69508
+ }
69509
+ return { ...legacyFallback2(), fallbackReason: reason };
69510
+ };
69511
+ let extraPlannerCallUsed = false;
68744
69512
  const compressed = compressSiteMap(siteMap);
68745
69513
  const agentModel = options.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
68746
69514
  const plannerMaxTokens = getAIPlannerMaxTokens(agentModel);
@@ -68776,7 +69544,7 @@ Each entry is exactly one of:
68776
69544
  } catch (err) {
68777
69545
  const message = err instanceof Error ? err.message : String(err);
68778
69546
  logs2.push(`AI planner failed after retries: ${message}`);
68779
- return { ...buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget), fallbackReason: (0, ai_retry_js_1.describeAIFailure)(err) };
69547
+ return failurePlan("transport", (0, ai_retry_js_1.describeAIFailure)(err), () => buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget));
68780
69548
  }
68781
69549
  try {
68782
69550
  options.onAICall?.({
@@ -68795,20 +69563,104 @@ Each entry is exactly one of:
68795
69563
  });
68796
69564
  } catch {
68797
69565
  }
68798
- const raw = response?.choices?.[0]?.message?.content;
68799
- if (!raw) {
69566
+ const choice = response?.choices?.[0];
69567
+ const raw = choice?.message?.content;
69568
+ const finishReason = (choice?.finish_reason ?? "").toString().toLowerCase();
69569
+ const completionTokens = response.usage?.completion_tokens;
69570
+ const TRUNCATION_FINISH = /* @__PURE__ */ new Set(["length", "max_tokens", "max_output_tokens", "token_limit"]);
69571
+ const definitelyTruncated = TRUNCATION_FINISH.has(finishReason);
69572
+ const atOrOverCap = completionTokens != null && completionTokens >= plannerMaxTokens;
69573
+ const blocked = finishReason === "content_filter" || finishReason === "tool_calls";
69574
+ const usage = response.usage;
69575
+ logs2.push(`AI planner: ${usage?.prompt_tokens ?? "?"} input, ${usage?.completion_tokens ?? "?"} output tokens (finish=${finishReason || "?"})`);
69576
+ const legacyFallback = () => buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget);
69577
+ if (blocked) {
69578
+ return failurePlan("blocked", "AI response was blocked (content_filter/tool_calls)", legacyFallback);
69579
+ }
69580
+ if (definitelyTruncated) {
69581
+ return failurePlan("truncated", "AI planner output truncated at max_tokens (finish_reason=length)", legacyFallback);
69582
+ }
69583
+ const trimmed = raw?.trim();
69584
+ if (!trimmed) {
68800
69585
  logs2.push("AI planner returned empty response");
68801
- return { ...buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget), fallbackReason: "AI returned an empty response" };
69586
+ return atOrOverCap ? failurePlan("truncated", "AI planner output truncated at max_tokens (empty body at cap)", legacyFallback) : failurePlan("empty", "AI returned an empty response", legacyFallback);
68802
69587
  }
68803
- const usage = response.usage;
68804
- logs2.push(`AI planner: ${usage?.prompt_tokens ?? "?"} input, ${usage?.completion_tokens ?? "?"} output tokens`);
68805
69588
  let aiOutput;
68806
69589
  try {
68807
- aiOutput = JSON.parse((0, strip_json_fences_js_1.stripJsonFences)(raw));
69590
+ aiOutput = JSON.parse((0, strip_json_fences_js_1.stripJsonFences)(trimmed));
68808
69591
  } catch (err) {
68809
69592
  const message = err instanceof Error ? err.message : String(err);
68810
69593
  logs2.push(`AI planner returned invalid JSON: ${message}`);
68811
- return { ...buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget), fallbackReason: "AI returned unreadable output" };
69594
+ if (atOrOverCap) {
69595
+ logs2.push("AI planner output at/over the token cap and unparseable \u2014 treating as truncated (skipping repair)");
69596
+ return failurePlan("truncated", "AI planner output truncated at max_tokens (unparseable at cap)", legacyFallback);
69597
+ }
69598
+ 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.`;
69599
+ const repairStart = Date.now();
69600
+ let repairResponse = null;
69601
+ try {
69602
+ extraPlannerCallUsed = true;
69603
+ repairResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
69604
+ arm();
69605
+ return aiClient.chat.completions.create({
69606
+ model: agentModel,
69607
+ messages: [
69608
+ ...messages,
69609
+ { role: "assistant", content: raw },
69610
+ { role: "user", content: repairPrompt }
69611
+ ],
69612
+ max_tokens: plannerMaxTokens,
69613
+ temperature: 0.3,
69614
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
69615
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
69616
+ }, { signal });
69617
+ }), {
69618
+ label: "ai-planner-repair",
69619
+ deferTimeoutUntilArmed: true,
69620
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner repair call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
69621
+ });
69622
+ } catch (repairErr) {
69623
+ const repairMessage = repairErr instanceof Error ? repairErr.message : String(repairErr);
69624
+ logs2.push(`AI planner repair failed (${repairMessage})`);
69625
+ }
69626
+ if (repairResponse) {
69627
+ try {
69628
+ options.onAICall?.({
69629
+ iteration: 1,
69630
+ groupId: `ai-planner-${context.projectId.slice(0, 8)}`,
69631
+ callType: "discoveryAIPlannerRepair",
69632
+ model: agentModel,
69633
+ provider: (0, ai_provider_js_1.getProviderForModel)(agentModel),
69634
+ durationMs: Date.now() - repairStart,
69635
+ promptTokens: repairResponse.usage?.prompt_tokens,
69636
+ completionTokens: repairResponse.usage?.completion_tokens,
69637
+ totalTokens: repairResponse.usage?.total_tokens,
69638
+ assistantMessage: repairResponse.choices[0]?.message?.content,
69639
+ requestMessageCount: messages.length + 2,
69640
+ requestMessages: [
69641
+ ...messages,
69642
+ { role: "assistant", content: raw },
69643
+ { role: "user", content: repairPrompt }
69644
+ ]
69645
+ });
69646
+ } catch {
69647
+ }
69648
+ const repairedRaw = repairResponse.choices[0]?.message?.content?.trim();
69649
+ if (repairedRaw) {
69650
+ try {
69651
+ aiOutput = JSON.parse((0, strip_json_fences_js_1.stripJsonFences)(repairedRaw));
69652
+ } catch (repairParseErr) {
69653
+ const repairMessage = repairParseErr instanceof Error ? repairParseErr.message : String(repairParseErr);
69654
+ logs2.push(`AI planner repair returned invalid JSON (${repairMessage})`);
69655
+ return failurePlan("malformed", "AI returned unreadable output", legacyFallback);
69656
+ }
69657
+ } else {
69658
+ logs2.push("AI planner repair returned empty response");
69659
+ return failurePlan("malformed", "AI returned unreadable output", legacyFallback);
69660
+ }
69661
+ } else {
69662
+ return failurePlan("malformed", "AI returned unreadable output", legacyFallback);
69663
+ }
68812
69664
  }
68813
69665
  let { scenarios, featureGroups, warnings, coverageGaps } = validateAIPlannerOutput(aiOutput, siteMap, context.existingTestNames, context.testBudget, evidence.recordedJourneys, context.mode, context.focusAreas);
68814
69666
  logs2.push(`AI planner: ${scenarios.length} scenarios, ${featureGroups.length} feature groups (${warnings.length} warnings, ${coverageGaps.length} coverage gaps)`);
@@ -68818,7 +69670,7 @@ Each entry is exactly one of:
68818
69670
  let topLevelWarning;
68819
69671
  const totalV1 = scenarios.length + coverageGaps.length;
68820
69672
  const rejectionRateV1 = totalV1 > 0 ? coverageGaps.length / totalV1 : 0;
68821
- if (rejectionRateV1 > 0.5 && totalV1 >= 3) {
69673
+ if (rejectionRateV1 > 0.5 && totalV1 >= 3 && !extraPlannerCallUsed) {
68822
69674
  logs2.push(`AI planner: ${Math.round(rejectionRateV1 * 100)}% rejection rate (${coverageGaps.length}/${totalV1}) \u2014 running self-correction retry`);
68823
69675
  const retryContext = [
68824
69676
  `Your previous emission produced ${coverageGaps.length} coverage gaps out of ${totalV1} scenarios (${Math.round(rejectionRateV1 * 100)}% rejection rate).`,
@@ -68837,6 +69689,7 @@ Each entry is exactly one of:
68837
69689
  const retryStart = Date.now();
68838
69690
  let retryResponse = null;
68839
69691
  try {
69692
+ extraPlannerCallUsed = true;
68840
69693
  retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
68841
69694
  arm();
68842
69695
  return aiClient.chat.completions.create({
@@ -68936,6 +69789,22 @@ Each entry is exactly one of:
68936
69789
  };
68937
69790
  }
68938
69791
  if (context.mode === "survey" && featureGroups.length === 0 && siteMap.pages.length > 0) {
69792
+ const hasGroupWorthyEvidence = siteMap.pages.some((p) => (p.forms?.length ?? 0) > 0 || (p.elements?.length ?? 0) > 0);
69793
+ const noFallback = process.env.DISCOVERY_PLANNER_NO_FALLBACK === "true";
69794
+ if (noFallback && !hasGroupWorthyEvidence) {
69795
+ return {
69796
+ scenarios,
69797
+ featureGroups: [],
69798
+ reasoning: aiOutput.reasoning ?? "",
69799
+ logs: logs2,
69800
+ coverageGaps,
69801
+ warning: topLevelWarning,
69802
+ fallbackUsed: false
69803
+ };
69804
+ }
69805
+ if (noFallback) {
69806
+ 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));
69807
+ }
68939
69808
  logs2.push("Survey AI returned 0 feature groups - falling back to deterministic grouping");
68940
69809
  const fallback = buildFallbackPlan(siteMap, evidence.recordedJourneys, context.mode, logs2, context.existingTestNames ?? [], context.focusAreas, context.testBudget);
68941
69810
  return {
@@ -70146,6 +71015,7 @@ var require_mobile_generator = __commonJS({
70146
71015
  var ai_provider_js_1 = require_ai_provider();
70147
71016
  var ai_queue_js_1 = require_ai_queue();
70148
71017
  var ai_retry_js_1 = require_ai_retry();
71018
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
70149
71019
  var mobile_source_parser_js_1 = require_mobile_source_parser();
70150
71020
  var mobile_source_parser_js_2 = require_mobile_source_parser();
70151
71021
  var mobile_observation_js_1 = require_mobile_observation();
@@ -70559,7 +71429,7 @@ var require_mobile_generator = __commonJS({
70559
71429
  - Re-snapshot before every assertion so the ref is fresh.
70560
71430
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
70561
71431
  - 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.
70562
- - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
71432
+ - 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.
70563
71433
  - 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.
70564
71434
  - 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.
70565
71435
  - 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.
@@ -71053,14 +71923,14 @@ ${statePacket}`, redactionCredentials) },
71053
71923
  const requestMessageCount = messages.length;
71054
71924
  const requestMessages = messages.slice();
71055
71925
  const callStart = Date.now();
71056
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
71926
+ 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)(() => {
71057
71927
  arm();
71058
71928
  return aiClient.chat.completions.create({ model: agentModel, messages, tools: GENERATE_TOOLS, tool_choice: "auto", max_tokens: 8e3 }, { signal });
71059
71929
  }), {
71060
71930
  label: `mobile-generate-${scenario.name.slice(0, 24)}-iter-${iteration}`,
71061
71931
  deferTimeoutUntilArmed: true,
71062
71932
  onRetry: (attempt, reason, delayMs) => log2(` \u26A0 mobile generate AI retry ${attempt} (${reason}) \u2014 waiting ${delayMs}ms`)
71063
- });
71933
+ }));
71064
71934
  const iterDuration = Date.now() - callStart;
71065
71935
  if (!response?.choices?.[0]?.message) {
71066
71936
  throw new Error(`AI generate call returned no response at iteration ${iteration}`);
@@ -71672,6 +72542,21 @@ ${statePacket}`, redactionCredentials) },
71672
72542
  };
71673
72543
  }
71674
72544
  const metadata = metadataForElement(element, screenName(), platform3);
72545
+ const expectedNeedle = expected.trim().toLowerCase();
72546
+ const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
72547
+ const visibleText = (element.text ?? "").trim().toLowerCase();
72548
+ const isSelfLabelAssertion = expectedNeedle.length > 0 && identityValues.includes(expectedNeedle) && !visibleText.includes(expectedNeedle);
72549
+ if (isSelfLabelAssertion) {
72550
+ recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
72551
+ log2(` assert_text DOWNGRADED to assert_visible (tautological self-label "${expected}"): ${metadata.targetLabel}`);
72552
+ return {
72553
+ ok: true,
72554
+ asserted: "visible",
72555
+ downgraded: true,
72556
+ 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.`,
72557
+ target: metadata.targetLabel
72558
+ };
72559
+ }
71675
72560
  if (hasStableId(element)) {
71676
72561
  recordAction({ type: "ASSERT_TEXT", value: expected, selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
71677
72562
  log2(` assert_text: ${metadata.targetLabel} ~ "${expected}"`);
@@ -72365,7 +73250,13 @@ ${statePacket}`, redactionCredentials) },
72365
73250
  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;
72366
73251
  const test = {
72367
73252
  name: compiled.name,
72368
- description: recording.finish?.description ?? compiled.description,
73253
+ // Description priority: the model's live finish description, then the
73254
+ // planner's scenario goal (always a meaningful sentence describing what the
73255
+ // test proves), then the generic compiled placeholder. Without the goal
73256
+ // fallback a scenario whose finish_generate omitted a description shipped
73257
+ // with the contentless "<platform> native flow generated from a recorded
73258
+ // mobile session." string — see mobile-test-generation.ts's compiled default.
73259
+ description: recording.finish?.description?.trim() || scenario.goal?.trim() || compiled.description,
72369
73260
  framework: "APPIUM",
72370
73261
  appiumCode: compiled.appiumCode,
72371
73262
  steps: compiled.steps,
@@ -75042,6 +75933,118 @@ var require_har_stream = __commonJS({
75042
75933
  }
75043
75934
  });
75044
75935
 
75936
+ // ../runner-core/dist/services/api-auth-headers.js
75937
+ var require_api_auth_headers = __commonJS({
75938
+ "../runner-core/dist/services/api-auth-headers.js"(exports) {
75939
+ "use strict";
75940
+ Object.defineProperty(exports, "__esModule", { value: true });
75941
+ exports.parseApiAuthHeaders = parseApiAuthHeaders;
75942
+ exports.resolveApiAuthHeaders = resolveApiAuthHeaders;
75943
+ exports.isAppOriginRequest = isAppOriginRequest;
75944
+ exports.buildOriginScopedApiFixtures = buildOriginScopedApiFixtures;
75945
+ var HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
75946
+ function hasControlChar(v) {
75947
+ for (let i = 0; i < v.length; i++) {
75948
+ const code = v.charCodeAt(i);
75949
+ if (code < 32 || code === 127)
75950
+ return true;
75951
+ }
75952
+ return false;
75953
+ }
75954
+ function parseApiAuthHeaders(raw) {
75955
+ if (!raw || typeof raw !== "string")
75956
+ return void 0;
75957
+ const trimmed = raw.trim();
75958
+ if (!trimmed)
75959
+ return void 0;
75960
+ try {
75961
+ const parsed = JSON.parse(trimmed);
75962
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
75963
+ return void 0;
75964
+ const headers = {};
75965
+ for (const [k, v] of Object.entries(parsed)) {
75966
+ if (typeof k === "string" && HEADER_NAME_RE.test(k) && typeof v === "string" && !hasControlChar(v)) {
75967
+ headers[k] = v;
75968
+ }
75969
+ }
75970
+ return Object.keys(headers).length > 0 ? headers : void 0;
75971
+ } catch {
75972
+ return void 0;
75973
+ }
75974
+ }
75975
+ function resolveApiAuthHeaders(input) {
75976
+ if (input.explicit)
75977
+ return input.explicit;
75978
+ if (!input.isApiTest)
75979
+ return void 0;
75980
+ return parseApiAuthHeaders(input.apiAuthHeadersVar);
75981
+ }
75982
+ function isAppOriginRequest(url, baseUrl) {
75983
+ if (typeof url !== "string" || !baseUrl)
75984
+ return false;
75985
+ let appOrigin;
75986
+ try {
75987
+ appOrigin = new URL(baseUrl).origin;
75988
+ } catch {
75989
+ return false;
75990
+ }
75991
+ try {
75992
+ return new URL(url, baseUrl).origin === appOrigin;
75993
+ } catch {
75994
+ return false;
75995
+ }
75996
+ }
75997
+ function buildOriginScopedApiFixtures(headers, baseUrl) {
75998
+ let appOrigin;
75999
+ try {
76000
+ appOrigin = baseUrl ? new URL(baseUrl).origin : null;
76001
+ } catch {
76002
+ appOrigin = null;
76003
+ }
76004
+ return `export * from '@playwright/test';
76005
+ import { test as base } from '@playwright/test';
76006
+
76007
+ // Standing auth headers \u2014 attached ONLY to same-origin requests so the credential never leaks to
76008
+ // an off-origin host. Embedded at run time; this file lives in the ephemeral run dir.
76009
+ const STANDING_HEADERS = ${JSON.stringify(headers)};
76010
+ const APP_ORIGIN = ${JSON.stringify(appOrigin)};
76011
+ const BASE_URL = ${JSON.stringify(baseUrl)};
76012
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'fetch'];
76013
+
76014
+ function sameOrigin(url) {
76015
+ if (!APP_ORIGIN || typeof url !== 'string') return false;
76016
+ try { return new URL(url, BASE_URL).origin === APP_ORIGIN; } catch { return false; }
76017
+ }
76018
+
76019
+ export const test = base.extend({
76020
+ request: async ({ request }, use) => {
76021
+ const scoped = new Proxy(request, {
76022
+ get(target, prop) {
76023
+ const value = target[prop];
76024
+ // HTTP verbs: wrap to add standing headers on same-origin requests only.
76025
+ if (typeof prop === 'string' && METHODS.indexOf(prop) !== -1) {
76026
+ const orig = value.bind(target);
76027
+ return (url, options) => {
76028
+ options = options || {};
76029
+ return sameOrigin(url)
76030
+ ? orig(url, { ...options, headers: { ...STANDING_HEADERS, ...(options.headers || {}) } })
76031
+ : orig(url, options);
76032
+ };
76033
+ }
76034
+ // Every other METHOD must run with 'this' bound to the REAL context \u2014 an APIRequestContext
76035
+ // uses internal slots/private fields a Proxy can't forward, so calling with 'this'=proxy
76036
+ // would throw. Non-function props pass through untouched.
76037
+ return typeof value === 'function' ? value.bind(target) : value;
76038
+ },
76039
+ });
76040
+ await use(scoped);
76041
+ },
76042
+ });
76043
+ `;
76044
+ }
76045
+ }
76046
+ });
76047
+
75045
76048
  // ../runner-core/dist/services/playwright-native.js
75046
76049
  var require_playwright_native = __commonJS({
75047
76050
  "../runner-core/dist/services/playwright-native.js"(exports) {
@@ -75084,7 +76087,7 @@ var require_playwright_native = __commonJS({
75084
76087
  };
75085
76088
  })();
75086
76089
  Object.defineProperty(exports, "__esModule", { value: true });
75087
- exports.scrubHarRequestBody = void 0;
76090
+ exports.scrubHarRequestBody = exports.buildOriginScopedApiFixtures = exports.isAppOriginRequest = exports.resolveApiAuthHeaders = exports.parseApiAuthHeaders = void 0;
75088
76091
  exports.executeTestViaNativePlaywright = executeTestViaNativePlaywright2;
75089
76092
  exports.parseJsonResults = parseJsonResults2;
75090
76093
  exports.parseHarToApiSummary = parseHarToApiSummary;
@@ -75096,6 +76099,20 @@ var require_playwright_native = __commonJS({
75096
76099
  var spawn_with_tree_kill_js_1 = require_spawn_with_tree_kill();
75097
76100
  var api_call_redaction_js_1 = require_api_call_redaction();
75098
76101
  var har_stream_js_1 = require_har_stream();
76102
+ var api_auth_headers_js_1 = require_api_auth_headers();
76103
+ var api_auth_headers_js_2 = require_api_auth_headers();
76104
+ Object.defineProperty(exports, "parseApiAuthHeaders", { enumerable: true, get: function() {
76105
+ return api_auth_headers_js_2.parseApiAuthHeaders;
76106
+ } });
76107
+ Object.defineProperty(exports, "resolveApiAuthHeaders", { enumerable: true, get: function() {
76108
+ return api_auth_headers_js_2.resolveApiAuthHeaders;
76109
+ } });
76110
+ Object.defineProperty(exports, "isAppOriginRequest", { enumerable: true, get: function() {
76111
+ return api_auth_headers_js_2.isAppOriginRequest;
76112
+ } });
76113
+ Object.defineProperty(exports, "buildOriginScopedApiFixtures", { enumerable: true, get: function() {
76114
+ return api_auth_headers_js_2.buildOriginScopedApiFixtures;
76115
+ } });
75099
76116
  var api_call_redaction_js_2 = require_api_call_redaction();
75100
76117
  Object.defineProperty(exports, "scrubHarRequestBody", { enumerable: true, get: function() {
75101
76118
  return api_call_redaction_js_2.scrubHarRequestBody;
@@ -75105,7 +76122,7 @@ var require_playwright_native = __commonJS({
75105
76122
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
75106
76123
  var PW_CLI = __require.resolve("@playwright/test/cli");
75107
76124
  async function executeTestViaNativePlaywright2(options) {
75108
- const { playwrightCode, baseUrl, testName, testVariables, storageStatePath, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
76125
+ const { playwrightCode, baseUrl, testName, testVariables, storageStatePath, extraHttpHeaders, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
75109
76126
  const logs2 = [];
75110
76127
  const screenshots = [];
75111
76128
  logs2.push(`\u{1F3AD} Native Playwright starting`);
@@ -75119,6 +76136,11 @@ var require_playwright_native = __commonJS({
75119
76136
  runDir = (0, path_1.join)(RUNS_DIR, (0, crypto_1.randomUUID)());
75120
76137
  await (0, promises_1.mkdir)(runDir, { recursive: true });
75121
76138
  const isApiTest2 = playwrightCode.includes("@api") || playwrightCode.includes("{ request }") && !playwrightCode.includes("{ page");
76139
+ const resolvedExtraHeaders = (0, api_auth_headers_js_1.resolveApiAuthHeaders)({
76140
+ explicit: extraHttpHeaders,
76141
+ isApiTest: isApiTest2,
76142
+ apiAuthHeadersVar: testVariables?.API_AUTH_HEADERS
76143
+ });
75122
76144
  const cssDisableSetup = `
75123
76145
  const __qaLiveBrowserStateFile = process.env.VALIDATE_LIVE_BROWSER_STATE_PATH;
75124
76146
  let __qaLiveBrowserInterval: ReturnType<typeof setInterval> | null = null;
@@ -75312,8 +76334,17 @@ test.afterEach(async ({ page }, testInfo) => {
75312
76334
  if (!importMatch) {
75313
76335
  logs2.push("\u26A0 Could not find @playwright/test import - animation disabling NOT injected");
75314
76336
  }
75315
- const instrumentedCode = isApiTest2 ? playwrightCode : importMatch ? playwrightCode.replace(importMatch[0], importMatch[0] + cssDisableSetup) : playwrightCode;
76337
+ const useScopedApiHeaders = isApiTest2 && !!resolvedExtraHeaders && !!importMatch && !!baseUrl;
76338
+ let instrumentedCode;
76339
+ if (isApiTest2) {
76340
+ instrumentedCode = useScopedApiHeaders ? playwrightCode.replace(importMatch[0], importMatch[0].replace(/from\s+['"]@playwright\/test['"]/, "from './qa-api-fixtures'")) : playwrightCode;
76341
+ } else {
76342
+ instrumentedCode = importMatch ? playwrightCode.replace(importMatch[0], importMatch[0] + cssDisableSetup) : playwrightCode;
76343
+ }
75316
76344
  await (0, promises_1.writeFile)((0, path_1.join)(runDir, "test.spec.ts"), instrumentedCode);
76345
+ if (useScopedApiHeaders && resolvedExtraHeaders) {
76346
+ await (0, promises_1.writeFile)((0, path_1.join)(runDir, "qa-api-fixtures.ts"), (0, api_auth_headers_js_1.buildOriginScopedApiFixtures)(resolvedExtraHeaders, baseUrl));
76347
+ }
75317
76348
  const headless = process.env.PLAYWRIGHT_HEADLESS === "true" || process.env.NODE_ENV === "production" || IS_CLOUD2;
75318
76349
  logs2.push(`\u2699\uFE0F Config: baseURL=${JSON.stringify(baseUrl)}, headless=${headless}${isApiTest2 ? ", API mode (no screenshot/video)" : ""}`);
75319
76350
  const absTestDir = runDir.replace(/\\/g, "/");
@@ -75327,6 +76358,7 @@ test.afterEach(async ({ page }, testInfo) => {
75327
76358
  const absHarPath = wantHar ? (0, path_1.join)(runDir, "session.har").replace(/\\/g, "/") : null;
75328
76359
  const recordHarLine = absHarPath ? `recordHar: { path: ${JSON.stringify(absHarPath)}, mode: 'full' },` : "";
75329
76360
  const storageStateLine = storageStatePath ? `storageState: ${JSON.stringify(storageStatePath.replace(/\\/g, "/"))},` : "";
76361
+ const extraHeadersLine = resolvedExtraHeaders && !useScopedApiHeaders && Object.keys(resolvedExtraHeaders).length > 0 ? `extraHTTPHeaders: ${JSON.stringify(resolvedExtraHeaders)},` : "";
75330
76362
  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'],";
75331
76363
  const config = `
75332
76364
  // Plain object export - no require('@playwright/test') needed.
@@ -75340,6 +76372,7 @@ module.exports = {
75340
76372
  use: {
75341
76373
  baseURL: ${JSON.stringify(baseUrl)},
75342
76374
  ${storageStateLine}
76375
+ ${extraHeadersLine}
75343
76376
  browserName: 'chromium',
75344
76377
  headless: ${headless},
75345
76378
  screenshot: ${isApiTest2 ? "'off'" : "'on'"},
@@ -75381,6 +76414,7 @@ module.exports = {
75381
76414
  // code. Forwarded (not allowlisted) so it stays a per-run toggle; absent → inert.
75382
76415
  ...process.env.SHARED_AUTH_INJECT ? { SHARED_AUTH_INJECT: process.env.SHARED_AUTH_INJECT } : {}
75383
76416
  };
76417
+ delete envWithIsolation.API_AUTH_HEADERS;
75384
76418
  logs2.push("\u{1F680} Spawning playwright test...");
75385
76419
  const stopLiveBrowserPolling = liveBrowserStatePath && liveBrowserHandle ? startLiveBrowserStatePolling(liveBrowserStatePath, liveBrowserHandle, onLiveBrowserSnapshot) : null;
75386
76420
  let playResult;
@@ -79232,6 +80266,34 @@ var require_anchor_harvester = __commonJS({
79232
80266
  }
79233
80267
  });
79234
80268
 
80269
+ // ../runner-core/dist/services/discovery-lint-flags.js
80270
+ var require_discovery_lint_flags = __commonJS({
80271
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
80272
+ "use strict";
80273
+ Object.defineProperty(exports, "__esModule", { value: true });
80274
+ exports.structuralLocatorLintMode = structuralLocatorLintMode;
80275
+ exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
80276
+ exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
80277
+ function strictOn(value) {
80278
+ return value === "true" || value === "1";
80279
+ }
80280
+ function structuralLocatorLintMode() {
80281
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
80282
+ if (v === "warn")
80283
+ return "warn";
80284
+ if (v === "true" || v === "1" || v === "enforce")
80285
+ return "enforce";
80286
+ return "off";
80287
+ }
80288
+ function isPwMethodPreflightEnabled() {
80289
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
80290
+ }
80291
+ function isVqaLoginDedupeEnabled() {
80292
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
80293
+ }
80294
+ }
80295
+ });
80296
+
79235
80297
  // ../runner-core/dist/services/auth-recipe.js
79236
80298
  var require_auth_recipe = __commonJS({
79237
80299
  "../runner-core/dist/services/auth-recipe.js"(exports) {
@@ -79254,9 +80316,11 @@ var require_auth_recipe = __commonJS({
79254
80316
  exports.extractAuthRegion = extractAuthRegion;
79255
80317
  exports.stripAuthRegions = stripAuthRegions;
79256
80318
  exports.ensureExpectImported = ensureExpectImported;
80319
+ exports.ensureLoginCall = ensureLoginCall;
79257
80320
  exports.upsertAuthBlock = upsertAuthBlock;
79258
80321
  exports.buildHealAuthStamp = buildHealAuthStamp;
79259
80322
  var crypto_1 = __require("crypto");
80323
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
79260
80324
  exports.AUTH_MARKER_VERSION = "v1";
79261
80325
  var START_MARKER = `// >>> VALIDATE_QA_AUTH ${exports.AUTH_MARKER_VERSION} \u2014 generated, do not edit >>>`;
79262
80326
  var END_MARKER = `// <<< VALIDATE_QA_AUTH ${exports.AUTH_MARKER_VERSION} <<<`;
@@ -79520,10 +80584,13 @@ ${tail}`;
79520
80584
  var TEST_OPENER_RE = /test(?:\.(?:only|skip|fixme))?\s*\(\s*(['"`])(?:[^\\]|\\.)*?\1\s*,\s*(?:\{[\s\S]*?\}\s*,\s*)?async\s*\(\s*\{[^}]*\}[^)]*\)\s*=>\s*\{/;
79521
80585
  var LOGIN_CALL_RE = /await\s+__vqaLogin\s*\(\s*page\s*\)/;
79522
80586
  function ensureLoginCall(code) {
79523
- const matches = [...code.matchAll(new RegExp(TEST_OPENER_RE.source, "g"))];
79524
- if (matches.length === 0)
80587
+ if (code.match(new RegExp(TEST_OPENER_RE.source, "g")) === null)
79525
80588
  return code;
79526
80589
  let result = code;
80590
+ if ((0, discovery_lint_flags_js_1.isVqaLoginDedupeEnabled)()) {
80591
+ result = result.replace(/^[ \t]*await[ \t]+__vqaLogin[ \t]*\([ \t]*page[ \t]*\)[ \t]*;?[ \t]*\r?\n/gm, "");
80592
+ }
80593
+ const matches = [...result.matchAll(new RegExp(TEST_OPENER_RE.source, "g"))];
79527
80594
  for (let i = matches.length - 1; i >= 0; i--) {
79528
80595
  const m = matches[i];
79529
80596
  if (m.index === void 0)
@@ -83415,7 +84482,7 @@ var require_discover_generator = __commonJS({
83415
84482
  "../runner-core/dist/services/discover-generator.js"(exports) {
83416
84483
  "use strict";
83417
84484
  Object.defineProperty(exports, "__esModule", { value: true });
83418
- exports.MIN_BUG_DESCRIPTION_LENGTH = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
84485
+ exports.MIN_BUG_DESCRIPTION_LENGTH = exports.VALID_PLAYWRIGHT_GETBY_METHODS = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
83419
84486
  exports.filterMcpToolsForScenarioGen = filterMcpToolsForScenarioGen;
83420
84487
  exports.detectScenarioSignals = detectScenarioSignals;
83421
84488
  exports.scenarioStepsCoverAuth = scenarioStepsCoverAuth;
@@ -83438,6 +84505,7 @@ var require_discover_generator = __commonJS({
83438
84505
  exports.upgradePositionalProvenLines = upgradePositionalProvenLines;
83439
84506
  exports.synthesizeRefActionLines = synthesizeRefActionLines;
83440
84507
  exports.renderSessionRecording = renderSessionRecording;
84508
+ exports.scanFragileStructuralLocators = scanFragileStructuralLocators;
83441
84509
  exports.validateFinalActionFidelity = validateFinalActionFidelity;
83442
84510
  exports.resolveSuspectedBugVerdict = resolveSuspectedBugVerdict;
83443
84511
  exports.buildScenarioSystemPrompt = buildScenarioSystemPrompt;
@@ -83453,6 +84521,7 @@ var require_discover_generator = __commonJS({
83453
84521
  var ai_provider_js_1 = require_ai_provider();
83454
84522
  var ai_queue_js_1 = require_ai_queue();
83455
84523
  var ai_retry_js_1 = require_ai_retry();
84524
+ var discover_ai_planner_js_1 = require_discover_ai_planner();
83456
84525
  var snapshot_parser_js_1 = require_snapshot_parser();
83457
84526
  var scrub_credentials_js_1 = require_scrub_credentials();
83458
84527
  var exploration_parser_js_1 = require_exploration_parser();
@@ -83465,6 +84534,7 @@ var require_discover_generator = __commonJS({
83465
84534
  var auth_recipe_js_1 = require_auth_recipe();
83466
84535
  var auth_replay_js_1 = require_auth_replay();
83467
84536
  var agentic_discovery_prompts_js_1 = require_agentic_discovery_prompts();
84537
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
83468
84538
  exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = /* @__PURE__ */ new Set([
83469
84539
  "browser_install",
83470
84540
  "browser_close",
@@ -84189,6 +85259,15 @@ ${elementContext}${formContext}${observationContext}${interactionContext}${lowCo
84189
85259
  return assembled.join(separator);
84190
85260
  }
84191
85261
  var SENSITIVE_ENV_KEY_RE = /email|password|token|secret|api[_-]?key|access[_-]?key|username|user(name)?|bearer|session|cookie|\bpat\b|refresh|jwt/i;
85262
+ exports.VALID_PLAYWRIGHT_GETBY_METHODS = /* @__PURE__ */ new Set([
85263
+ "getByRole",
85264
+ "getByText",
85265
+ "getByLabel",
85266
+ "getByPlaceholder",
85267
+ "getByTestId",
85268
+ "getByAltText",
85269
+ "getByTitle"
85270
+ ]);
84192
85271
  var VALID_PLAYWRIGHT_MATCHERS = /* @__PURE__ */ new Set([
84193
85272
  // Locator assertions
84194
85273
  "toBeAttached",
@@ -84676,6 +85755,16 @@ ${testOpen}`;
84676
85755
  return false;
84677
85756
  }
84678
85757
  }
85758
+ if ((0, discovery_lint_flags_js_1.isPwMethodPreflightEnabled)()) {
85759
+ const getByCalls = codeForMatcherScan.match(/\.getBy[A-Za-z0-9_]*\s*\(/g) ?? [];
85760
+ for (const call of getByCalls) {
85761
+ const methodName = call.replace(/[\s(]+$/, "").slice(1);
85762
+ if (!exports.VALID_PLAYWRIGHT_GETBY_METHODS.has(methodName)) {
85763
+ logs2?.push(` \u2717 Rejected "${t.name}": invalid Playwright locator method "${methodName}" (not a real getBy* API \u2014 likely hallucinated)`);
85764
+ return false;
85765
+ }
85766
+ }
85767
+ }
84679
85768
  if (code.includes("// TODO")) {
84680
85769
  logs2?.push(` \u26A0 "${t.name}" contains TODO comment \u2014 marking as unverified`);
84681
85770
  t.tags = t.tags.filter((tag) => tag !== "@verified");
@@ -85789,6 +86878,35 @@ ${testOpen}`;
85789
86878
  }
85790
86879
  return null;
85791
86880
  }
86881
+ var STRUCTURAL_ACTION_RE = /\.(?:click|dblclick|tap|fill|type|pressSequentially|press|selectOption|hover|check|uncheck|setChecked|setInputFiles|dragTo|focus|clear)\s*\(/;
86882
+ var POSITIONAL_PSEUDO_RE = /:(?:last-child|first-child|nth-child|nth-of-type|nth-last-child|nth-last-of-type)\b/i;
86883
+ var TABLE_TOKEN_RE = /\b(?:tr|td|th|tbody|thead)\b/i;
86884
+ var ROW_ANCHOR_RE = /getByRole\s*\(\s*['"`]row['"`]|getByTestId|data-testid|data-test\b|\[role\s*=\s*['"`]row['"`]\]/i;
86885
+ var LOCATOR_ARG_RE = /\.locator\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
86886
+ function scanFragileStructuralLocators(code) {
86887
+ const findings = [];
86888
+ const scanCode = code.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
86889
+ for (const rawLine of scanCode.split("\n")) {
86890
+ const line = rawLine.replace(/\/\/.*$/, "");
86891
+ if (!STRUCTURAL_ACTION_RE.test(line))
86892
+ continue;
86893
+ if (ROW_ANCHOR_RE.test(line))
86894
+ continue;
86895
+ LOCATOR_ARG_RE.lastIndex = 0;
86896
+ let m;
86897
+ while ((m = LOCATOR_ARG_RE.exec(line)) !== null) {
86898
+ const css = m[2];
86899
+ if (POSITIONAL_PSEUDO_RE.test(css) && TABLE_TOKEN_RE.test(css)) {
86900
+ findings.push({
86901
+ line: rawLine.trim().slice(0, 200),
86902
+ 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>' }).`
86903
+ });
86904
+ break;
86905
+ }
86906
+ }
86907
+ }
86908
+ return findings;
86909
+ }
85792
86910
  function validateFinalActionFidelity(playwrightCode, actionLedger, credentials, opts) {
85793
86911
  const ledgerSigs = [];
85794
86912
  for (const entry of actionLedger) {
@@ -86228,6 +87346,7 @@ ${tailBlock}`;
86228
87346
  let actionRevisionAttempted = false;
86229
87347
  let ambiguityRevisionAttempted = false;
86230
87348
  let strengthRevisionAttempted = false;
87349
+ let structuralRevisionAttempted = false;
86231
87350
  let lastRenderedLedgerEntryCount = 0;
86232
87351
  const successfulVerifications = [];
86233
87352
  const actionLedger = [];
@@ -86354,7 +87473,15 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
86354
87473
  tools: openaiTools,
86355
87474
  tool_choice: "auto",
86356
87475
  temperature: 0.2,
86357
- max_tokens: 8192
87476
+ // Shared provider-aware output ceiling (same helper the survey planner uses):
87477
+ // generous where the provider's max output is verified large (deepseek/minimax
87478
+ // 65536), conservative elsewhere (16384). The old flat 8192 truncated large
87479
+ // finish_generate emissions mid-JSON — the loop re-prompted on the parse error
87480
+ // (line ~4744) and, for a test that genuinely needs >8192 output tokens, could
87481
+ // never finish before the hard stop → zero tests for that scenario. It's a
87482
+ // ceiling, not a target (calls finish at finish_reason:'stop' well under it), so
87483
+ // this adds no per-call cost.
87484
+ max_tokens: (0, discover_ai_planner_js_1.getAIPlannerMaxTokens)(agentModel)
86358
87485
  }, { signal });
86359
87486
  }), {
86360
87487
  label: `scenario-gen-iter-${iteration}`,
@@ -86498,6 +87625,33 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
86498
87625
  });
86499
87626
  continue;
86500
87627
  }
87628
+ const structuralLintMode = (0, discovery_lint_flags_js_1.structuralLocatorLintMode)();
87629
+ const structuralFindings = structuralLintMode !== "off" ? scanFragileStructuralLocators(validated[0].playwrightCode) : [];
87630
+ if (structuralLintMode === "warn" && structuralFindings.length > 0) {
87631
+ logs2.push(` Structural-locator gate [warn]: ${structuralFindings.length} positional table-row selector(s) \u2014 ${structuralFindings.map((f) => sanitizeLedgerText(f.line)).join(" | ").slice(0, 300)}`);
87632
+ }
87633
+ if (structuralLintMode === "enforce" && structuralFindings.length > 0 && !structuralRevisionAttempted) {
87634
+ structuralRevisionAttempted = true;
87635
+ const renderedLedger = actionLedger.length > 0 ? renderSessionRecording(actionLedger, credentials) : "";
87636
+ if (renderedLedger)
87637
+ lastRenderedLedgerEntryCount = actionLedger.length;
87638
+ logs2.push(` Structural-locator gate: ${structuralFindings.length} positional table-row selector(s) found \u2014 requesting one revision (recording attached: ${renderedLedger ? "yes" : "no"})`);
87639
+ const structuralPayload = {
87640
+ acknowledged: false,
87641
+ valid: false,
87642
+ revisionRequired: true,
87643
+ reason: "fragile_structural_locator",
87644
+ 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.",
87645
+ offendingLocators: structuralFindings.map((f) => ({
87646
+ line: sanitizeLedgerText(f.line),
87647
+ reason: f.reason
87648
+ }))
87649
+ };
87650
+ if (renderedLedger)
87651
+ structuralPayload.sessionRecording = renderedLedger;
87652
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(structuralPayload) });
87653
+ continue;
87654
+ }
86501
87655
  const ambiguityGateEval = evaluateAmbiguityGate(validated[0].playwrightCode, ambiguityRevisionAttempted, iteration < MAX_SCENARIO_ITERATIONS);
86502
87656
  const ambiguousLocators = ambiguityGateEval.findings;
86503
87657
  if (ambiguityGateEval.fire) {
@@ -86594,12 +87748,16 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86594
87748
  const strengthSeverity = strengthVerdict?.severity ?? "none";
86595
87749
  const hasResidualViolations = qualityCheck.offendingLines.length > 0;
86596
87750
  const hasResidualActionDrift = actionCheck.offendingActions.length > 0;
87751
+ const hasResidualStructural = structuralLintMode === "enforce" && structuralFindings.length > 0;
86597
87752
  if (hasResidualViolations) {
86598
87753
  logs2.push(` Assertion quality gate: violations remain after revision \u2014 downgrading to @needs-review`);
86599
87754
  }
86600
87755
  if (hasResidualActionDrift) {
86601
87756
  logs2.push(` Action fidelity gate: ${actionCheck.offendingActions.length} action drift(s) remain after revision \u2014 downgrading to @needs-review`);
86602
87757
  }
87758
+ if (hasResidualStructural) {
87759
+ logs2.push(` Structural-locator gate: ${structuralFindings.length} positional table-row selector(s) remain after revision \u2014 downgrading to @needs-review`);
87760
+ }
86603
87761
  let actualVerified = verified;
86604
87762
  if (verified && verifyCallCount === 0) {
86605
87763
  actualVerified = false;
@@ -86652,6 +87810,16 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86652
87810
  residual: ambiguousLocators.length,
86653
87811
  lines: ambiguousLocators.map((a) => sanitizeLedgerText(a.line))
86654
87812
  },
87813
+ // G2 — omitted entirely when off so the off-path healingContext is
87814
+ // byte-identical; present in warn (measure) + enforce (act) modes.
87815
+ ...structuralLintMode !== "off" ? {
87816
+ structuralGate: {
87817
+ mode: structuralLintMode,
87818
+ fired: structuralRevisionAttempted,
87819
+ residual: structuralFindings.length,
87820
+ lines: structuralFindings.map((f) => sanitizeLedgerText(f.line))
87821
+ }
87822
+ } : {},
86655
87823
  // Durable marker the server promote-seam (failure-analyzer.checkTestStability)
86656
87824
  // reads to decide ACTIVE vs QUARANTINED(@bug-found) vs hold-DRAFT. Omitted when
86657
87825
  // the gate errored (fail-open) so a missing marker is treated as "promote".
@@ -86677,7 +87845,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86677
87845
  verified: actualVerified,
86678
87846
  healingContext: { ...baseHealingContext, gateActivity }
86679
87847
  };
86680
- if ((hasResidualViolations || hasResidualActionDrift || hasUncoveredOutcomes) && !result.tags.includes("@needs-review")) {
87848
+ if ((hasResidualViolations || hasResidualActionDrift || hasUncoveredOutcomes || hasResidualStructural) && !result.tags.includes("@needs-review")) {
86681
87849
  result.tags.push("@needs-review");
86682
87850
  }
86683
87851
  if (actualVerified) {
@@ -320911,675 +322079,6 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
320911
322079
  }
320912
322080
  });
320913
322081
 
320914
- // ../runner-core/dist/services/mobile/mobile-exploration-state.js
320915
- var require_mobile_exploration_state = __commonJS({
320916
- "../runner-core/dist/services/mobile/mobile-exploration-state.js"(exports) {
320917
- "use strict";
320918
- Object.defineProperty(exports, "__esModule", { value: true });
320919
- exports.MobileExplorationState = void 0;
320920
- exports.stableMobileControlIdentity = stableMobileControlIdentity;
320921
- exports.classifySafeMobileNavigationControl = classifySafeMobileNavigationControl;
320922
- exports.deriveScreenId = deriveScreenId;
320923
- var mobile_observation_js_1 = require_mobile_observation();
320924
- var DEFAULT_STALE_THRESHOLD = 4;
320925
- var MAX_SCREEN_OBSERVATIONS = 50;
320926
- var EDITABLE_CONTROL_ROLE_RE = /(?:android\.widget\.(?:EditText|AutoCompleteTextView)|XCUIElementType(?:TextField|SecureTextField|SearchField|TextView)|textbox|searchbox|combobox|textarea|select)$/i;
320927
- var STATEFUL_CONTROL_ROLE_RE = /(?:Switch|CheckBox|Checkbox|RadioButton|radio|Slider|SeekBar|Stepper|PickerWheel|Toggle)$/i;
320928
- 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;
320929
- var SAFE_MODAL_TRIGGER_LABEL_RE = /^(?:create|new|add|edit|invite|configure|manage|details|preview|share)$/i;
320930
- var MENU_TRIGGER_LABEL_RE = /^(?:more|menu|options|actions|filter|filters|sort|columns|customize)$/i;
320931
- var GENERIC_NAVIGATION_DESTINATION_LABEL_RE = /^(?:home|feed|dashboard|profile|settings|inbox|messages?|notifications?|projects?|about|privacy|terms|learn\s+more)$/i;
320932
- var SYSTEM_BACK_CONTROL_LABEL_RE = /^(?:back|go\s+back|navigate\s+up|navigation\s+up|up)$/i;
320933
- 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;
320934
- function normalizeControlIdentityPart(value) {
320935
- return value.trim().replace(/\s+/g, " ").toLowerCase();
320936
- }
320937
- function semanticControlAlias(label) {
320938
- return `navigation-label:${normalizeControlIdentityPart(label)}`;
320939
- }
320940
- function localControlCoverageIdentity(screenId, identity) {
320941
- return `screen:${screenId}:${identity}`;
320942
- }
320943
- function storedMobileControlIdentity(candidate) {
320944
- return [
320945
- candidate.identity,
320946
- `kind:${candidate.kind}`,
320947
- `label:${normalizeControlIdentityPart(candidate.label)}`
320948
- ].join(":");
320949
- }
320950
- function isSystemBackNavigationControl(element, label) {
320951
- if (SYSTEM_BACK_CONTROL_LABEL_RE.test(normalizeControlIdentityPart(label)))
320952
- return true;
320953
- return [element.accessibilityId, element.resourceId].filter((value) => Boolean(value?.trim())).some((value) => {
320954
- const leaf = normalizeControlIdentityPart(value).split(/[/:.]/).filter(Boolean).at(-1) ?? "";
320955
- return SYSTEM_BACK_CONTROL_ID_RE.test(leaf);
320956
- });
320957
- }
320958
- function stableMobileControlIdentity(element) {
320959
- const role = normalizeControlIdentityPart(element.role || "control");
320960
- const accessibilityId = element.accessibilityId?.trim();
320961
- if (accessibilityId)
320962
- return `a11y:${role}:${normalizeControlIdentityPart(accessibilityId)}`;
320963
- const resourceId = element.resourceId?.trim();
320964
- if (resourceId)
320965
- return `resource:${role}:${normalizeControlIdentityPart(resourceId)}`;
320966
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320967
- if (label)
320968
- return `semantic:${role}:${normalizeControlIdentityPart(label)}`;
320969
- return null;
320970
- }
320971
- function classifySafeMobileNavigationControl(element) {
320972
- if (!element.actionable || element.enabled === false)
320973
- return null;
320974
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320975
- const safetyIdentity = [label, element.accessibilityId, element.resourceId].filter(Boolean).join(" ");
320976
- if (!label || UNSAFE_OR_COMMIT_LABEL_RE.test(safetyIdentity))
320977
- return null;
320978
- if (isSystemBackNavigationControl(element, label))
320979
- return null;
320980
- const role = element.role.trim();
320981
- if (EDITABLE_CONTROL_ROLE_RE.test(role) || STATEFUL_CONTROL_ROLE_RE.test(role))
320982
- return null;
320983
- const linkLike = /(?:Link$|^a$)/i.test(role);
320984
- let kind = null;
320985
- if (/(?:TabBarItem|\btab\b)/i.test(role))
320986
- kind = "tab";
320987
- else if (/menuitem/i.test(role))
320988
- kind = "menu-item";
320989
- else if (linkLike && element.navigationProvenance)
320990
- kind = "link";
320991
- else if (linkLike)
320992
- return null;
320993
- else if (element.hasPopup && element.hasPopup.toLowerCase() !== "false")
320994
- kind = "menu-trigger";
320995
- else if (element.expanded === false)
320996
- kind = "disclosure";
320997
- else if (MENU_TRIGGER_LABEL_RE.test(label))
320998
- kind = "menu-trigger";
320999
- else if (SAFE_MODAL_TRIGGER_LABEL_RE.test(label))
321000
- kind = "modal-trigger";
321001
- else if (element.navigationProvenance)
321002
- kind = "navigation-button";
321003
- else if (GENERIC_NAVIGATION_DESTINATION_LABEL_RE.test(label))
321004
- kind = "navigation-button";
321005
- if (!kind)
321006
- return null;
321007
- const identity = stableMobileControlIdentity(element);
321008
- return identity ? { identity, label, kind } : null;
321009
- }
321010
- function sharedMobileControlCoverageIdentity(element, candidate, semanticFamilyId) {
321011
- if (!element.navigationProvenance)
321012
- return void 0;
321013
- const durableId = element.accessibilityId?.trim() ? `a11y:${normalizeControlIdentityPart(element.accessibilityId)}` : element.resourceId?.trim() ? `resource:${normalizeControlIdentityPart(element.resourceId)}` : "";
321014
- if (!durableId && !semanticFamilyId)
321015
- return void 0;
321016
- return [
321017
- "shared-navigation",
321018
- element.navigationProvenance,
321019
- !durableId && semanticFamilyId ? `family:${semanticFamilyId}` : "",
321020
- candidate.kind,
321021
- durableId || "semantic-only",
321022
- normalizeControlIdentityPart(candidate.label)
321023
- ].filter(Boolean).join(":");
321024
- }
321025
- function mobileNavigationRegion(element, windowSize) {
321026
- if (!windowSize || windowSize.width <= 0 || windowSize.height <= 0)
321027
- return "unknown";
321028
- const centerX = (element.bounds.x + element.bounds.width / 2) / windowSize.width;
321029
- const centerY = (element.bounds.y + element.bounds.height / 2) / windowSize.height;
321030
- if (centerY >= 0.72)
321031
- return "bottom";
321032
- if (centerY <= 0.28)
321033
- return "top";
321034
- if (centerX <= 0.35)
321035
- return "left";
321036
- if (centerX >= 0.65)
321037
- return "right";
321038
- return "center";
321039
- }
321040
- function buildSemanticNavigationGroups(elements, windowSize) {
321041
- const groups = /* @__PURE__ */ new Map();
321042
- for (const element of elements) {
321043
- if (!element.navigationProvenance)
321044
- continue;
321045
- const candidate = classifySafeMobileNavigationControl(element);
321046
- if (!candidate)
321047
- continue;
321048
- const groupKey = `${element.navigationProvenance}:${mobileNavigationRegion(element, windowSize)}`;
321049
- const labels = groups.get(groupKey) ?? /* @__PURE__ */ new Set();
321050
- labels.add(`${candidate.kind}:${normalizeControlIdentityPart(candidate.label)}`);
321051
- groups.set(groupKey, labels);
321052
- }
321053
- return new Map([...groups].filter(([, labels]) => labels.size >= 2));
321054
- }
321055
- function deriveScreenId(signal) {
321056
- const namespace = signal.activity?.trim() || signal.bundleId?.trim();
321057
- return namespace ? `${namespace}#${signal.screenHash}` : signal.screenHash;
321058
- }
321059
- function observedElementIdentity(element) {
321060
- return (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(element);
321061
- }
321062
- function mergeObservedElements(previous, current) {
321063
- const merged = [...current];
321064
- const seen = new Set(current.map(observedElementIdentity));
321065
- for (const element of previous) {
321066
- const identity = observedElementIdentity(element);
321067
- if (seen.has(identity))
321068
- continue;
321069
- seen.add(identity);
321070
- merged.push(element);
321071
- }
321072
- return merged;
321073
- }
321074
- function sanitizeElementsForEvidence(elements) {
321075
- return elements.map((element) => {
321076
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element);
321077
- return {
321078
- ...element,
321079
- ...label ? { label } : { label: void 0 },
321080
- text: void 0
321081
- };
321082
- });
321083
- }
321084
- var MobileExplorationState = class {
321085
- /** screenId -> ScreenState (the mobile analog of routeState). */
321086
- screens = /* @__PURE__ */ new Map();
321087
- /** Observed directed transitions between screens. */
321088
- transitions = [];
321089
- /** Transition-announced screens that do not yet have a live observation. */
321090
- frontier = /* @__PURE__ */ new Map();
321091
- /** Safe navigation controls observed on each screen, keyed by stable identity. */
321092
- safeControlsByScreen = /* @__PURE__ */ new Map();
321093
- /** Screen-scoped coverage keys plus explicitly proven shared-navigation keys. */
321094
- attemptedSafeControlCoverageIdentities = /* @__PURE__ */ new Set();
321095
- successfulSafeControlAttemptCount = 0;
321096
- /** Repeated id-less structural navigation families, grouped by region. */
321097
- semanticNavigationFamilies = /* @__PURE__ */ new Map();
321098
- nextSemanticNavigationFamilyId = 1;
321099
- /** The screen-id of the most recently observed screen. */
321100
- currentScreenId = null;
321101
- /** Latest iteration recorded (drives stale detection + firstSeen stamps). */
321102
- iteration = 0;
321103
- /** Stale detector: progress mark (#screens + #total elements) at last advance. */
321104
- lastProgressMark = 0;
321105
- /** Consecutive iterations observed with no progress. */
321106
- staleIterations = 0;
321107
- staleThreshold;
321108
- constructor(options = {}) {
321109
- this.staleThreshold = typeof options.staleThreshold === "number" && options.staleThreshold > 0 ? Math.floor(options.staleThreshold) : DEFAULT_STALE_THRESHOLD;
321110
- }
321111
- // -------------------------------------------------------------------------
321112
- // Observation recording
321113
- // -------------------------------------------------------------------------
321114
- /**
321115
- * Returns true when `signal` identifies a screen this run has never observed
321116
- * before. Pure read — does not mutate state.
321117
- */
321118
- isNewScreen(signal) {
321119
- return !this.screens.has(deriveScreenId(signal));
321120
- }
321121
- /**
321122
- * Record an observation of `screen` at `iteration`. Creates a `ScreenState`
321123
- * on first sight (stamping `firstSeenIteration`), otherwise refreshes the
321124
- * stored element set (latest-wins, mirroring the web reducer's PAGE_CAPTURED
321125
- * element refresh). A driver observation already contains the structural UI
321126
- * tree, elements, screenshot association, and screen identity, so it is
321127
- * captured evidence immediately; the model's capture_screen call only adds
321128
- * a friendly name/type. Returns the resolved screen-id.
321129
- */
321130
- recordObservation(signal, screen, iteration) {
321131
- this.iteration = iteration;
321132
- const screenId = deriveScreenId(signal);
321133
- const evidenceElements = sanitizeElementsForEvidence(screen.elements);
321134
- const existing = this.screens.get(screenId);
321135
- if (existing) {
321136
- this.screens.set(screenId, {
321137
- ...existing,
321138
- elements: mergeObservedElements(existing.elements, evidenceElements)
321139
- });
321140
- } else {
321141
- this.screens.set(screenId, {
321142
- screenId,
321143
- firstSeenIteration: iteration,
321144
- elements: evidenceElements,
321145
- visited: true
321146
- });
321147
- }
321148
- this.frontier.delete(screenId);
321149
- const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
321150
- const semanticNavigationGroups = buildSemanticNavigationGroups(evidenceElements, screen.windowSize);
321151
- const semanticNavigationFamilyIds = /* @__PURE__ */ new Map();
321152
- for (const [groupKey, labels] of semanticNavigationGroups) {
321153
- semanticNavigationFamilyIds.set(groupKey, this.resolveSemanticNavigationFamilyId(groupKey, labels));
321154
- }
321155
- for (const element of evidenceElements) {
321156
- const candidate = classifySafeMobileNavigationControl(element);
321157
- if (!candidate)
321158
- continue;
321159
- const storedIdentity = storedMobileControlIdentity(candidate);
321160
- const semanticGroupKey = element.navigationProvenance ? `${element.navigationProvenance}:${mobileNavigationRegion(element, screen.windowSize)}` : "";
321161
- controls.set(storedIdentity, {
321162
- ...candidate,
321163
- identity: storedIdentity,
321164
- sharedCoverageIdentity: sharedMobileControlCoverageIdentity(element, candidate, semanticNavigationFamilyIds.get(semanticGroupKey))
321165
- });
321166
- }
321167
- if (controls.size > 0)
321168
- this.safeControlsByScreen.set(screenId, controls);
321169
- this.currentScreenId = screenId;
321170
- this.updateStaleProgress();
321171
- return screenId;
321172
- }
321173
- /**
321174
- * Back-compatible acknowledgement from capture_screen. Observation already
321175
- * marks structural evidence visited; this call only resolves a rare pending
321176
- * transition frontier and preserves callers that annotate explicitly.
321177
- */
321178
- markVisited(screenId) {
321179
- const state2 = this.screens.get(screenId);
321180
- if (state2 && !state2.visited) {
321181
- this.screens.set(screenId, { ...state2, visited: true });
321182
- }
321183
- this.frontier.delete(screenId);
321184
- }
321185
- /**
321186
- * Record a confirmed tap against a safe navigation candidate. Callers invoke
321187
- * this only after the driver reports success; recovery-unknown, stale-ref, and
321188
- * failed actions must remain on the breadth frontier.
321189
- */
321190
- noteControlAttempt(screenId, element) {
321191
- if (!screenId)
321192
- return false;
321193
- const candidate = classifySafeMobileNavigationControl(element);
321194
- const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
321195
- if (candidate) {
321196
- const storedIdentity = storedMobileControlIdentity(candidate);
321197
- const existing = controls.get(storedIdentity);
321198
- controls.set(storedIdentity, {
321199
- ...candidate,
321200
- identity: storedIdentity,
321201
- sharedCoverageIdentity: existing?.sharedCoverageIdentity ?? sharedMobileControlCoverageIdentity(element, candidate)
321202
- });
321203
- this.safeControlsByScreen.set(screenId, controls);
321204
- }
321205
- const tappedLabel = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
321206
- if (!tappedLabel)
321207
- return false;
321208
- const before = this.attemptedSafeControlCoverageIdentities.size;
321209
- const alias = semanticControlAlias(tappedLabel);
321210
- for (const control of controls.values()) {
321211
- if (semanticControlAlias(control.label) === alias) {
321212
- this.attemptedSafeControlCoverageIdentities.add(localControlCoverageIdentity(screenId, control.identity));
321213
- if (control.sharedCoverageIdentity) {
321214
- this.attemptedSafeControlCoverageIdentities.add(control.sharedCoverageIdentity);
321215
- }
321216
- }
321217
- }
321218
- if (this.attemptedSafeControlCoverageIdentities.size > before) {
321219
- this.successfulSafeControlAttemptCount += 1;
321220
- this.staleIterations = 0;
321221
- return true;
321222
- }
321223
- return false;
321224
- }
321225
- /** Attach a human-friendly name / coarse type to a screen (capture_screen). */
321226
- annotateScreen(screenId, annotation) {
321227
- const state2 = this.screens.get(screenId);
321228
- if (!state2)
321229
- return;
321230
- this.screens.set(screenId, {
321231
- ...state2,
321232
- screenName: annotation.screenName ?? state2.screenName,
321233
- screenType: annotation.screenType ?? state2.screenType
321234
- });
321235
- }
321236
- /** Attach structurally observed authentication provenance to a screen. */
321237
- setScreenAuthEvidence(screenId, evidence) {
321238
- const state2 = this.screens.get(screenId);
321239
- if (!state2)
321240
- return;
321241
- this.screens.set(screenId, {
321242
- ...state2,
321243
- requiresAuth: evidence.requiresAuth,
321244
- authProvenance: evidence.authProvenance,
321245
- sessionState: { ...evidence.sessionState }
321246
- });
321247
- }
321248
- /** Append a free-form observation note to a screen (observations tool). */
321249
- addScreenObservation(screenId, note) {
321250
- const state2 = this.screens.get(screenId);
321251
- if (!state2)
321252
- return;
321253
- const normalized = note.trim();
321254
- if (!normalized)
321255
- return;
321256
- const current = state2.observations ?? [];
321257
- if (current.some((existing) => existing.trim().toLowerCase() === normalized.toLowerCase())) {
321258
- return;
321259
- }
321260
- const observations = [...current, normalized].slice(-MAX_SCREEN_OBSERVATIONS);
321261
- this.screens.set(screenId, { ...state2, observations });
321262
- }
321263
- /**
321264
- * Record a directed transition between two screens. Both endpoints are
321265
- * ensured to exist on the frontier if not yet observed (mirrors the web
321266
- * reducer adding both ends of a TRANSITION_RECORDED to the frontier). The
321267
- * `toScreenId` becomes the current screen when the transition starts from the
321268
- * current screen.
321269
- */
321270
- recordTransition(transition) {
321271
- let recordedTransition = transition;
321272
- if (!transition.viaControlIdentity && transition.viaRef) {
321273
- const sourceElement = this.screens.get(transition.fromScreenId)?.elements.find((element) => element.ref === transition.viaRef);
321274
- if (sourceElement) {
321275
- recordedTransition = {
321276
- ...transition,
321277
- viaControlIdentity: (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(sourceElement)
321278
- };
321279
- }
321280
- }
321281
- const alreadyRecorded = this.transitions.some((existing) => existing.fromScreenId === recordedTransition.fromScreenId && existing.toScreenId === recordedTransition.toScreenId && existing.action === recordedTransition.action);
321282
- if (!alreadyRecorded) {
321283
- this.transitions.push(recordedTransition);
321284
- }
321285
- for (const endpointId of [transition.fromScreenId, transition.toScreenId]) {
321286
- if (!this.screens.has(endpointId) && !this.frontier.has(endpointId)) {
321287
- this.frontier.set(endpointId, {
321288
- screenId: endpointId,
321289
- source: "transition",
321290
- discoveredAtIteration: this.iteration
321291
- });
321292
- }
321293
- }
321294
- if (transition.fromScreenId === this.currentScreenId) {
321295
- this.currentScreenId = transition.toScreenId;
321296
- }
321297
- }
321298
- // -------------------------------------------------------------------------
321299
- // Selectors
321300
- // -------------------------------------------------------------------------
321301
- /** The current screen-id (most recently observed), or null before any observation. */
321302
- getCurrentScreenId() {
321303
- return this.currentScreenId;
321304
- }
321305
- /** ScreenState for `screenId`, if known. */
321306
- getScreen(screenId) {
321307
- return this.screens.get(screenId);
321308
- }
321309
- /** Number of distinct screens observed this run. */
321310
- getScreenCount() {
321311
- return this.screens.size;
321312
- }
321313
- /** Number of screens backed by a live structural driver observation. */
321314
- getVisitedScreenCount() {
321315
- let total = 0;
321316
- for (const state2 of this.screens.values()) {
321317
- if (state2.visited)
321318
- total += 1;
321319
- }
321320
- return total;
321321
- }
321322
- /** Total elements observed across all known screens (latest snapshot each). */
321323
- getTotalElementCount() {
321324
- let total = 0;
321325
- for (const state2 of this.screens.values()) {
321326
- total += state2.elements.length;
321327
- }
321328
- return total;
321329
- }
321330
- /**
321331
- * Frontier screen-ids ranked by recency (most recently discovered first),
321332
- * excluding any screen already visited. Mirrors the web `getRankedFrontier`
321333
- * intent (filter visited, rank by recency) — without URL prefix similarity,
321334
- * which has no mobile analog.
321335
- */
321336
- getFrontier() {
321337
- return [...this.frontier.values()].filter((item) => {
321338
- const state2 = this.screens.get(item.screenId);
321339
- return !state2 || !state2.visited;
321340
- }).sort((a, b) => b.discoveredAtIteration - a.discoveredAtIteration).map((item) => item.screenId);
321341
- }
321342
- /** Safe navigation candidates not yet attempted, deduped across shared chrome. */
321343
- getUnexploredSafeNavigationCandidates() {
321344
- const authenticatedProductObserved = [...this.screens.values()].some((screen) => screen.requiresAuth === true);
321345
- const candidates = [];
321346
- for (const [screenId, controls] of this.safeControlsByScreen) {
321347
- const origin = this.screens.get(screenId);
321348
- const originIsConfirmedPublicEntry = origin?.authProvenance === "public" || !origin?.authProvenance && origin?.requiresAuth !== true && /^(?:AUTH|LANDING)$/i.test(origin?.screenType ?? "");
321349
- if (authenticatedProductObserved && originIsConfirmedPublicEntry)
321350
- continue;
321351
- for (const control of controls.values()) {
321352
- const alias = semanticControlAlias(control.label);
321353
- const coverageIdentity = control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity);
321354
- if (this.attemptedSafeControlCoverageIdentities.has(coverageIdentity) || this.attemptedSafeControlCoverageIdentities.has(localControlCoverageIdentity(screenId, control.identity)))
321355
- continue;
321356
- const existing = candidates.find((candidate) => candidate.identity === coverageIdentity || candidate.alias === alias && candidate.screenIds.has(screenId));
321357
- if (existing) {
321358
- existing.screenIds.add(screenId);
321359
- } else {
321360
- candidates.push({
321361
- identity: coverageIdentity,
321362
- label: control.label,
321363
- kind: control.kind,
321364
- alias,
321365
- screenIds: /* @__PURE__ */ new Set([screenId])
321366
- });
321367
- }
321368
- }
321369
- }
321370
- return candidates.map((candidate) => ({
321371
- identity: candidate.identity,
321372
- label: candidate.label,
321373
- kind: candidate.kind,
321374
- screenIds: [...candidate.screenIds]
321375
- })).sort((a, b) => {
321376
- const aCurrent = this.currentScreenId !== null && a.screenIds.includes(this.currentScreenId);
321377
- const bCurrent = this.currentScreenId !== null && b.screenIds.includes(this.currentScreenId);
321378
- if (aCurrent !== bCurrent)
321379
- return aCurrent ? -1 : 1;
321380
- return b.screenIds.length - a.screenIds.length;
321381
- });
321382
- }
321383
- /** Harness-owned survey completion floor, independent of model assertions. */
321384
- getSurveyBreadthCoverage() {
321385
- const unvisitedScreenIds = this.getFrontier();
321386
- const unexploredControls = this.getUnexploredSafeNavigationCandidates();
321387
- const complete = unvisitedScreenIds.length === 0 && unexploredControls.length === 0;
321388
- const missing = [];
321389
- if (unvisitedScreenIds.length > 0) {
321390
- missing.push(`${unvisitedScreenIds.length} uncaptured screen(s)`);
321391
- }
321392
- if (unexploredControls.length > 0) {
321393
- missing.push(`${unexploredControls.length} safe navigation control(s)`);
321394
- }
321395
- return {
321396
- required: true,
321397
- complete,
321398
- unvisitedScreenIds,
321399
- unexploredControls,
321400
- attemptedControlCount: this.successfulSafeControlAttemptCount,
321401
- ...missing.length > 0 ? { reason: `Missing ${missing.join(" and ")}.` } : {}
321402
- };
321403
- }
321404
- /**
321405
- * Monotonic structural progress token for adaptive survey leases. Frontier
321406
- * cardinality alone is insufficient: attempting control A can reveal control
321407
- * B and leave the remaining count unchanged even though real work happened.
321408
- */
321409
- getSurveyBreadthProgressMark() {
321410
- const discoveredControlIdentities = /* @__PURE__ */ new Set();
321411
- for (const [screenId, controls] of this.safeControlsByScreen) {
321412
- for (const control of controls.values()) {
321413
- discoveredControlIdentities.add(control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity));
321414
- }
321415
- }
321416
- return [
321417
- `screens:${this.screens.size}`,
321418
- `attempts:${this.successfulSafeControlAttemptCount}`,
321419
- `controls:${discoveredControlIdentities.size}`,
321420
- `transitions:${this.transitions.length}`
321421
- ].join("|");
321422
- }
321423
- resolveSemanticNavigationFamilyId(groupKey, labels) {
321424
- const families = this.semanticNavigationFamilies.get(groupKey) ?? [];
321425
- let best;
321426
- let bestIntersection = 0;
321427
- for (const family of families) {
321428
- let intersection = 0;
321429
- for (const label of labels) {
321430
- if (family.labels.has(label))
321431
- intersection += 1;
321432
- }
321433
- const overlap = intersection / Math.min(labels.size, family.labels.size);
321434
- if (intersection >= 2 && overlap >= 0.6 && intersection > bestIntersection) {
321435
- best = family;
321436
- bestIntersection = intersection;
321437
- }
321438
- }
321439
- if (best) {
321440
- for (const label of labels)
321441
- best.labels.add(label);
321442
- return best.id;
321443
- }
321444
- const created = {
321445
- id: `semantic-nav-family-${this.nextSemanticNavigationFamilyId++}`,
321446
- labels: new Set(labels)
321447
- };
321448
- families.push(created);
321449
- this.semanticNavigationFamilies.set(groupKey, families);
321450
- return created.id;
321451
- }
321452
- /** Observed transitions (defensive copy). */
321453
- getTransitions() {
321454
- return [...this.transitions];
321455
- }
321456
- // -------------------------------------------------------------------------
321457
- // Stale-progress detection (ported semantics from the web stale guard)
321458
- // -------------------------------------------------------------------------
321459
- /**
321460
- * Progress mark: distinct screens weighted heavily, plus total elements seen.
321461
- * A run advances this mark whenever it finds a new screen OR new elements;
321462
- * the stale detector triggers when the mark stops advancing for
321463
- * `staleThreshold` consecutive iterations.
321464
- */
321465
- getProgressMark() {
321466
- return this.screens.size * 1e3 + this.transitions.length * 100 + this.successfulSafeControlAttemptCount * 10 + this.getTotalElementCount();
321467
- }
321468
- updateStaleProgress() {
321469
- const mark = this.getProgressMark();
321470
- if (mark > this.lastProgressMark) {
321471
- this.lastProgressMark = mark;
321472
- this.staleIterations = 0;
321473
- } else {
321474
- this.staleIterations += 1;
321475
- }
321476
- }
321477
- /** Consecutive no-progress iterations observed so far. */
321478
- getStaleIterations() {
321479
- return this.staleIterations;
321480
- }
321481
- /**
321482
- * True when the run has gone `staleThreshold` consecutive iterations with no
321483
- * new screen and no new element — the signal the explorer uses to nudge or
321484
- * finish (mirrors the web explorer's stale guard).
321485
- */
321486
- isStaleProgress() {
321487
- return this.staleIterations >= this.staleThreshold;
321488
- }
321489
- // -------------------------------------------------------------------------
321490
- // Planner evidence export
321491
- // -------------------------------------------------------------------------
321492
- /**
321493
- * Snapshot the screen-graph as a plain object (frozen mobile-types
321494
- * `MobileScreenGraph`). Defensive copies so callers cannot mutate internal
321495
- * state.
321496
- */
321497
- toScreenGraph() {
321498
- const screens = /* @__PURE__ */ new Map();
321499
- for (const [id, state2] of this.screens) {
321500
- screens.set(id, {
321501
- ...state2,
321502
- elements: [...state2.elements],
321503
- observations: state2.observations ? [...state2.observations] : void 0,
321504
- sessionState: state2.sessionState ? { ...state2.sessionState } : void 0
321505
- });
321506
- }
321507
- return {
321508
- screens,
321509
- transitions: this.transitions.map((t) => ({
321510
- fromScreenId: t.fromScreenId,
321511
- toScreenId: t.toScreenId,
321512
- viaRef: t.viaRef,
321513
- viaControlIdentity: t.viaControlIdentity,
321514
- action: t.action
321515
- }))
321516
- };
321517
- }
321518
- /**
321519
- * Build the planner evidence shape: one `CollectedPageData` per screen, with
321520
- * the screen-id carried in BOTH `route` (the on-the-wire positional field the
321521
- * server keys screenshots by) AND the explicit `screenId` field (per the
321522
- * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
321523
- * the screen's parsed actionable elements into the platform-neutral
321524
- * `CollectedPageElement[]` shape the server persists as SiteElements — without
321525
- * this, mobile screens land with zero elements (coverage always 0%, no element
321526
- * intelligence). `triggersNavigation` is derived from the durable source
321527
- * element identity captured when the transition happened (with ref-only
321528
- * fallback for legacy transitions). Insertion order matches first-seen order.
321529
- */
321530
- buildInMemoryScreenMap() {
321531
- const pages = [];
321532
- const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
321533
- const navRefsByScreen = /* @__PURE__ */ new Map();
321534
- const navIdentitiesByScreen = /* @__PURE__ */ new Map();
321535
- for (const transition of this.transitions) {
321536
- if (transition.viaControlIdentity) {
321537
- const set = navIdentitiesByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
321538
- set.add(transition.viaControlIdentity);
321539
- navIdentitiesByScreen.set(transition.fromScreenId, set);
321540
- } else if (transition.viaRef) {
321541
- const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
321542
- set.add(transition.viaRef);
321543
- navRefsByScreen.set(transition.fromScreenId, set);
321544
- }
321545
- }
321546
- for (const state2 of ordered) {
321547
- const page = {
321548
- route: state2.screenId,
321549
- screenId: state2.screenId,
321550
- pageType: state2.screenType ?? "UNKNOWN",
321551
- elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
321552
- navigationRefs: navRefsByScreen.get(state2.screenId),
321553
- navigationIdentities: navIdentitiesByScreen.get(state2.screenId)
321554
- }),
321555
- requiresAuth: state2.requiresAuth ?? false
321556
- };
321557
- if (state2.sessionState) {
321558
- page.sessionState = { ...state2.sessionState };
321559
- }
321560
- if (state2.screenName) {
321561
- page.title = state2.screenName;
321562
- }
321563
- if (state2.observations && state2.observations.length > 0) {
321564
- page.observations = state2.observations.map((note) => ({
321565
- action: "observe",
321566
- outcome: note,
321567
- outcomeType: "SUCCESS"
321568
- }));
321569
- }
321570
- pages.push(page);
321571
- }
321572
- return pages;
321573
- }
321574
- /** Alias for `buildInMemoryScreenMap()` — name parity with the planner-evidence call site. */
321575
- toScreenMap() {
321576
- return this.buildInMemoryScreenMap();
321577
- }
321578
- };
321579
- exports.MobileExplorationState = MobileExplorationState;
321580
- }
321581
- });
321582
-
321583
322082
  // ../runner-core/dist/services/mobile/mobile-discovery-agent.js
321584
322083
  var require_mobile_discovery_agent = __commonJS({
321585
322084
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
@@ -321593,6 +322092,7 @@ var require_mobile_discovery_agent = __commonJS({
321593
322092
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
321594
322093
  var mobile_discovery_auth_js_1 = require_mobile_discovery_auth();
321595
322094
  var mobile_driver_errors_js_1 = require_mobile_driver_errors();
322095
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
321596
322096
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
321597
322097
  var discover_planner_js_1 = require_discover_planner();
321598
322098
  function checkpointErrorMessage(err) {
@@ -321875,7 +322375,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321875
322375
  };
321876
322376
  }
321877
322377
  if (isSurvey && explore.authCoverage && !explore.authCoverage.complete) {
321878
- throw new Error("Mobile survey authentication coverage is incomplete; planning was skipped. " + (explore.authCoverage.reason ?? "No authenticated product screen was proven."));
322378
+ log2("Mobile survey continuing to planning with incomplete authenticated coverage: " + (explore.authCoverage.reason ?? "No authenticated product screen was proven."));
321879
322379
  }
321880
322380
  if (isSurvey && explore.breadthCoverage && !explore.breadthCoverage.complete) {
321881
322381
  log2("Mobile survey continuing to planning with residual breadth gaps: " + (explore.breadthCoverage.reason ?? "Unvisited screens or safe navigation controls remain."));
@@ -321893,7 +322393,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321893
322393
  const graph = state2.toScreenGraph();
321894
322394
  const screenMap = state2.buildInMemoryScreenMap();
321895
322395
  const testBudget = (0, discover_planner_js_1.computeTestBudget)(explore.exploreStats.pagesVisited, ctx.focusAreas);
321896
- const planResult = await runPlan({
322396
+ const planResult = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(driver, () => runPlan({
321897
322397
  graph,
321898
322398
  mode,
321899
322399
  projectId: ctx.projectId,
@@ -321917,7 +322417,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321917
322417
  }, {
321918
322418
  modelOverride: ctx.planModel ?? ctx.generateModel,
321919
322419
  onAICall
321920
- });
322420
+ }));
321921
322421
  logs2.push(...planResult.logs);
321922
322422
  log2(`
321923
322423
  Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.length} feature groups (budget: ${testBudget})`);
@@ -322136,6 +322636,9 @@ Fatal error: ${msg}`);
322136
322636
  if (explore.breadthCoverage && !explore.breadthCoverage.complete) {
322137
322637
  warnings.push("Discovery retained the captured mobile screen graph with residual navigation gaps: " + (explore.breadthCoverage.reason ?? "some observed controls could not be revisited."));
322138
322638
  }
322639
+ if (explore.authCoverage && !explore.authCoverage.complete) {
322640
+ warnings.push("Discovery retained the captured mobile screen graph with incomplete authenticated coverage: " + (explore.authCoverage.reason ?? "no authenticated product screen was proven."));
322641
+ }
322139
322642
  if (explore.truncated) {
322140
322643
  const reasonLabel = explore.truncatedReason === "rate_limit" ? "AI rate-limit" : explore.truncatedReason === "timeout" ? "AI timeout" : "AI error";
322141
322644
  warnings.push(`Discovery may be incomplete: exploration aborted by ${reasonLabel}. Re-run after the limit clears for a complete screen graph.`);
@@ -322772,6 +323275,7 @@ Health: ${surveyHealth.totalIssues} issue(s) - ${surveyHealth.consoleErrors.leng
322772
323275
  if (surveyAuthRecipe) {
322773
323276
  logs2.push(`Auth-prologue: captured + verified login recipe ${surveyAuthRecipe.recipeHash} during survey \u2014 persisting for reuse on later runs`);
322774
323277
  }
323278
+ 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;
322775
323279
  return {
322776
323280
  observedOrigins: mergeObservedOrigins(exploreResult.observedOrigins, loginObservedOrigins),
322777
323281
  resolvedAppOrigin: surveyAuthRecipe?.appOrigin ?? void 0,
@@ -322787,7 +323291,8 @@ Health: ${surveyHealth.totalIssues} issue(s) - ${surveyHealth.consoleErrors.leng
322787
323291
  exploreTruncatedReason: exploreResult.truncatedReason,
322788
323292
  fallbackUsed: planResult.fallbackUsed ?? false,
322789
323293
  fallbackReason: planResult.fallbackReason,
322790
- featureGroups,
323294
+ error: plannerFailedError,
323295
+ featureGroups: plannerFailedError ? void 0 : featureGroups,
322791
323296
  apiCalls: discoveryApiCalls.length > 0 ? discoveryApiCalls : void 0,
322792
323297
  healthObservations: surveyHealth,
322793
323298
  exploreFinalMessages: exploreResult.finalMessages,
@@ -336645,7 +337150,7 @@ var require_package4 = __commonJS({
336645
337150
  "package.json"(exports, module) {
336646
337151
  module.exports = {
336647
337152
  name: "@validate.qa/runner",
336648
- version: "1.0.38",
337153
+ version: "1.0.40",
336649
337154
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
336650
337155
  bin: {
336651
337156
  "validate-runner": "dist/cli.js"
@@ -339903,8 +340408,9 @@ async function activateTargetApp(sessionId, platform3, appId) {
339903
340408
  const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
339904
340409
  await executeMobileScript(sessionId, "mobile: activateApp", args);
339905
340410
  }
340411
+ var APP_TERMINATE_TIMEOUT_MS = 5e3;
339906
340412
  async function terminateTargetApp(sessionId, platform3, appId) {
339907
- const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
340413
+ const args = platform3 === "IOS" ? [{ bundleId: appId, timeout: APP_TERMINATE_TIMEOUT_MS }] : [{ appId, timeout: APP_TERMINATE_TIMEOUT_MS }];
339908
340414
  await executeMobileScript(sessionId, "mobile: terminateApp", args);
339909
340415
  }
339910
340416
  async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
@@ -342915,7 +343421,11 @@ var MobileBridgeDriver = class {
342915
343421
  async relaunch() {
342916
343422
  await this.withSessionRecovery("app relaunch", async () => {
342917
343423
  const args = this.appLifecycleArgs();
342918
- await this.executeScript("mobile: terminateApp", [args]);
343424
+ try {
343425
+ await this.executeScript("mobile: terminateApp", [{ ...args, timeout: 5e3 }]);
343426
+ } catch (err) {
343427
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
343428
+ }
342919
343429
  await this.executeScript("mobile: activateApp", [args]);
342920
343430
  try {
342921
343431
  await waitForColdBootSettle(this.sessionPath);
@@ -345460,10 +345970,22 @@ ${finalVerifyResult.logs ?? ""}`;
345460
345970
  onPlanReady
345461
345971
  });
345462
345972
  } finally {
345973
+ const TEARDOWN_STEP_TIMEOUT = /* @__PURE__ */ Symbol("vqa-teardown-timeout");
345974
+ const timeboxed = async (step) => Promise.race([
345975
+ step,
345976
+ new Promise((resolve2) => {
345977
+ const t = setTimeout(() => resolve2(TEARDOWN_STEP_TIMEOUT), 45e3);
345978
+ t.unref?.();
345979
+ })
345980
+ ]);
345463
345981
  const activeCapture = capture;
345464
345982
  if (activeCapture) {
345465
345983
  try {
345466
- const apiCalls = await activeCapture.stop();
345984
+ const stopped = await timeboxed(activeCapture.stop());
345985
+ if (stopped === TEARDOWN_STEP_TIMEOUT) {
345986
+ throw new Error("capture stop timed out after 45s \u2014 continuing teardown without apiCalls");
345987
+ }
345988
+ const apiCalls = stopped;
345467
345989
  const captureHealth = activeCapture.health();
345468
345990
  if (discoveryResult) {
345469
345991
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
@@ -345484,7 +346006,7 @@ ${finalVerifyResult.logs ?? ""}`;
345484
346006
  if (session) {
345485
346007
  detachMirrorSession(session.deviceId);
345486
346008
  try {
345487
- await session.stop();
346009
+ await timeboxed(session.stop());
345488
346010
  } catch {
345489
346011
  }
345490
346012
  }