@validate.qa/runner 1.0.82 → 1.0.83

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 +133 -196
  2. package/dist/cli.mjs +133 -196
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1019,8 +1019,7 @@ var require_mobile_source_parser = __commonJS({
1019
1019
  const isGenericView = /android\.view\.View$/i.test(role);
1020
1020
  const moderateHitTarget = hasArea && bounds.width >= 24 && bounds.height >= 24 && bounds.width <= 2e3 && bounds.height <= 1800;
1021
1021
  const webHybridLabelledControl = isGenericView && hasHumanIdentity && moderateHitTarget && (focusable || clickable || longClickable || Boolean(contentDesc));
1022
- const isAnrChrome = typeof resourceId === "string" && /(?:android:id\/)?aerr_/i.test(resourceId) || typeof text === "string" && /isn['’]?t responding/i.test(text) || typeof label === "string" && /isn['’]?t responding/i.test(label);
1023
- const actionable = !isAnrChrome && enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1022
+ const actionable = enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1024
1023
  return {
1025
1024
  role,
1026
1025
  label,
@@ -1198,7 +1197,6 @@ var require_mobile_source_parser = __commonJS({
1198
1197
  element.secure = fields.secure;
1199
1198
  if (fields.enabled !== void 0)
1200
1199
  element.enabled = fields.enabled;
1201
- element.visible = fields.visible;
1202
1200
  if (isCredibleStructuralNavigationTarget(element) && chainHasNativeNavigationSemantics(chain, platform3)) {
1203
1201
  markNavigation(element, "native-navigation-semantics");
1204
1202
  }
@@ -1573,8 +1571,6 @@ var require_mobile_boundary = __commonJS({
1573
1571
  "../runner-core/dist/services/mobile/mobile-boundary.js"(exports2) {
1574
1572
  "use strict";
1575
1573
  Object.defineProperty(exports2, "__esModule", { value: true });
1576
- exports2.isAndroidAnrSystemResourceId = isAndroidAnrSystemResourceId;
1577
- exports2.looksLikeAndroidAnrUi = looksLikeAndroidAnrUi;
1578
1574
  exports2.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1579
1575
  exports2.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1580
1576
  exports2.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
@@ -1592,28 +1588,6 @@ var require_mobile_boundary = __commonJS({
1592
1588
  "com.miui.securitycenter",
1593
1589
  "com.samsung.android.permissioncontroller"
1594
1590
  ]);
1595
- var ANDROID_ANR_RESOURCE_ID_RE = /^(?:android:id\/)?(?:aerr_(?:wait|close|close_app|mute|report|restart)|alertTitle|button1|button2|button3|parentPanel|topPanel|contentPanel|customPanel|buttonPanel|scrollView|textSpacerNoButtons|textSpacerNoTitle)$/i;
1596
- var ANDROID_ANR_TEXT_RE = /isn['’]?t responding|isn['’]?t responding\.|anr|close app|wait/i;
1597
- function isAndroidAnrSystemResourceId(resourceId) {
1598
- const rid = resourceId?.trim();
1599
- if (!rid)
1600
- return false;
1601
- if (/(?:android:id\/)?aerr_/i.test(rid))
1602
- return true;
1603
- return ANDROID_ANR_RESOURCE_ID_RE.test(rid) && /aerr_/i.test(rid);
1604
- }
1605
- function looksLikeAndroidAnrUi(input) {
1606
- if (isAndroidAnrSystemResourceId(input.resourceId))
1607
- return true;
1608
- const rid = input.resourceId?.trim() ?? "";
1609
- const blob = [input.text, input.label, input.contentDesc].filter(Boolean).join(" ");
1610
- if (/(?:android:id\/)?(?:alertTitle|aerr_)/i.test(rid) && ANDROID_ANR_TEXT_RE.test(blob)) {
1611
- return true;
1612
- }
1613
- if (/isn['’]?t responding/i.test(blob))
1614
- return true;
1615
- return false;
1616
- }
1617
1591
  var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1618
1592
  "com.apple.springboard",
1619
1593
  "com.apple.authenticationservicesui",
@@ -1771,7 +1745,7 @@ var init_constants = __esm({
1771
1745
  MOBILE_APP_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?$/;
1772
1746
  MOBILE_FOCUS_SETTLE_MS = 150;
1773
1747
  MOBILE_POST_ACTION_SETTLE_MS = 1800;
1774
- MOBILE_COLD_BOOT_SETTLE_MS = 25e3;
1748
+ MOBILE_COLD_BOOT_SETTLE_MS = 12e3;
1775
1749
  MOBILE_DEFAULT_SWIPE_DISTANCE = 500;
1776
1750
  MOBILE_MAX_SWIPE_DISTANCE = 2e3;
1777
1751
  SELECTOR_PRIORITIES = [
@@ -74913,7 +74887,7 @@ var require_mobile_explorer = __commonJS({
74913
74887
  type: "function",
74914
74888
  function: {
74915
74889
  name: "mobile_relaunch",
74916
- description: "Terminate and relaunch the app for a clean, hermetic state (sessions are noReset=true, so Home only backgrounds). Only for CLEAR_APP_DATA / cold flows. Blocked when app data is being preserved (PRESERVE_APP_DATA or survey AUTO). Do not use on a working product screen. Re-snapshot afterward.",
74890
+ description: "Terminate and relaunch the app for a clean, hermetic state (sessions are noReset=true, so Home only backgrounds). Use between exploration episodes when you want to start fresh. Re-snapshot afterward.",
74917
74891
  parameters: { type: "object", properties: {} }
74918
74892
  }
74919
74893
  };
@@ -75713,11 +75687,11 @@ Rules:
75713
75687
  **Phase 2 \u2014 Logged-in breadth first (~35% of budget).** Once signed in, find the primary navigation (bottom tab bar, drawer, or menu) and visit EVERY top-level destination ONCE, observing each screen, BEFORE drilling into any of them. A survey that maps one tab deeply but never opens the other tabs has failed \u2014 breadth across all tabs/drawer entries is mandatory.
75714
75688
  **Phase 3 \u2014 Depth (remaining budget).** Return to each observed tab and explore its sub-screens: open ONE list row per list (rows share a template), open menus/sheets/dialogs, observe detail screens, and mobile_swipe up/down to reveal off-screen content. record_transition whenever you move between screens.
75715
75689
 
75716
- Rules that apply throughout: snapshots and post-action observations capture screen evidence automatically. Use capture_screen sparingly to name/classify important stable destinations (use screenType "LANDING" for welcome/onboarding/intro and "AUTH" for sign-in/sign-up), but never spend turns renaming already-observed structural variants. Record transitions on every move; mobile_back to return. Prefer staying on the current app instance \u2014 do not cold-relaunch unless the product UI is truly stuck after a long wait. When no new screens or safe navigation controls remain, call finish_exploration.
75690
+ Rules that apply throughout: snapshots and post-action observations capture screen evidence automatically. Use capture_screen sparingly to name/classify important stable destinations (use screenType "LANDING" for welcome/onboarding/intro and "AUTH" for sign-in/sign-up), but never spend turns renaming already-observed structural variants. Record transitions on every move; mobile_back to return; mobile_relaunch only for a clean restart between episodes. When no new screens or safe navigation controls remain, call finish_exploration.
75717
75691
 
75718
75692
  ## Boundaries (survey = map, do not mutate)
75719
75693
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
75720
- - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back (not a cold relaunch).
75694
+ - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
75721
75695
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
75722
75696
 
75723
75697
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -76128,8 +76102,6 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76128
76102
  const kickoff = buildKickoffMessage(mode);
76129
76103
  const recentExchanges = [];
76130
76104
  const transientDirectives = [];
76131
- const resetPolicyForTools = ctx.target?.resetPolicy ?? "AUTO";
76132
- const preserveAppDataForTools = resetPolicyForTools === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicyForTools === "AUTO";
76133
76105
  const tools = [
76134
76106
  MOBILE_SNAPSHOT_TOOL,
76135
76107
  MOBILE_TAP_TOOL,
@@ -76139,8 +76111,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76139
76111
  MOBILE_SWIPE_TOOL,
76140
76112
  MOBILE_BACK_TOOL,
76141
76113
  MOBILE_HOME_TOOL,
76142
- // Omit relaunch under preserve — model otherwise cold-restarts RN into splash.
76143
- ...preserveAppDataForTools ? [] : [MOBILE_RELAUNCH_TOOL],
76114
+ MOBILE_RELAUNCH_TOOL,
76144
76115
  MOBILE_OPEN_DEEPLINK_TOOL,
76145
76116
  CAPTURE_SCREEN_TOOL,
76146
76117
  RECORD_TRANSITION_TOOL,
@@ -76496,8 +76467,7 @@ Exploration complete: ${summary}`);
76496
76467
  credentials: credentialSecrets,
76497
76468
  redactionCredentials: credentialValuesForRedaction,
76498
76469
  authTracker,
76499
- inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null,
76500
- allowRelaunch: !preserveAppDataForTools
76470
+ inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null
76501
76471
  });
76502
76472
  pushResult(handled);
76503
76473
  const handledRecord = handled && typeof handled === "object" ? handled : null;
@@ -76590,7 +76560,7 @@ Exploration complete: ${summary}`);
76590
76560
  return result;
76591
76561
  }
76592
76562
  async function dispatchMobileTool(args) {
76593
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, resolveHybridRef, currentBoundary, collectedTransitions, counters, journeys, log: log2, credentials, redactionCredentials, authTracker, inbox, allowRelaunch } = args;
76563
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, resolveHybridRef, currentBoundary, collectedTransitions, counters, journeys, log: log2, credentials, redactionCredentials, authTracker, inbox } = args;
76594
76564
  const absorbActionResult = (actionResult, source, preferCredentialSecondary = false) => {
76595
76565
  (0, mobile_driver_errors_js_1.throwIfTerminalMobileActionFailure)(actionResult, source);
76596
76566
  const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
@@ -76993,13 +76963,6 @@ Exploration complete: ${summary}`);
76993
76963
  }
76994
76964
  // ----- Relaunch (hermetic reset) -----
76995
76965
  case "mobile_relaunch": {
76996
- if (!allowRelaunch) {
76997
- return {
76998
- ok: false,
76999
- error: "mobile_relaunch is blocked while app data is being preserved. Use mobile_back / mobile_snapshot to continue mapping; request CLEAR_APP_DATA for a true cold restart.",
77000
- note: "Relaunch refused under preserve policy."
77001
- };
77002
- }
77003
76966
  authTracker?.noteInterveningMutation(iteration);
77004
76967
  await driver.relaunch();
77005
76968
  log2(" Relaunched app for hermetic reset.");
@@ -82062,7 +82025,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
82062
82025
  }
82063
82026
  }
82064
82027
  async function runScenarioLoop(args) {
82065
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch } = args;
82028
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog } = args;
82066
82029
  const log2 = (line) => emitLog(redactCredentialValues(line, redactionCredentials));
82067
82030
  const actions = [];
82068
82031
  let assertCount = 0;
@@ -82636,7 +82599,6 @@ ${statePacket}`, redactionCredentials) },
82636
82599
  setLatest,
82637
82600
  credentials,
82638
82601
  screenName: () => lastScreenName || "UnknownScreen",
82639
- allowRelaunch,
82640
82602
  recordAction: (action) => {
82641
82603
  const screenHash = getLatest()?.signal.screenHash;
82642
82604
  const structuralKey = structuralScreenKey(getLatest());
@@ -82671,7 +82633,7 @@ ${statePacket}`, redactionCredentials) },
82671
82633
  return { actions, assertCount, finish, lastSnapshot: lastTargetSnapshot, lastScreenName: lastTargetScreenName };
82672
82634
  }
82673
82635
  async function dispatchGenerateTool(args) {
82674
- const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, allowRelaunch, recordAction, recordedActions, log: log2 } = args;
82636
+ const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, recordAction, recordedActions, log: log2 } = args;
82675
82637
  const unknownRef = (ref) => ({
82676
82638
  ok: false,
82677
82639
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -83061,16 +83023,6 @@ ${statePacket}`, redactionCredentials) },
83061
83023
  return actionPayload(result, absorbed);
83062
83024
  }
83063
83025
  case "mobile_relaunch": {
83064
- if (!allowRelaunch) {
83065
- log2(" relaunch refused under PRESERVE_APP_DATA \u2014 staying on current app instance.");
83066
- const snap2 = await driver.snapshot();
83067
- const absorbed2 = await absorbObservation(snap2.xml, snap2.screenSignal, snap2.windowSize, snap2.screenshot, "mobile_relaunch_blocked");
83068
- return {
83069
- ok: false,
83070
- error: "mobile_relaunch is blocked while app data is preserved. Continue from the current screen.",
83071
- observation: absorbed2.observation
83072
- };
83073
- }
83074
83026
  await driver.relaunch();
83075
83027
  const snap = await driver.snapshot();
83076
83028
  const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
@@ -83289,48 +83241,14 @@ ${statePacket}`, redactionCredentials) },
83289
83241
  });
83290
83242
  return { ok: true, label: metadata.targetLabel ?? accessibilityId ?? text ?? "element" };
83291
83243
  }
83292
- var AMBIENT_CHROME_LABEL_RE = /^(your\s*profile|you|home|search|profile|more|settings|notifications?|tab\s*\d+(\s*of\s*\d+)?|you,\s*tab.*)$/i;
83293
- function isDisplayableLandingCandidate(el, windowSize) {
83294
- if (el.visible === false)
83295
- return false;
83296
- const { x, y, width, height } = el.bounds;
83297
- if (!(width > 0 && height > 0))
83298
- return false;
83299
- if (width < 8 || height < 8)
83300
- return false;
83301
- if (windowSize && windowSize.width > 0 && windowSize.height > 0) {
83302
- const centerX = x + width / 2;
83303
- const centerY = y + height / 2;
83304
- if (centerX < 0 || centerY < 0 || centerX > windowSize.width || centerY > windowSize.height) {
83305
- return false;
83306
- }
83307
- if (width >= windowSize.width * 0.92 && height >= windowSize.height * 0.55) {
83308
- return false;
83309
- }
83310
- }
83311
- return true;
83312
- }
83313
- function landingAnchorSortKey(el) {
83314
- const label = (nonEmpty3(el.label) || nonEmpty3(el.text) || "").trim();
83315
- const ambient = AMBIENT_CHROME_LABEL_RE.test(label) ? 1 : 0;
83316
- const labelled = nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83317
- const area = Math.max(1, el.bounds.width * el.bounds.height);
83318
- const areaBucket = area < 8e4 ? 0 : area < 25e4 ? 1 : 2;
83319
- return [ambient, labelled, areaBucket];
83320
- }
83321
83244
  function pickLandingAnchor(snapshot) {
83322
83245
  if (!snapshot || snapshot.screen.elements.length === 0)
83323
83246
  return null;
83324
83247
  const elements = snapshot.screen.elements;
83325
- const windowSize = snapshot.screen.windowSize ?? null;
83326
- const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)).sort((a, b) => {
83327
- const ka = landingAnchorSortKey(a);
83328
- const kb = landingAnchorSortKey(b);
83329
- for (let i = 0; i < ka.length; i += 1) {
83330
- if (ka[i] !== kb[i])
83331
- return ka[i] - kb[i];
83332
- }
83333
- return 0;
83248
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
83249
+ stableAnchors.sort((a, b) => {
83250
+ const labelled2 = (el) => nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83251
+ return labelled2(a) - labelled2(b);
83334
83252
  });
83335
83253
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83336
83254
  if (contentfulNonButton)
@@ -83340,10 +83258,8 @@ ${statePacket}`, redactionCredentials) },
83340
83258
  return nonButton;
83341
83259
  if (stableAnchors.length > 0)
83342
83260
  return stableAnchors[0];
83343
- const labelled = elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83344
- if (labelled)
83345
- return labelled;
83346
- return elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)) ?? null;
83261
+ const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83262
+ return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
83347
83263
  }
83348
83264
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
83349
83265
  var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
@@ -83441,19 +83357,10 @@ ${statePacket}`, redactionCredentials) },
83441
83357
  const anchor = pickLandingAnchor(recording.lastSnapshot);
83442
83358
  if (!anchor)
83443
83359
  return false;
83444
- const modelAssertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83445
- const anchorLabel = (nonEmpty3(anchor.label) || nonEmpty3(anchor.text) || "").trim();
83446
- if (modelAssertCount > 0 && AMBIENT_CHROME_LABEL_RE.test(anchorLabel)) {
83447
- return false;
83448
- }
83449
- const anchorSelector = (0, mobile_source_parser_js_2.preferredLocator)(anchor);
83450
- const alreadyAsserted = actions.some((a) => (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT") && typeof a.selector === "string" && a.selector === anchorSelector);
83451
- if (alreadyAsserted)
83452
- return false;
83453
83360
  const screen = recording.lastScreenName || "LandingScreen";
83454
83361
  actions.push(finalizeAction({
83455
83362
  type: "ASSERT_VISIBLE",
83456
- selector: anchorSelector,
83363
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83457
83364
  metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83458
83365
  }));
83459
83366
  return true;
@@ -84329,8 +84236,7 @@ ${statePacket}`, redactionCredentials) },
84329
84236
  targetAppId,
84330
84237
  credentials: credentialSecrets,
84331
84238
  redactionCredentials: credentialValuesForRedaction,
84332
- log: log2,
84333
- allowRelaunch: (ctx.target?.resetPolicy ?? "AUTO") !== "PRESERVE_APP_DATA"
84239
+ log: log2
84334
84240
  });
84335
84241
  consecutiveInfraFailures = 0;
84336
84242
  const test = assembleTest({
@@ -337263,11 +337169,10 @@ Fatal error: ${msg}`);
337263
337169
  const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
337264
337170
  if (shouldPreserveAppData) {
337265
337171
  try {
337266
- await driver.launchApp();
337267
- log2("App activated with preserved data (no terminate/clear).");
337172
+ await driver.relaunch();
337268
337173
  } catch (err) {
337269
337174
  (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
337270
- log2(`App activate failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337175
+ log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337271
337176
  }
337272
337177
  return false;
337273
337178
  }
@@ -353432,7 +353337,7 @@ var require_package4 = __commonJS({
353432
353337
  "package.json"(exports2, module2) {
353433
353338
  module2.exports = {
353434
353339
  name: "@validate.qa/runner",
353435
- version: "1.0.82",
353340
+ version: "1.0.83",
353436
353341
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353437
353342
  bin: {
353438
353343
  "validate-runner": "dist/cli.js"
@@ -354298,9 +354203,9 @@ async function waitForColdBootSettle(sessionPath) {
354298
354203
  timeoutMs: MOBILE_COLD_BOOT_SETTLE_MS,
354299
354204
  minStableFrames: 2,
354300
354205
  pollMs: 400,
354301
- // Native splash / WebView shell is ~5–8 nodes; RN product screens and rich
354302
- // onboarding typically flatten to 15+ once JS has painted.
354303
- minElements: 15
354206
+ // WebView native shell alone is ~5–8 nodes; real first screens flatten to
354207
+ // dozens. Require enough nodes that the DOM has painted (mirrors executor).
354208
+ minElements: 12
354304
354209
  });
354305
354210
  }
354306
354211
  async function getWindowSize(sessionPath) {
@@ -354449,33 +354354,20 @@ async function sendKeysToFocusedInput(sessionPath, value) {
354449
354354
  }
354450
354355
  async function typeText(sessionPath, value, bounds) {
354451
354356
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
354452
- if (!elementId && bounds === void 0) {
354453
- throw new Error("No focused element available for TYPE. Tap an input field first.");
354454
- }
354455
354357
  if (elementId) {
354456
354358
  try {
354457
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
354359
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354458
354360
  method: "POST",
354459
- body: JSON.stringify({})
354361
+ body: JSON.stringify({ text: value })
354460
354362
  });
354363
+ return;
354461
354364
  } catch (err) {
354462
354365
  if (isAppiumDriverDeadError(err)) throw err;
354463
354366
  }
354367
+ } else if (bounds === void 0) {
354368
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
354464
354369
  }
354465
- try {
354466
- await sendKeysToFocusedInput(sessionPath, value);
354467
- return;
354468
- } catch (err) {
354469
- if (isAppiumDriverDeadError(err)) throw err;
354470
- }
354471
- if (elementId) {
354472
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354473
- method: "POST",
354474
- body: JSON.stringify({ text: value })
354475
- });
354476
- return;
354477
- }
354478
- throw new Error("TYPE failed: could not send keys to the focused input.");
354370
+ await sendKeysToFocusedInput(sessionPath, value);
354479
354371
  }
354480
354372
  async function submitKeyboard(sessionPath, bounds) {
354481
354373
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
@@ -357282,10 +357174,17 @@ function xpathLiteral3(value) {
357282
357174
  const parts = value.split("'").map((p) => `'${p}'`);
357283
357175
  return `concat(${parts.join(`, "'", `)})`;
357284
357176
  }
357177
+ var XPATH_ASCII_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
357178
+ var XPATH_ASCII_LOWER = "abcdefghijklmnopqrstuvwxyz";
357285
357179
  function identityTextXpath2(value) {
357286
357180
  const lit = xpathLiteral3(value);
357287
357181
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
357288
357182
  }
357183
+ function identityTextXpathCaseInsensitive(value) {
357184
+ const lit = xpathLiteral3(value.toLowerCase());
357185
+ const fold = (attr) => `translate(@${attr}, '${XPATH_ASCII_UPPER}', '${XPATH_ASCII_LOWER}')=${lit}`;
357186
+ return `//*[${fold("text")} or ${fold("content-desc")} or ${fold("label")} or ${fold("name")} or ${fold("value")}]`;
357187
+ }
357289
357188
  function iosPredicateLiteral(value) {
357290
357189
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
357291
357190
  }
@@ -357293,19 +357192,28 @@ function iosTextIdentityPredicate(value) {
357293
357192
  const lit = iosPredicateLiteral(value);
357294
357193
  return `label == ${lit} OR name == ${lit} OR value == ${lit}`;
357295
357194
  }
357195
+ function iosTextIdentityPredicateCaseInsensitive(value) {
357196
+ const lit = iosPredicateLiteral(value);
357197
+ return `label ==[c] ${lit} OR name ==[c] ${lit} OR value ==[c] ${lit}`;
357198
+ }
357296
357199
  function textIdentityStrategies(value, platform3) {
357297
357200
  const strategies = [];
357298
357201
  if (platform3 === "IOS") {
357299
357202
  strategies.push({ using: "-ios predicate string", value: iosTextIdentityPredicate(value) });
357203
+ strategies.push({ using: "-ios predicate string", value: iosTextIdentityPredicateCaseInsensitive(value) });
357300
357204
  }
357301
357205
  strategies.push({ using: "xpath", value: identityTextXpath2(value) });
357206
+ strategies.push({ using: "xpath", value: identityTextXpathCaseInsensitive(value) });
357302
357207
  return strategies;
357303
357208
  }
357304
357209
  function isIdentityIosPredicate(value) {
357305
- return /(?:^|[\s(])(?:name|label|value)\s*==\s*"/.test(value) && !/\b(?:CONTAINS|BEGINSWITH|ENDSWITH|LIKE|MATCHES|IN)\b/i.test(value);
357210
+ return /(?:^|[\s(])(?:name|label|value)\s*==(?:\[[cC]\])?\s*"/.test(value) && !/\b(?:CONTAINS|BEGINSWITH|ENDSWITH|LIKE|MATCHES|IN)\b/i.test(value);
357306
357211
  }
357307
357212
  function isIdentityXpath(value) {
357308
- return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
357213
+ if (/(?:contains|starts-with)\s*\(/i.test(value)) return false;
357214
+ if (/@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value)) return true;
357215
+ if (/translate\s*\(\s*@[A-Za-z_:][-A-Za-z0-9_:.]*\s*,/.test(value) && /\)\s*=/.test(value)) return true;
357216
+ return false;
357309
357217
  }
357310
357218
  function isIdentityStrategy(strategy) {
357311
357219
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
@@ -358128,7 +358036,12 @@ function mobileMjpegPortForDevice(platform3, deviceId, env = process.env) {
358128
358036
  return typeof port === "number" && Number.isFinite(port) ? port : null;
358129
358037
  }
358130
358038
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
358131
- var pinnedDeviceSnapshot = null;
358039
+ var pinnedDeviceSnapshots = /* @__PURE__ */ new Map();
358040
+ function prunePinnedDeviceSnapshots(now = Date.now()) {
358041
+ for (const [id, entry] of pinnedDeviceSnapshots) {
358042
+ if (now - entry.at >= PINNED_DEVICE_SNAPSHOT_TTL_MS) pinnedDeviceSnapshots.delete(id);
358043
+ }
358044
+ }
358132
358045
  async function executeMobileTest(options) {
358133
358046
  const logLines = [];
358134
358047
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -358203,8 +358116,10 @@ async function executeMobileTest(options) {
358203
358116
  }
358204
358117
  const platformFilter = options.target.platform;
358205
358118
  const pinnedDeviceId = options.target.assignedDeviceId;
358206
- const candidateSnapshot = pinnedDeviceSnapshot;
358207
- const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && candidateSnapshot.deviceId === pinnedDeviceId && Date.now() - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
358119
+ const now = Date.now();
358120
+ prunePinnedDeviceSnapshots(now);
358121
+ const candidateSnapshot = pinnedDeviceId ? pinnedDeviceSnapshots.get(pinnedDeviceId) : void 0;
358122
+ const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && now - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
358208
358123
  if (reusablePinnedSnapshot) {
358209
358124
  assertDeviceNotQuarantined(reusablePinnedSnapshot.device.id);
358210
358125
  selectedDevice = reusablePinnedSnapshot.device;
@@ -358220,22 +358135,24 @@ async function executeMobileTest(options) {
358220
358135
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
358221
358136
  );
358222
358137
  }
358223
- if (options.target.assignedDeviceId) {
358224
- selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
358225
- if (!selectedDevice) {
358226
- throw new Error(
358227
- `Assigned ${platformFilter} device ${options.target.assignedDeviceId} is no longer booted/available (booted: ${matchingDevices.map((d) => d.id).join(", ") || "none"}). Refusing to fall back to another device.`
358228
- );
358229
- }
358230
- }
358231
- if (!selectedDevice && options.target.recordedDeviceId) {
358232
- selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
358138
+ if (!options.target.assignedDeviceId) {
358139
+ throw new Error(
358140
+ "CONFIG_ISSUE: mobile test requires an assigned device lease; refusing unpinned session create."
358141
+ );
358233
358142
  }
358143
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
358234
358144
  if (!selectedDevice) {
358235
- selectedDevice = matchingDevices[0];
358145
+ throw new Error(
358146
+ `Assigned ${platformFilter} device ${options.target.assignedDeviceId} is no longer booted/available (booted: ${matchingDevices.map((d) => d.id).join(", ") || "none"}). Refusing to fall back to another device.`
358147
+ );
358236
358148
  }
358237
358149
  log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
358238
- pinnedDeviceSnapshot = options.target.assignedDeviceId ? { deviceId: options.target.assignedDeviceId, device: selectedDevice, at: Date.now() } : null;
358150
+ if (options.target.assignedDeviceId) {
358151
+ pinnedDeviceSnapshots.set(options.target.assignedDeviceId, {
358152
+ device: selectedDevice,
358153
+ at: Date.now()
358154
+ });
358155
+ }
358239
358156
  }
358240
358157
  try {
358241
358158
  capture = await startMobileNetworkCapture({
@@ -358571,20 +358488,16 @@ async function createMobileDiscoverySession(target, options) {
358571
358488
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
358572
358489
  );
358573
358490
  }
358574
- let selectedDevice;
358575
- if (target.assignedDeviceId) {
358576
- selectedDevice = matchingDevices.find((d) => d.id === target.assignedDeviceId);
358577
- if (!selectedDevice) {
358578
- throw new Error(
358579
- `CONFIG_ISSUE: assigned ${platformFilter} device ${target.assignedDeviceId} is no longer booted for mobile discovery; will retry.`
358580
- );
358581
- }
358582
- }
358583
- if (!selectedDevice && target.recordedDeviceId) {
358584
- selectedDevice = matchingDevices.find((d) => d.id === target.recordedDeviceId);
358491
+ if (!target.assignedDeviceId) {
358492
+ throw new Error(
358493
+ "CONFIG_ISSUE: mobile discovery requires an assigned device lease; refusing unpinned session create."
358494
+ );
358585
358495
  }
358496
+ const selectedDevice = matchingDevices.find((d) => d.id === target.assignedDeviceId);
358586
358497
  if (!selectedDevice) {
358587
- selectedDevice = matchingDevices[0];
358498
+ throw new Error(
358499
+ `CONFIG_ISSUE: assigned ${platformFilter} device ${target.assignedDeviceId} is no longer booted for mobile discovery; will retry.`
358500
+ );
358588
358501
  }
358589
358502
  if (selectedDevice.installedApps && selectedDevice.installedApps.length > 0 && !selectedDevice.installedApps.includes(target.appId)) {
358590
358503
  throw new Error(
@@ -359026,6 +358939,10 @@ function computeMobileCapacity(input) {
359026
358939
  }
359027
358940
  return Math.max(0, cap);
359028
358941
  }
358942
+ function discoveryKindOf(ctx) {
358943
+ if (!isNativeMobileDiscoveryContext(ctx)) return null;
358944
+ return ctx?.mode === "survey" ? "survey" : "deep";
358945
+ }
359029
358946
  function decideMobileDeviceGate(input) {
359030
358947
  const {
359031
358948
  runMode,
@@ -359036,8 +358953,10 @@ function decideMobileDeviceGate(input) {
359036
358953
  readyDevices,
359037
358954
  capacity,
359038
358955
  freeDeviceId,
359039
- isDiscovery,
359040
- activeMobileDiscoveries
358956
+ discoveryKind,
358957
+ activeMobileSurveys,
358958
+ activeMobileDeeps,
358959
+ maxMobileDeeps
359041
358960
  } = input;
359042
358961
  if (!platform3) {
359043
358962
  return { claim: false, disposition: "pending", reason: "mobile run has no supported target platform" };
@@ -359049,8 +358968,18 @@ function decideMobileDeviceGate(input) {
359049
358968
  reason: `no booted ${platform3 === "IOS" ? "iOS" : "Android"} device for mobile run`
359050
358969
  };
359051
358970
  }
359052
- if (isDiscovery && activeMobileDiscoveries >= 1) {
359053
- return { claim: false, disposition: "defer", reason: "native discovery serialized to the shared live-mirror" };
358971
+ if (discoveryKind === "survey" && activeMobileSurveys >= 1) {
358972
+ return { claim: false, disposition: "defer", reason: "native survey serialized to 1 concurrent" };
358973
+ }
358974
+ if (discoveryKind === "deep") {
358975
+ const deepCap = Math.max(0, maxMobileDeeps);
358976
+ if (activeMobileDeeps >= deepCap) {
358977
+ return {
358978
+ claim: false,
358979
+ disposition: "defer",
358980
+ reason: `native deep discovery capacity reached: ${activeMobileDeeps}/${deepCap}`
358981
+ };
358982
+ }
359054
358983
  }
359055
358984
  const isHealForHeldTest = runMode === "heal" && !!testCaseId && healHoldTestCaseIds.has(testCaseId);
359056
358985
  const reservedForHeals = isHealForHeldTest ? Math.max(0, healHoldTestCaseIds.size - 1) : healHoldTestCaseIds.size;
@@ -359583,9 +359512,6 @@ async function startSession(validated, res) {
359583
359512
  ...deviceId ? { "appium:udid": deviceId } : {},
359584
359513
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
359585
359514
  "appium:noReset": true,
359586
- // Keep pace with executor createSession: Expo "Open in App?" sheets and
359587
- // permission dialogs otherwise freeze WDA for recording/mirror sessions.
359588
- ...process.env.APPIUM_IOS_AUTO_ACCEPT_ALERTS !== "false" ? { "appium:autoAcceptAlerts": true } : {},
359589
359515
  // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
359590
359516
  // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
359591
359517
  // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
@@ -361461,11 +361387,12 @@ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWA
361461
361387
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
361462
361388
  var DEFAULT_TYPE_LIMITS = {
361463
361389
  discovery: IS_CLOUD ? 2 : 15,
361464
- // Native mobile discovery is single-device (one Appium session per runner), so
361465
- // it is always capped at 1 regardless of cloud/local — the shared-device mutex
361466
- // (setExecutorBusy + getBridgeState().hasActiveSession) already serializes it,
361467
- // and this static limit documents that ceiling for the resource governor.
361468
- "mobile-discovery": 1,
361390
+ // Max concurrent native deep discoveries on one local runner (survey is always
361391
+ // 1 via decideMobileDeviceGate). Applied by the device gate as maxMobileDeeps
361392
+ // (MAX_MOBILE_DISCOVERY); the resource-governor type key is run.mode='discovery'
361393
+ // and does NOT read this entry — do not treat TYPE_LIMITS['mobile-discovery'] as
361394
+ // the live governor. Env default 2 enables two deeps when two free devices exist.
361395
+ "mobile-discovery": 2,
361469
361396
  heal: IS_CLOUD ? 2 : 8,
361470
361397
  "regression-suite": IS_CLOUD ? 1 : 2,
361471
361398
  "system-regression-suite": IS_CLOUD ? 1 : 2,
@@ -363114,10 +363041,10 @@ ${finalVerifyResult.logs ?? ""}`;
363114
363041
  log.fail("ERROR - malformed locator-discovery context");
363115
363042
  return;
363116
363043
  }
363117
- if (getBridgeState().hasActiveSession) {
363044
+ if (getBridgeState().recordingActive) {
363118
363045
  await postResult(opts.serverUrl, headers, run2.runId, {
363119
363046
  status: "ERROR",
363120
- error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
363047
+ error: "CONFIG_ISSUE: a mobile recording session is active; retry once it ends."
363121
363048
  });
363122
363049
  log.fail("ERROR - device busy (recording) for locator discovery");
363123
363050
  return;
@@ -363546,6 +363473,11 @@ ${finalVerifyResult.logs ?? ""}`;
363546
363473
  if (!mobileTarget || !mobileTarget.appId) {
363547
363474
  throw new Error("Native mobile discovery requires a target with an appId (bundleId/appPackage).");
363548
363475
  }
363476
+ if (!assignedDeviceId) {
363477
+ throw new Error(
363478
+ "CONFIG_ISSUE: mobile discovery was claimed without a device lease; refusing unpinned session create."
363479
+ );
363480
+ }
363549
363481
  const targetPlatform = mobileTarget.platform;
363550
363482
  const platform3 = mobileMedium === "IOS_NATIVE" ? "IOS" : mobileMedium === "ANDROID_NATIVE" ? "ANDROID" : targetPlatform === "IOS" || targetPlatform === "ANDROID" ? targetPlatform : null;
363551
363483
  if (!platform3) {
@@ -363554,8 +363486,8 @@ ${finalVerifyResult.logs ?? ""}`;
363554
363486
  if (mobileMedium === "IOS_NATIVE" && targetPlatform === "ANDROID" || mobileMedium === "ANDROID_NATIVE" && targetPlatform === "IOS") {
363555
363487
  throw new Error("Native mobile discovery medium does not match target.platform.");
363556
363488
  }
363557
- if (getBridgeState().hasActiveSession) {
363558
- throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
363489
+ if (getBridgeState().recordingActive) {
363490
+ throw new Error("CONFIG_ISSUE: a mobile recording session is active; mobile discovery will retry once it ends.");
363559
363491
  }
363560
363492
  const mobileProgressLines = [];
363561
363493
  const mobileOnLog = (line) => {
@@ -364033,10 +363965,10 @@ ${finalVerifyResult.logs ?? ""}`;
364033
363965
  return;
364034
363966
  }
364035
363967
  if (runMode === "heal" && run2.mobileAiHeal === true) {
364036
- if (getBridgeState().hasActiveSession) {
363968
+ if (getBridgeState().recordingActive) {
364037
363969
  await postResult(opts.serverUrl, headers, run2.runId, {
364038
363970
  status: "ERROR",
364039
- error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
363971
+ error: "CONFIG_ISSUE: a mobile recording session is active; the AI heal will retry once it ends.",
364040
363972
  duration: Date.now() - startTime
364041
363973
  });
364042
363974
  log.warn("Skipped mobile AI heal (device busy with a recording)");
@@ -364774,7 +364706,9 @@ async function startRunner(opts) {
364774
364706
  };
364775
364707
  const leasedDeviceIds = /* @__PURE__ */ new Map();
364776
364708
  const activeMobileRuns = /* @__PURE__ */ new Map();
364777
- let activeMobileDiscoveries = 0;
364709
+ let activeMobileSurveys = 0;
364710
+ let activeMobileDeeps = 0;
364711
+ const MAX_MOBILE_DEEPS = Math.max(0, envInt("MAX_MOBILE_DISCOVERY", DEFAULT_TYPE_LIMITS["mobile-discovery"]));
364778
364712
  const mobileHealHold = /* @__PURE__ */ new Map();
364779
364713
  const pruneMobileHealHolds = () => {
364780
364714
  if (mobileHealHold.size === 0) return;
@@ -365119,7 +365053,7 @@ async function startRunner(opts) {
365119
365053
  if (activeRuns >= MAX_CONCURRENT) break;
365120
365054
  const runMode = run2.mode ?? "run";
365121
365055
  const isMobileOp = enableMobile && isMobileDeviceOp(run2);
365122
- const isDiscovery = isMobileOp && isNativeMobileDiscoveryContext(run2.discoveryContext);
365056
+ const discoveryKind = isMobileOp ? discoveryKindOf(run2.discoveryContext) : null;
365123
365057
  let leasedDeviceId = null;
365124
365058
  let mobileLeaseError;
365125
365059
  const runProject = projectKey(run2);
@@ -365138,7 +365072,7 @@ async function startRunner(opts) {
365138
365072
  logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (${admission.reason})`);
365139
365073
  continue;
365140
365074
  }
365141
- if (enableMobile && run2.framework === "APPIUM" && getBridgeState().recordingActive) {
365075
+ if (enableMobile && isMobileOp && getBridgeState().recordingActive) {
365142
365076
  logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active)`);
365143
365077
  continue;
365144
365078
  }
@@ -365154,10 +365088,6 @@ async function startRunner(opts) {
365154
365088
  logDeferredOnce(run2.runId, ` Left PENDING ${run2.runId} (no booted ${discoveryMedium === "IOS_NATIVE" ? "iOS" : "Android"} device for native mobile discovery)`);
365155
365089
  continue;
365156
365090
  }
365157
- if (getBridgeState().hasActiveSession) {
365158
- logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active on shared device)`);
365159
- continue;
365160
- }
365161
365091
  }
365162
365092
  const mobilePlatform = isMobileOp ? mobilePlatformOf(run2) : null;
365163
365093
  if (isMobileOp && mobilePlatform) {
@@ -365199,13 +365129,18 @@ async function startRunner(opts) {
365199
365129
  readyDevices: virtual + physical,
365200
365130
  capacity,
365201
365131
  freeDeviceId,
365202
- isDiscovery,
365203
- activeMobileDiscoveries
365132
+ discoveryKind,
365133
+ activeMobileSurveys,
365134
+ activeMobileDeeps,
365135
+ maxMobileDeeps: MAX_MOBILE_DEEPS
365204
365136
  });
365205
365137
  if (!decision.claim) {
365206
365138
  logDeferredOnce(run2.runId, ` ${decision.disposition === "pending" ? "Left PENDING" : "Deferred"} ${run2.runId} (${decision.reason})`);
365207
365139
  continue;
365208
365140
  }
365141
+ log.dim(
365142
+ ` [mobile-gate] admit ${discoveryKind ?? runMode} device=${freeDeviceId} surveys=${activeMobileSurveys} deeps=${activeMobileDeeps}/${MAX_MOBILE_DEEPS} ops=${getActiveMobileOps(mobilePlatform)}/${capacity}`
365143
+ );
365209
365144
  leasedDeviceId = freeDeviceId;
365210
365145
  leasedDeviceIds.set(freeDeviceId, run2.runId);
365211
365146
  }
@@ -365233,7 +365168,8 @@ async function startRunner(opts) {
365233
365168
  incrementProject(runProject);
365234
365169
  if (isMobileOp) {
365235
365170
  if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, 1);
365236
- if (isDiscovery) activeMobileDiscoveries++;
365171
+ if (discoveryKind === "survey") activeMobileSurveys++;
365172
+ else if (discoveryKind === "deep") activeMobileDeeps++;
365237
365173
  if (runMode === "heal" && run2.testCaseId) mobileHealHold.delete(run2.testCaseId);
365238
365174
  }
365239
365175
  if (claimedThisPoll > 0 && staggerMs > 0) {
@@ -365246,7 +365182,8 @@ async function startRunner(opts) {
365246
365182
  decrementProject(runProject);
365247
365183
  if (isMobileOp) {
365248
365184
  if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
365249
- if (isDiscovery) activeMobileDiscoveries--;
365185
+ if (discoveryKind === "survey") activeMobileSurveys = Math.max(0, activeMobileSurveys - 1);
365186
+ else if (discoveryKind === "deep") activeMobileDeeps = Math.max(0, activeMobileDeeps - 1);
365250
365187
  if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
365251
365188
  activeMobileRuns.delete(run2.runId);
365252
365189
  }