@validate.qa/runner 1.0.82 → 1.0.84

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.mjs CHANGED
@@ -1024,8 +1024,7 @@ var require_mobile_source_parser = __commonJS({
1024
1024
  const isGenericView = /android\.view\.View$/i.test(role);
1025
1025
  const moderateHitTarget = hasArea && bounds.width >= 24 && bounds.height >= 24 && bounds.width <= 2e3 && bounds.height <= 1800;
1026
1026
  const webHybridLabelledControl = isGenericView && hasHumanIdentity && moderateHitTarget && (focusable || clickable || longClickable || Boolean(contentDesc));
1027
- 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);
1028
- const actionable = !isAnrChrome && enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1027
+ const actionable = enabled && hasArea && (clickable || longClickable || classActionable || focusable && classActionable || focusable && hasHumanIdentity && !isGenericView || webHybridLabelledControl);
1029
1028
  return {
1030
1029
  role,
1031
1030
  label,
@@ -1203,7 +1202,6 @@ var require_mobile_source_parser = __commonJS({
1203
1202
  element.secure = fields.secure;
1204
1203
  if (fields.enabled !== void 0)
1205
1204
  element.enabled = fields.enabled;
1206
- element.visible = fields.visible;
1207
1205
  if (isCredibleStructuralNavigationTarget(element) && chainHasNativeNavigationSemantics(chain, platform3)) {
1208
1206
  markNavigation(element, "native-navigation-semantics");
1209
1207
  }
@@ -1578,8 +1576,6 @@ var require_mobile_boundary = __commonJS({
1578
1576
  "../runner-core/dist/services/mobile/mobile-boundary.js"(exports) {
1579
1577
  "use strict";
1580
1578
  Object.defineProperty(exports, "__esModule", { value: true });
1581
- exports.isAndroidAnrSystemResourceId = isAndroidAnrSystemResourceId;
1582
- exports.looksLikeAndroidAnrUi = looksLikeAndroidAnrUi;
1583
1579
  exports.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1584
1580
  exports.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1585
1581
  exports.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
@@ -1597,28 +1593,6 @@ var require_mobile_boundary = __commonJS({
1597
1593
  "com.miui.securitycenter",
1598
1594
  "com.samsung.android.permissioncontroller"
1599
1595
  ]);
1600
- 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;
1601
- var ANDROID_ANR_TEXT_RE = /isn['’]?t responding|isn['’]?t responding\.|anr|close app|wait/i;
1602
- function isAndroidAnrSystemResourceId(resourceId) {
1603
- const rid = resourceId?.trim();
1604
- if (!rid)
1605
- return false;
1606
- if (/(?:android:id\/)?aerr_/i.test(rid))
1607
- return true;
1608
- return ANDROID_ANR_RESOURCE_ID_RE.test(rid) && /aerr_/i.test(rid);
1609
- }
1610
- function looksLikeAndroidAnrUi(input) {
1611
- if (isAndroidAnrSystemResourceId(input.resourceId))
1612
- return true;
1613
- const rid = input.resourceId?.trim() ?? "";
1614
- const blob = [input.text, input.label, input.contentDesc].filter(Boolean).join(" ");
1615
- if (/(?:android:id\/)?(?:alertTitle|aerr_)/i.test(rid) && ANDROID_ANR_TEXT_RE.test(blob)) {
1616
- return true;
1617
- }
1618
- if (/isn['’]?t responding/i.test(blob))
1619
- return true;
1620
- return false;
1621
- }
1622
1596
  var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1623
1597
  "com.apple.springboard",
1624
1598
  "com.apple.authenticationservicesui",
@@ -1776,7 +1750,7 @@ var init_constants = __esm({
1776
1750
  MOBILE_APP_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?$/;
1777
1751
  MOBILE_FOCUS_SETTLE_MS = 150;
1778
1752
  MOBILE_POST_ACTION_SETTLE_MS = 1800;
1779
- MOBILE_COLD_BOOT_SETTLE_MS = 25e3;
1753
+ MOBILE_COLD_BOOT_SETTLE_MS = 12e3;
1780
1754
  MOBILE_DEFAULT_SWIPE_DISTANCE = 500;
1781
1755
  MOBILE_MAX_SWIPE_DISTANCE = 2e3;
1782
1756
  SELECTOR_PRIORITIES = [
@@ -74918,7 +74892,7 @@ var require_mobile_explorer = __commonJS({
74918
74892
  type: "function",
74919
74893
  function: {
74920
74894
  name: "mobile_relaunch",
74921
- 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.",
74895
+ 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.",
74922
74896
  parameters: { type: "object", properties: {} }
74923
74897
  }
74924
74898
  };
@@ -75718,11 +75692,11 @@ Rules:
75718
75692
  **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.
75719
75693
  **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.
75720
75694
 
75721
- 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.
75695
+ 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.
75722
75696
 
75723
75697
  ## Boundaries (survey = map, do not mutate)
75724
75698
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
75725
- - 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).
75699
+ - 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.
75726
75700
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
75727
75701
 
75728
75702
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -76133,8 +76107,6 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76133
76107
  const kickoff = buildKickoffMessage(mode);
76134
76108
  const recentExchanges = [];
76135
76109
  const transientDirectives = [];
76136
- const resetPolicyForTools = ctx.target?.resetPolicy ?? "AUTO";
76137
- const preserveAppDataForTools = resetPolicyForTools === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicyForTools === "AUTO";
76138
76110
  const tools = [
76139
76111
  MOBILE_SNAPSHOT_TOOL,
76140
76112
  MOBILE_TAP_TOOL,
@@ -76144,8 +76116,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
76144
76116
  MOBILE_SWIPE_TOOL,
76145
76117
  MOBILE_BACK_TOOL,
76146
76118
  MOBILE_HOME_TOOL,
76147
- // Omit relaunch under preserve — model otherwise cold-restarts RN into splash.
76148
- ...preserveAppDataForTools ? [] : [MOBILE_RELAUNCH_TOOL],
76119
+ MOBILE_RELAUNCH_TOOL,
76149
76120
  MOBILE_OPEN_DEEPLINK_TOOL,
76150
76121
  CAPTURE_SCREEN_TOOL,
76151
76122
  RECORD_TRANSITION_TOOL,
@@ -76501,8 +76472,7 @@ Exploration complete: ${summary}`);
76501
76472
  credentials: credentialSecrets,
76502
76473
  redactionCredentials: credentialValuesForRedaction,
76503
76474
  authTracker,
76504
- inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null,
76505
- allowRelaunch: !preserveAppDataForTools
76475
+ inbox: inboxEnabled && inboxState && inboxReader ? { state: inboxState, reader: inboxReader } : null
76506
76476
  });
76507
76477
  pushResult(handled);
76508
76478
  const handledRecord = handled && typeof handled === "object" ? handled : null;
@@ -76595,7 +76565,7 @@ Exploration complete: ${summary}`);
76595
76565
  return result;
76596
76566
  }
76597
76567
  async function dispatchMobileTool(args) {
76598
- 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;
76568
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, resolveHybridRef, currentBoundary, collectedTransitions, counters, journeys, log: log2, credentials, redactionCredentials, authTracker, inbox } = args;
76599
76569
  const absorbActionResult = (actionResult, source, preferCredentialSecondary = false) => {
76600
76570
  (0, mobile_driver_errors_js_1.throwIfTerminalMobileActionFailure)(actionResult, source);
76601
76571
  const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
@@ -76998,13 +76968,6 @@ Exploration complete: ${summary}`);
76998
76968
  }
76999
76969
  // ----- Relaunch (hermetic reset) -----
77000
76970
  case "mobile_relaunch": {
77001
- if (!allowRelaunch) {
77002
- return {
77003
- ok: false,
77004
- 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.",
77005
- note: "Relaunch refused under preserve policy."
77006
- };
77007
- }
77008
76971
  authTracker?.noteInterveningMutation(iteration);
77009
76972
  await driver.relaunch();
77010
76973
  log2(" Relaunched app for hermetic reset.");
@@ -82067,7 +82030,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
82067
82030
  }
82068
82031
  }
82069
82032
  async function runScenarioLoop(args) {
82070
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog, allowRelaunch } = args;
82033
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, redactionCredentials, log: emitLog } = args;
82071
82034
  const log2 = (line) => emitLog(redactCredentialValues(line, redactionCredentials));
82072
82035
  const actions = [];
82073
82036
  let assertCount = 0;
@@ -82641,7 +82604,6 @@ ${statePacket}`, redactionCredentials) },
82641
82604
  setLatest,
82642
82605
  credentials,
82643
82606
  screenName: () => lastScreenName || "UnknownScreen",
82644
- allowRelaunch,
82645
82607
  recordAction: (action) => {
82646
82608
  const screenHash = getLatest()?.signal.screenHash;
82647
82609
  const structuralKey = structuralScreenKey(getLatest());
@@ -82676,7 +82638,7 @@ ${statePacket}`, redactionCredentials) },
82676
82638
  return { actions, assertCount, finish, lastSnapshot: lastTargetSnapshot, lastScreenName: lastTargetScreenName };
82677
82639
  }
82678
82640
  async function dispatchGenerateTool(args) {
82679
- const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, allowRelaunch, recordAction, recordedActions, log: log2 } = args;
82641
+ const { toolName, toolArgs, driver, platform: platform3, targetAppId, latest, absorbObservation, resolveRef, resolveHybridRef, maybeEnrichHybridWeb, setLatest, credentials, screenName, recordAction, recordedActions, log: log2 } = args;
82680
82642
  const unknownRef = (ref) => ({
82681
82643
  ok: false,
82682
82644
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -83066,16 +83028,6 @@ ${statePacket}`, redactionCredentials) },
83066
83028
  return actionPayload(result, absorbed);
83067
83029
  }
83068
83030
  case "mobile_relaunch": {
83069
- if (!allowRelaunch) {
83070
- log2(" relaunch refused under PRESERVE_APP_DATA \u2014 staying on current app instance.");
83071
- const snap2 = await driver.snapshot();
83072
- const absorbed2 = await absorbObservation(snap2.xml, snap2.screenSignal, snap2.windowSize, snap2.screenshot, "mobile_relaunch_blocked");
83073
- return {
83074
- ok: false,
83075
- error: "mobile_relaunch is blocked while app data is preserved. Continue from the current screen.",
83076
- observation: absorbed2.observation
83077
- };
83078
- }
83079
83031
  await driver.relaunch();
83080
83032
  const snap = await driver.snapshot();
83081
83033
  const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
@@ -83294,48 +83246,14 @@ ${statePacket}`, redactionCredentials) },
83294
83246
  });
83295
83247
  return { ok: true, label: metadata.targetLabel ?? accessibilityId ?? text ?? "element" };
83296
83248
  }
83297
- 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;
83298
- function isDisplayableLandingCandidate(el, windowSize) {
83299
- if (el.visible === false)
83300
- return false;
83301
- const { x, y, width, height } = el.bounds;
83302
- if (!(width > 0 && height > 0))
83303
- return false;
83304
- if (width < 8 || height < 8)
83305
- return false;
83306
- if (windowSize && windowSize.width > 0 && windowSize.height > 0) {
83307
- const centerX = x + width / 2;
83308
- const centerY = y + height / 2;
83309
- if (centerX < 0 || centerY < 0 || centerX > windowSize.width || centerY > windowSize.height) {
83310
- return false;
83311
- }
83312
- if (width >= windowSize.width * 0.92 && height >= windowSize.height * 0.55) {
83313
- return false;
83314
- }
83315
- }
83316
- return true;
83317
- }
83318
- function landingAnchorSortKey(el) {
83319
- const label = (nonEmpty3(el.label) || nonEmpty3(el.text) || "").trim();
83320
- const ambient = AMBIENT_CHROME_LABEL_RE.test(label) ? 1 : 0;
83321
- const labelled = nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83322
- const area = Math.max(1, el.bounds.width * el.bounds.height);
83323
- const areaBucket = area < 8e4 ? 0 : area < 25e4 ? 1 : 2;
83324
- return [ambient, labelled, areaBucket];
83325
- }
83326
83249
  function pickLandingAnchor(snapshot) {
83327
83250
  if (!snapshot || snapshot.screen.elements.length === 0)
83328
83251
  return null;
83329
83252
  const elements = snapshot.screen.elements;
83330
- const windowSize = snapshot.screen.windowSize ?? null;
83331
- const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)).sort((a, b) => {
83332
- const ka = landingAnchorSortKey(a);
83333
- const kb = landingAnchorSortKey(b);
83334
- for (let i = 0; i < ka.length; i += 1) {
83335
- if (ka[i] !== kb[i])
83336
- return ka[i] - kb[i];
83337
- }
83338
- return 0;
83253
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
83254
+ stableAnchors.sort((a, b) => {
83255
+ const labelled2 = (el) => nonEmpty3(el.text) || nonEmpty3(el.label) ? 0 : 1;
83256
+ return labelled2(a) - labelled2(b);
83339
83257
  });
83340
83258
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83341
83259
  if (contentfulNonButton)
@@ -83345,10 +83263,8 @@ ${statePacket}`, redactionCredentials) },
83345
83263
  return nonButton;
83346
83264
  if (stableAnchors.length > 0)
83347
83265
  return stableAnchors[0];
83348
- const labelled = elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83349
- if (labelled)
83350
- return labelled;
83351
- return elements.find((el) => !weakAssertionTargetReason(el) && isDisplayableLandingCandidate(el, windowSize)) ?? null;
83266
+ const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty3(el.label) || nonEmpty3(el.text)));
83267
+ return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
83352
83268
  }
83353
83269
  var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
83354
83270
  var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
@@ -83446,19 +83362,10 @@ ${statePacket}`, redactionCredentials) },
83446
83362
  const anchor = pickLandingAnchor(recording.lastSnapshot);
83447
83363
  if (!anchor)
83448
83364
  return false;
83449
- const modelAssertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
83450
- const anchorLabel = (nonEmpty3(anchor.label) || nonEmpty3(anchor.text) || "").trim();
83451
- if (modelAssertCount > 0 && AMBIENT_CHROME_LABEL_RE.test(anchorLabel)) {
83452
- return false;
83453
- }
83454
- const anchorSelector = (0, mobile_source_parser_js_2.preferredLocator)(anchor);
83455
- const alreadyAsserted = actions.some((a) => (a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT") && typeof a.selector === "string" && a.selector === anchorSelector);
83456
- if (alreadyAsserted)
83457
- return false;
83458
83365
  const screen = recording.lastScreenName || "LandingScreen";
83459
83366
  actions.push(finalizeAction({
83460
83367
  type: "ASSERT_VISIBLE",
83461
- selector: anchorSelector,
83368
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(anchor),
83462
83369
  metadata: metadataForElement(anchor, screen, platform3, nativeElementsOf(recording.lastSnapshot))
83463
83370
  }));
83464
83371
  return true;
@@ -84334,8 +84241,7 @@ ${statePacket}`, redactionCredentials) },
84334
84241
  targetAppId,
84335
84242
  credentials: credentialSecrets,
84336
84243
  redactionCredentials: credentialValuesForRedaction,
84337
- log: log2,
84338
- allowRelaunch: (ctx.target?.resetPolicy ?? "AUTO") !== "PRESERVE_APP_DATA"
84244
+ log: log2
84339
84245
  });
84340
84246
  consecutiveInfraFailures = 0;
84341
84247
  const test = assembleTest({
@@ -337268,11 +337174,10 @@ Fatal error: ${msg}`);
337268
337174
  const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
337269
337175
  if (shouldPreserveAppData) {
337270
337176
  try {
337271
- await driver.launchApp();
337272
- log2("App activated with preserved data (no terminate/clear).");
337177
+ await driver.relaunch();
337273
337178
  } catch (err) {
337274
337179
  (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(err);
337275
- log2(`App activate failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337180
+ log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
337276
337181
  }
337277
337182
  return false;
337278
337183
  }
@@ -353437,7 +353342,7 @@ var require_package4 = __commonJS({
353437
353342
  "package.json"(exports, module) {
353438
353343
  module.exports = {
353439
353344
  name: "@validate.qa/runner",
353440
- version: "1.0.82",
353345
+ version: "1.0.84",
353441
353346
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353442
353347
  bin: {
353443
353348
  "validate-runner": "dist/cli.js"
@@ -354303,9 +354208,9 @@ async function waitForColdBootSettle(sessionPath) {
354303
354208
  timeoutMs: MOBILE_COLD_BOOT_SETTLE_MS,
354304
354209
  minStableFrames: 2,
354305
354210
  pollMs: 400,
354306
- // Native splash / WebView shell is ~5–8 nodes; RN product screens and rich
354307
- // onboarding typically flatten to 15+ once JS has painted.
354308
- minElements: 15
354211
+ // WebView native shell alone is ~5–8 nodes; real first screens flatten to
354212
+ // dozens. Require enough nodes that the DOM has painted (mirrors executor).
354213
+ minElements: 12
354309
354214
  });
354310
354215
  }
354311
354216
  async function getWindowSize(sessionPath) {
@@ -354454,33 +354359,20 @@ async function sendKeysToFocusedInput(sessionPath, value) {
354454
354359
  }
354455
354360
  async function typeText(sessionPath, value, bounds) {
354456
354361
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
354457
- if (!elementId && bounds === void 0) {
354458
- throw new Error("No focused element available for TYPE. Tap an input field first.");
354459
- }
354460
354362
  if (elementId) {
354461
354363
  try {
354462
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
354364
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354463
354365
  method: "POST",
354464
- body: JSON.stringify({})
354366
+ body: JSON.stringify({ text: value })
354465
354367
  });
354368
+ return;
354466
354369
  } catch (err) {
354467
354370
  if (isAppiumDriverDeadError(err)) throw err;
354468
354371
  }
354372
+ } else if (bounds === void 0) {
354373
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
354469
354374
  }
354470
- try {
354471
- await sendKeysToFocusedInput(sessionPath, value);
354472
- return;
354473
- } catch (err) {
354474
- if (isAppiumDriverDeadError(err)) throw err;
354475
- }
354476
- if (elementId) {
354477
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
354478
- method: "POST",
354479
- body: JSON.stringify({ text: value })
354480
- });
354481
- return;
354482
- }
354483
- throw new Error("TYPE failed: could not send keys to the focused input.");
354375
+ await sendKeysToFocusedInput(sessionPath, value);
354484
354376
  }
354485
354377
  async function submitKeyboard(sessionPath, bounds) {
354486
354378
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
@@ -357290,10 +357182,17 @@ function xpathLiteral3(value) {
357290
357182
  const parts = value.split("'").map((p) => `'${p}'`);
357291
357183
  return `concat(${parts.join(`, "'", `)})`;
357292
357184
  }
357185
+ var XPATH_ASCII_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
357186
+ var XPATH_ASCII_LOWER = "abcdefghijklmnopqrstuvwxyz";
357293
357187
  function identityTextXpath2(value) {
357294
357188
  const lit = xpathLiteral3(value);
357295
357189
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
357296
357190
  }
357191
+ function identityTextXpathCaseInsensitive(value) {
357192
+ const lit = xpathLiteral3(value.toLowerCase());
357193
+ const fold = (attr) => `translate(@${attr}, '${XPATH_ASCII_UPPER}', '${XPATH_ASCII_LOWER}')=${lit}`;
357194
+ return `//*[${fold("text")} or ${fold("content-desc")} or ${fold("label")} or ${fold("name")} or ${fold("value")}]`;
357195
+ }
357297
357196
  function iosPredicateLiteral(value) {
357298
357197
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
357299
357198
  }
@@ -357301,19 +357200,28 @@ function iosTextIdentityPredicate(value) {
357301
357200
  const lit = iosPredicateLiteral(value);
357302
357201
  return `label == ${lit} OR name == ${lit} OR value == ${lit}`;
357303
357202
  }
357203
+ function iosTextIdentityPredicateCaseInsensitive(value) {
357204
+ const lit = iosPredicateLiteral(value);
357205
+ return `label ==[c] ${lit} OR name ==[c] ${lit} OR value ==[c] ${lit}`;
357206
+ }
357304
357207
  function textIdentityStrategies(value, platform3) {
357305
357208
  const strategies = [];
357306
357209
  if (platform3 === "IOS") {
357307
357210
  strategies.push({ using: "-ios predicate string", value: iosTextIdentityPredicate(value) });
357211
+ strategies.push({ using: "-ios predicate string", value: iosTextIdentityPredicateCaseInsensitive(value) });
357308
357212
  }
357309
357213
  strategies.push({ using: "xpath", value: identityTextXpath2(value) });
357214
+ strategies.push({ using: "xpath", value: identityTextXpathCaseInsensitive(value) });
357310
357215
  return strategies;
357311
357216
  }
357312
357217
  function isIdentityIosPredicate(value) {
357313
- return /(?:^|[\s(])(?:name|label|value)\s*==\s*"/.test(value) && !/\b(?:CONTAINS|BEGINSWITH|ENDSWITH|LIKE|MATCHES|IN)\b/i.test(value);
357218
+ return /(?:^|[\s(])(?:name|label|value)\s*==(?:\[[cC]\])?\s*"/.test(value) && !/\b(?:CONTAINS|BEGINSWITH|ENDSWITH|LIKE|MATCHES|IN)\b/i.test(value);
357314
357219
  }
357315
357220
  function isIdentityXpath(value) {
357316
- return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
357221
+ if (/(?:contains|starts-with)\s*\(/i.test(value)) return false;
357222
+ if (/@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value)) return true;
357223
+ if (/translate\s*\(\s*@[A-Za-z_:][-A-Za-z0-9_:.]*\s*,/.test(value) && /\)\s*=/.test(value)) return true;
357224
+ return false;
357317
357225
  }
357318
357226
  function isIdentityStrategy(strategy) {
357319
357227
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
@@ -358136,7 +358044,12 @@ function mobileMjpegPortForDevice(platform3, deviceId, env = process.env) {
358136
358044
  return typeof port === "number" && Number.isFinite(port) ? port : null;
358137
358045
  }
358138
358046
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
358139
- var pinnedDeviceSnapshot = null;
358047
+ var pinnedDeviceSnapshots = /* @__PURE__ */ new Map();
358048
+ function prunePinnedDeviceSnapshots(now = Date.now()) {
358049
+ for (const [id, entry] of pinnedDeviceSnapshots) {
358050
+ if (now - entry.at >= PINNED_DEVICE_SNAPSHOT_TTL_MS) pinnedDeviceSnapshots.delete(id);
358051
+ }
358052
+ }
358140
358053
  async function executeMobileTest(options) {
358141
358054
  const logLines = [];
358142
358055
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -358211,8 +358124,10 @@ async function executeMobileTest(options) {
358211
358124
  }
358212
358125
  const platformFilter = options.target.platform;
358213
358126
  const pinnedDeviceId = options.target.assignedDeviceId;
358214
- const candidateSnapshot = pinnedDeviceSnapshot;
358215
- const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && candidateSnapshot.deviceId === pinnedDeviceId && Date.now() - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
358127
+ const now = Date.now();
358128
+ prunePinnedDeviceSnapshots(now);
358129
+ const candidateSnapshot = pinnedDeviceId ? pinnedDeviceSnapshots.get(pinnedDeviceId) : void 0;
358130
+ const reusablePinnedSnapshot = pinnedDeviceId && candidateSnapshot && now - candidateSnapshot.at < PINNED_DEVICE_SNAPSHOT_TTL_MS ? candidateSnapshot : null;
358216
358131
  if (reusablePinnedSnapshot) {
358217
358132
  assertDeviceNotQuarantined(reusablePinnedSnapshot.device.id);
358218
358133
  selectedDevice = reusablePinnedSnapshot.device;
@@ -358228,22 +358143,24 @@ async function executeMobileTest(options) {
358228
358143
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
358229
358144
  );
358230
358145
  }
358231
- if (options.target.assignedDeviceId) {
358232
- selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
358233
- if (!selectedDevice) {
358234
- throw new Error(
358235
- `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.`
358236
- );
358237
- }
358238
- }
358239
- if (!selectedDevice && options.target.recordedDeviceId) {
358240
- selectedDevice = matchingDevices.find((d) => d.id === options.target.recordedDeviceId);
358146
+ if (!options.target.assignedDeviceId) {
358147
+ throw new Error(
358148
+ "CONFIG_ISSUE: mobile test requires an assigned device lease; refusing unpinned session create."
358149
+ );
358241
358150
  }
358151
+ selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
358242
358152
  if (!selectedDevice) {
358243
- selectedDevice = matchingDevices[0];
358153
+ throw new Error(
358154
+ `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.`
358155
+ );
358244
358156
  }
358245
358157
  log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
358246
- pinnedDeviceSnapshot = options.target.assignedDeviceId ? { deviceId: options.target.assignedDeviceId, device: selectedDevice, at: Date.now() } : null;
358158
+ if (options.target.assignedDeviceId) {
358159
+ pinnedDeviceSnapshots.set(options.target.assignedDeviceId, {
358160
+ device: selectedDevice,
358161
+ at: Date.now()
358162
+ });
358163
+ }
358247
358164
  }
358248
358165
  try {
358249
358166
  capture = await startMobileNetworkCapture({
@@ -358579,20 +358496,16 @@ async function createMobileDiscoverySession(target, options) {
358579
358496
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
358580
358497
  );
358581
358498
  }
358582
- let selectedDevice;
358583
- if (target.assignedDeviceId) {
358584
- selectedDevice = matchingDevices.find((d) => d.id === target.assignedDeviceId);
358585
- if (!selectedDevice) {
358586
- throw new Error(
358587
- `CONFIG_ISSUE: assigned ${platformFilter} device ${target.assignedDeviceId} is no longer booted for mobile discovery; will retry.`
358588
- );
358589
- }
358590
- }
358591
- if (!selectedDevice && target.recordedDeviceId) {
358592
- selectedDevice = matchingDevices.find((d) => d.id === target.recordedDeviceId);
358499
+ if (!target.assignedDeviceId) {
358500
+ throw new Error(
358501
+ "CONFIG_ISSUE: mobile discovery requires an assigned device lease; refusing unpinned session create."
358502
+ );
358593
358503
  }
358504
+ const selectedDevice = matchingDevices.find((d) => d.id === target.assignedDeviceId);
358594
358505
  if (!selectedDevice) {
358595
- selectedDevice = matchingDevices[0];
358506
+ throw new Error(
358507
+ `CONFIG_ISSUE: assigned ${platformFilter} device ${target.assignedDeviceId} is no longer booted for mobile discovery; will retry.`
358508
+ );
358596
358509
  }
358597
358510
  if (selectedDevice.installedApps && selectedDevice.installedApps.length > 0 && !selectedDevice.installedApps.includes(target.appId)) {
358598
358511
  throw new Error(
@@ -359034,6 +358947,10 @@ function computeMobileCapacity(input) {
359034
358947
  }
359035
358948
  return Math.max(0, cap);
359036
358949
  }
358950
+ function discoveryKindOf(ctx) {
358951
+ if (!isNativeMobileDiscoveryContext(ctx)) return null;
358952
+ return ctx?.mode === "survey" ? "survey" : "deep";
358953
+ }
359037
358954
  function decideMobileDeviceGate(input) {
359038
358955
  const {
359039
358956
  runMode,
@@ -359044,8 +358961,10 @@ function decideMobileDeviceGate(input) {
359044
358961
  readyDevices,
359045
358962
  capacity,
359046
358963
  freeDeviceId,
359047
- isDiscovery,
359048
- activeMobileDiscoveries
358964
+ discoveryKind,
358965
+ activeMobileSurveys,
358966
+ activeMobileDeeps,
358967
+ maxMobileDeeps
359049
358968
  } = input;
359050
358969
  if (!platform3) {
359051
358970
  return { claim: false, disposition: "pending", reason: "mobile run has no supported target platform" };
@@ -359057,8 +358976,18 @@ function decideMobileDeviceGate(input) {
359057
358976
  reason: `no booted ${platform3 === "IOS" ? "iOS" : "Android"} device for mobile run`
359058
358977
  };
359059
358978
  }
359060
- if (isDiscovery && activeMobileDiscoveries >= 1) {
359061
- return { claim: false, disposition: "defer", reason: "native discovery serialized to the shared live-mirror" };
358979
+ if (discoveryKind === "survey" && activeMobileSurveys >= 1) {
358980
+ return { claim: false, disposition: "defer", reason: "native survey serialized to 1 concurrent" };
358981
+ }
358982
+ if (discoveryKind === "deep") {
358983
+ const deepCap = Math.max(0, maxMobileDeeps);
358984
+ if (activeMobileDeeps >= deepCap) {
358985
+ return {
358986
+ claim: false,
358987
+ disposition: "defer",
358988
+ reason: `native deep discovery capacity reached: ${activeMobileDeeps}/${deepCap}`
358989
+ };
358990
+ }
359062
358991
  }
359063
358992
  const isHealForHeldTest = runMode === "heal" && !!testCaseId && healHoldTestCaseIds.has(testCaseId);
359064
358993
  const reservedForHeals = isHealForHeldTest ? Math.max(0, healHoldTestCaseIds.size - 1) : healHoldTestCaseIds.size;
@@ -359591,9 +359520,6 @@ async function startSession(validated, res) {
359591
359520
  ...deviceId ? { "appium:udid": deviceId } : {},
359592
359521
  ...platformVersion ? { "appium:platformVersion": platformVersion } : {},
359593
359522
  "appium:noReset": true,
359594
- // Keep pace with executor createSession: Expo "Open in App?" sheets and
359595
- // permission dialogs otherwise freeze WDA for recording/mirror sessions.
359596
- ...process.env.APPIUM_IOS_AUTO_ACCEPT_ALERTS !== "false" ? { "appium:autoAcceptAlerts": true } : {},
359597
359523
  // Same Xcode 27+ workaround as the executor's createSession (appium-executor.ts):
359598
359524
  // Simulator.app is no longer at <DEVELOPER_DIR>/Applications, so XCUITest's
359599
359525
  // "open Simulator UI" step fails. The sim is already booted via CoreSimulator;
@@ -361469,11 +361395,12 @@ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWA
361469
361395
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
361470
361396
  var DEFAULT_TYPE_LIMITS = {
361471
361397
  discovery: IS_CLOUD ? 2 : 15,
361472
- // Native mobile discovery is single-device (one Appium session per runner), so
361473
- // it is always capped at 1 regardless of cloud/local — the shared-device mutex
361474
- // (setExecutorBusy + getBridgeState().hasActiveSession) already serializes it,
361475
- // and this static limit documents that ceiling for the resource governor.
361476
- "mobile-discovery": 1,
361398
+ // Max concurrent native deep discoveries on one local runner (survey is always
361399
+ // 1 via decideMobileDeviceGate). Applied by the device gate as maxMobileDeeps
361400
+ // (MAX_MOBILE_DISCOVERY); the resource-governor type key is run.mode='discovery'
361401
+ // and does NOT read this entry — do not treat TYPE_LIMITS['mobile-discovery'] as
361402
+ // the live governor. Env default 2 enables two deeps when two free devices exist.
361403
+ "mobile-discovery": 2,
361477
361404
  heal: IS_CLOUD ? 2 : 8,
361478
361405
  "regression-suite": IS_CLOUD ? 1 : 2,
361479
361406
  "system-regression-suite": IS_CLOUD ? 1 : 2,
@@ -363122,10 +363049,10 @@ ${finalVerifyResult.logs ?? ""}`;
363122
363049
  log.fail("ERROR - malformed locator-discovery context");
363123
363050
  return;
363124
363051
  }
363125
- if (getBridgeState().hasActiveSession) {
363052
+ if (getBridgeState().recordingActive) {
363126
363053
  await postResult(opts.serverUrl, headers, run2.runId, {
363127
363054
  status: "ERROR",
363128
- error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
363055
+ error: "CONFIG_ISSUE: a mobile recording session is active; retry once it ends."
363129
363056
  });
363130
363057
  log.fail("ERROR - device busy (recording) for locator discovery");
363131
363058
  return;
@@ -363554,6 +363481,11 @@ ${finalVerifyResult.logs ?? ""}`;
363554
363481
  if (!mobileTarget || !mobileTarget.appId) {
363555
363482
  throw new Error("Native mobile discovery requires a target with an appId (bundleId/appPackage).");
363556
363483
  }
363484
+ if (!assignedDeviceId) {
363485
+ throw new Error(
363486
+ "CONFIG_ISSUE: mobile discovery was claimed without a device lease; refusing unpinned session create."
363487
+ );
363488
+ }
363557
363489
  const targetPlatform = mobileTarget.platform;
363558
363490
  const platform3 = mobileMedium === "IOS_NATIVE" ? "IOS" : mobileMedium === "ANDROID_NATIVE" ? "ANDROID" : targetPlatform === "IOS" || targetPlatform === "ANDROID" ? targetPlatform : null;
363559
363491
  if (!platform3) {
@@ -363562,8 +363494,8 @@ ${finalVerifyResult.logs ?? ""}`;
363562
363494
  if (mobileMedium === "IOS_NATIVE" && targetPlatform === "ANDROID" || mobileMedium === "ANDROID_NATIVE" && targetPlatform === "IOS") {
363563
363495
  throw new Error("Native mobile discovery medium does not match target.platform.");
363564
363496
  }
363565
- if (getBridgeState().hasActiveSession) {
363566
- throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
363497
+ if (getBridgeState().recordingActive) {
363498
+ throw new Error("CONFIG_ISSUE: a mobile recording session is active; mobile discovery will retry once it ends.");
363567
363499
  }
363568
363500
  const mobileProgressLines = [];
363569
363501
  const mobileOnLog = (line) => {
@@ -364041,10 +363973,10 @@ ${finalVerifyResult.logs ?? ""}`;
364041
363973
  return;
364042
363974
  }
364043
363975
  if (runMode === "heal" && run2.mobileAiHeal === true) {
364044
- if (getBridgeState().hasActiveSession) {
363976
+ if (getBridgeState().recordingActive) {
364045
363977
  await postResult(opts.serverUrl, headers, run2.runId, {
364046
363978
  status: "ERROR",
364047
- error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
363979
+ error: "CONFIG_ISSUE: a mobile recording session is active; the AI heal will retry once it ends.",
364048
363980
  duration: Date.now() - startTime
364049
363981
  });
364050
363982
  log.warn("Skipped mobile AI heal (device busy with a recording)");
@@ -364782,7 +364714,9 @@ async function startRunner(opts) {
364782
364714
  };
364783
364715
  const leasedDeviceIds = /* @__PURE__ */ new Map();
364784
364716
  const activeMobileRuns = /* @__PURE__ */ new Map();
364785
- let activeMobileDiscoveries = 0;
364717
+ let activeMobileSurveys = 0;
364718
+ let activeMobileDeeps = 0;
364719
+ const MAX_MOBILE_DEEPS = Math.max(0, envInt("MAX_MOBILE_DISCOVERY", DEFAULT_TYPE_LIMITS["mobile-discovery"]));
364786
364720
  const mobileHealHold = /* @__PURE__ */ new Map();
364787
364721
  const pruneMobileHealHolds = () => {
364788
364722
  if (mobileHealHold.size === 0) return;
@@ -365127,7 +365061,7 @@ async function startRunner(opts) {
365127
365061
  if (activeRuns >= MAX_CONCURRENT) break;
365128
365062
  const runMode = run2.mode ?? "run";
365129
365063
  const isMobileOp = enableMobile && isMobileDeviceOp(run2);
365130
- const isDiscovery = isMobileOp && isNativeMobileDiscoveryContext(run2.discoveryContext);
365064
+ const discoveryKind = isMobileOp ? discoveryKindOf(run2.discoveryContext) : null;
365131
365065
  let leasedDeviceId = null;
365132
365066
  let mobileLeaseError;
365133
365067
  const runProject = projectKey(run2);
@@ -365146,7 +365080,7 @@ async function startRunner(opts) {
365146
365080
  logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (${admission.reason})`);
365147
365081
  continue;
365148
365082
  }
365149
- if (enableMobile && run2.framework === "APPIUM" && getBridgeState().recordingActive) {
365083
+ if (enableMobile && isMobileOp && getBridgeState().recordingActive) {
365150
365084
  logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active)`);
365151
365085
  continue;
365152
365086
  }
@@ -365162,10 +365096,6 @@ async function startRunner(opts) {
365162
365096
  logDeferredOnce(run2.runId, ` Left PENDING ${run2.runId} (no booted ${discoveryMedium === "IOS_NATIVE" ? "iOS" : "Android"} device for native mobile discovery)`);
365163
365097
  continue;
365164
365098
  }
365165
- if (getBridgeState().hasActiveSession) {
365166
- logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active on shared device)`);
365167
- continue;
365168
- }
365169
365099
  }
365170
365100
  const mobilePlatform = isMobileOp ? mobilePlatformOf(run2) : null;
365171
365101
  if (isMobileOp && mobilePlatform) {
@@ -365207,13 +365137,18 @@ async function startRunner(opts) {
365207
365137
  readyDevices: virtual + physical,
365208
365138
  capacity,
365209
365139
  freeDeviceId,
365210
- isDiscovery,
365211
- activeMobileDiscoveries
365140
+ discoveryKind,
365141
+ activeMobileSurveys,
365142
+ activeMobileDeeps,
365143
+ maxMobileDeeps: MAX_MOBILE_DEEPS
365212
365144
  });
365213
365145
  if (!decision.claim) {
365214
365146
  logDeferredOnce(run2.runId, ` ${decision.disposition === "pending" ? "Left PENDING" : "Deferred"} ${run2.runId} (${decision.reason})`);
365215
365147
  continue;
365216
365148
  }
365149
+ log.dim(
365150
+ ` [mobile-gate] admit ${discoveryKind ?? runMode} device=${freeDeviceId} surveys=${activeMobileSurveys} deeps=${activeMobileDeeps}/${MAX_MOBILE_DEEPS} ops=${getActiveMobileOps(mobilePlatform)}/${capacity}`
365151
+ );
365217
365152
  leasedDeviceId = freeDeviceId;
365218
365153
  leasedDeviceIds.set(freeDeviceId, run2.runId);
365219
365154
  }
@@ -365241,7 +365176,8 @@ async function startRunner(opts) {
365241
365176
  incrementProject(runProject);
365242
365177
  if (isMobileOp) {
365243
365178
  if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, 1);
365244
- if (isDiscovery) activeMobileDiscoveries++;
365179
+ if (discoveryKind === "survey") activeMobileSurveys++;
365180
+ else if (discoveryKind === "deep") activeMobileDeeps++;
365245
365181
  if (runMode === "heal" && run2.testCaseId) mobileHealHold.delete(run2.testCaseId);
365246
365182
  }
365247
365183
  if (claimedThisPoll > 0 && staggerMs > 0) {
@@ -365254,7 +365190,8 @@ async function startRunner(opts) {
365254
365190
  decrementProject(runProject);
365255
365191
  if (isMobileOp) {
365256
365192
  if (mobilePlatform) bumpActiveMobileOps(mobilePlatform, -1);
365257
- if (isDiscovery) activeMobileDiscoveries--;
365193
+ if (discoveryKind === "survey") activeMobileSurveys = Math.max(0, activeMobileSurveys - 1);
365194
+ else if (discoveryKind === "deep") activeMobileDeeps = Math.max(0, activeMobileDeeps - 1);
365258
365195
  if (leasedDeviceId) leasedDeviceIds.delete(leasedDeviceId);
365259
365196
  activeMobileRuns.delete(run2.runId);
365260
365197
  }