@validate.qa/runner 1.0.31 → 1.0.32

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 +205 -18
  2. package/dist/cli.mjs +205 -18
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5001,6 +5001,29 @@ var require_mobile_observation = __commonJS({
5001
5001
  }
5002
5002
  });
5003
5003
 
5004
+ // ../runner-core/dist/services/mobile/mobile-keyboard.js
5005
+ var require_mobile_keyboard = __commonJS({
5006
+ "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports2) {
5007
+ "use strict";
5008
+ Object.defineProperty(exports2, "__esModule", { value: true });
5009
+ exports2.shouldRetryTapAfterKeyboardDismiss = shouldRetryTapAfterKeyboardDismiss;
5010
+ exports2.dismissKeyboardForRetry = dismissKeyboardForRetry;
5011
+ var EDITABLE_FOCUSED_ROLE_RE = /(EditText|TextField|SecureTextField|SearchField|TextView)$/i;
5012
+ var OUTSIDE_VIEWPORT_ERROR_RE = /(outside|out of).*?(bounds|viewport|visible|screen|window)|coordinate.*?(invalid|outside)/i;
5013
+ function shouldRetryTapAfterKeyboardDismiss(error2, elements) {
5014
+ if (!error2 || !OUTSIDE_VIEWPORT_ERROR_RE.test(error2))
5015
+ return false;
5016
+ return elements.some((el) => el.focused === true && EDITABLE_FOCUSED_ROLE_RE.test(el.role));
5017
+ }
5018
+ async function dismissKeyboardForRetry(driver) {
5019
+ if (typeof driver.dismissKeyboard === "function") {
5020
+ return driver.dismissKeyboard();
5021
+ }
5022
+ return driver.back();
5023
+ }
5024
+ }
5025
+ });
5026
+
5004
5027
  // ../runner-core/dist/utils/scrub-credentials.js
5005
5028
  var require_scrub_credentials = __commonJS({
5006
5029
  "../runner-core/dist/utils/scrub-credentials.js"(exports2) {
@@ -61982,6 +62005,7 @@ var require_mobile_explorer = __commonJS({
61982
62005
  var mobile_source_parser_js_1 = require_mobile_source_parser();
61983
62006
  var mobile_observation_js_1 = require_mobile_observation();
61984
62007
  var mobile_boundary_js_1 = require_mobile_boundary();
62008
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
61985
62009
  var credential_tools_js_1 = require_credential_tools();
61986
62010
  var verification_inbox_js_1 = require_verification_inbox();
61987
62011
  var inbox_tools_js_1 = require_inbox_tools();
@@ -61991,6 +62015,7 @@ var require_mobile_explorer = __commonJS({
61991
62015
  var MAX_RECENT_EXCHANGES = 24;
61992
62016
  var OBSERVATION_MAX_ELEMENTS = 50;
61993
62017
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
62018
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
61994
62019
  var MOBILE_SNAPSHOT_TOOL = {
61995
62020
  type: "function",
61996
62021
  function: {
@@ -62556,9 +62581,46 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62556
62581
  externalOwner: boundary.owner
62557
62582
  };
62558
62583
  }
62559
- log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
62584
+ let lastExternalOwner = recoveredBoundary.owner;
62585
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
62586
+ try {
62587
+ await driver.launchApp();
62588
+ const retrySnap = await driver.snapshot();
62589
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.windowSize, retrySnap.screenSignal);
62590
+ if (!retryRecovered)
62591
+ continue;
62592
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
62593
+ latest = retryRecovered;
62594
+ lastSnapshotIteration = iterationForObservation;
62595
+ if (retryBoundary.kind === "target" || retryBoundary.kind === "unknown") {
62596
+ const screenId = ingestSnapshot(retryRecovered, iterationForObservation);
62597
+ captureScreenshot(retrySnap.screenshot, screenId);
62598
+ log2(` Recovered target app after ${attempt} additional activation attempt(s).`);
62599
+ return {
62600
+ observation: retryRecovered.observation,
62601
+ screenId,
62602
+ externalBlocked: true,
62603
+ externalOwner: boundary.owner
62604
+ };
62605
+ }
62606
+ if (retryBoundary.kind === "transient_external") {
62607
+ log2(` Recovery surfaced system overlay ${retryBoundary.owner}; not adding it to target-app survey evidence.`);
62608
+ return {
62609
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
62610
+ screenId: null,
62611
+ externalBlocked: true,
62612
+ transientExternal: true,
62613
+ externalOwner: boundary.owner
62614
+ };
62615
+ }
62616
+ lastExternalOwner = retryBoundary.owner;
62617
+ } catch (err) {
62618
+ log2(` Target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
62619
+ }
62620
+ }
62621
+ log2(` Relaunch still outside target app (${lastExternalOwner}); no screen recorded.`);
62560
62622
  return {
62561
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
62623
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no screen was recorded)`,
62562
62624
  screenId: null,
62563
62625
  externalBlocked: true,
62564
62626
  externalOwner: boundary.owner
@@ -62936,7 +62998,19 @@ Exploration complete: ${summary}`);
62936
62998
  const fromScreenId = state2.getCurrentScreenId();
62937
62999
  const boundaryBeforeAction = currentBoundary();
62938
63000
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
62939
- const actionResult = await driver.tap(resolved.bounds);
63001
+ let actionResult = await driver.tap(resolved.bounds);
63002
+ if (!actionResult.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(actionResult.error, latest()?.screen.elements ?? [])) {
63003
+ log2(" Tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
63004
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
63005
+ const retryResult = await driver.tap(resolved.bounds);
63006
+ actionResult = retryResult.ok ? retryResult : {
63007
+ ...retryResult,
63008
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
63009
+ source: retryResult.source ?? dismissResult.source,
63010
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
63011
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
63012
+ };
63013
+ }
62940
63014
  const absorbed = await absorbActionResult(actionResult, "mobile_tap");
62941
63015
  const { observation, screenId } = absorbed;
62942
63016
  if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
@@ -65936,6 +66010,25 @@ var require_mobile_discovery_prompts = __commonJS({
65936
66010
  function targetAppScreens(graph, appId) {
65937
66011
  return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
65938
66012
  }
66013
+ function isGenericNonFeatureScreen(state2) {
66014
+ const type = state2.screenType?.trim().toLowerCase();
66015
+ if (type === "error" || type === "permission")
66016
+ return true;
66017
+ if (type && type !== "unknown")
66018
+ return false;
66019
+ const actionableCount = state2.elements.filter((el) => el.actionable).length;
66020
+ if (actionableCount > 0)
66021
+ return false;
66022
+ const evidenceText = [
66023
+ state2.screenName,
66024
+ state2.screenType,
66025
+ ...state2.observations ?? []
66026
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ");
66027
+ return /\b(empty|loading|blank|spinner|intermediate|placeholder|no actionable)\b/i.test(evidenceText);
66028
+ }
66029
+ function plannableTargetAppScreens(graph, appId) {
66030
+ return targetAppScreens(graph, appId).filter((state2) => !isGenericNonFeatureScreen(state2));
66031
+ }
65939
66032
  exports2.MOBILE_SURVEY_SYSTEM_PROMPT = `You are a feature analyst for a native mobile application (iOS or Android). You receive evidence from an AI explorer that mapped the app's screens by tapping, typing, and swiping through the live UI. Your job is to organize screens into meaningful feature groups.
65940
66033
 
65941
66034
  ## Output Discipline
@@ -65955,8 +66048,8 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65955
66048
  5. CRUD screens for the same entity belong together
65956
66049
  6. Do NOT merge screens into one feature just because they serve a similar broader goal. Treat areas as separate features when the evidence shows separate primary navigation destinations (e.g. separate tab-bar items or drawer links), separate screen families for distinct entities, or different CRUD entities/forms. Keep screens together when they share one navigational area and operate on the same entity (e.g. a list screen, its filter/sort sheet, and its detail screen all belong together).
65957
66050
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65958
- 8. Do NOT create standalone features for generic error / empty screens (network-error, permission-denied). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description.
65959
- 9. COVER EVERY SCREEN: every explored screen except generic error / permission screens MUST appear in exactly one feature group's routes. Do not leave screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
66051
+ 8. Do NOT create standalone features for generic error / empty / loading / intermediate screens (network-error, permission-denied, blank WebView, spinner-only loading). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description. If a screen has no actionable controls and only says it is empty/loading/intermediate, omit it from featureGroups.routes unless it is the only evidence for a real product feature.
66052
+ 9. COVER EVERY PRODUCT SCREEN: every explored product screen except generic error / permission / empty / loading / intermediate screens MUST appear in exactly one feature group's routes. Do not leave usable screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous usable screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
65960
66053
  10. LOGGED-OUT vs LOGGED-IN SEPARATION: screens shown BEFORE the user is signed in (splash, landing/welcome, theme or language pickers, walkthroughs, sign-up, sign-in, forgot-password, demo/preview screens) must NEVER share a feature group with the signed-in app. Put sign-in / sign-up / forgot-password in "Authentication" (rule 4) and the remaining pre-login screens in an "Onboarding" group. Features named after signed-in areas (feeds, dashboards, tabs, settings) must contain ONLY screens reached after authentication. A pre-login flow is a linear sequence, not a navigation destination \u2014 do not let it swallow the app's main features.
65961
66054
 
65962
66055
  ## Output JSON Schema
@@ -66223,7 +66316,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
66223
66316
  sections.push(`## Feature: ${context.featureName}
66224
66317
  SCOPE: this is a feature-focused deep run. Plan scenarios ONLY for the "${context.featureName}" feature \u2014 its screens, forms, and flows. Screens belonging to other features are navigation context / prerequisites only; do NOT plan scenarios whose primary goal exercises another feature.`);
66225
66318
  }
66226
- const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66319
+ const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66227
66320
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66228
66321
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
66229
66322
  for (const state2 of orderedScreens) {
@@ -66332,7 +66425,7 @@ ${constraints.join("\n")}`);
66332
66425
  const now = /* @__PURE__ */ new Date();
66333
66426
  const idByScreen = /* @__PURE__ */ new Map();
66334
66427
  let pageSeq = 0;
66335
- const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66428
+ const orderedScreens = plannableTargetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66336
66429
  for (const state2 of orderedScreens) {
66337
66430
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
66338
66431
  }
@@ -66393,7 +66486,7 @@ ${constraints.join("\n")}`);
66393
66486
  }
66394
66487
  function buildMobileFeatureGroupsFromGraph(graph, appId) {
66395
66488
  const groups = /* @__PURE__ */ new Map();
66396
- const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66489
+ const orderedScreens = plannableTargetAppScreens(graph, appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66397
66490
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66398
66491
  for (const state2 of orderedScreens) {
66399
66492
  const screenTypeLower = state2.screenType?.toLowerCase();
@@ -66440,7 +66533,7 @@ ${constraints.join("\n")}`);
66440
66533
  return [];
66441
66534
  if (input.featureName?.trim())
66442
66535
  return [];
66443
- const orderedScreens = targetAppScreens(input.graph, input.appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66536
+ const orderedScreens = plannableTargetAppScreens(input.graph, input.appId).filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66444
66537
  if (orderedScreens.length === 0)
66445
66538
  return [];
66446
66539
  const entryScreenId = orderedScreens[0].screenId;
@@ -66642,9 +66735,11 @@ var require_mobile_generator = __commonJS({
66642
66735
  var mobile_source_parser_js_2 = require_mobile_source_parser();
66643
66736
  var mobile_observation_js_1 = require_mobile_observation();
66644
66737
  var mobile_boundary_js_1 = require_mobile_boundary();
66738
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
66645
66739
  var MAX_SCENARIO_ITERATIONS = 30;
66646
66740
  var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
66647
66741
  var MAX_RECENT_EXCHANGES = 24;
66742
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
66648
66743
  var OBSERVATION_MAX_ELEMENTS = 50;
66649
66744
  function finalizeAction(input) {
66650
66745
  return { ...input, url: "", selector: input.selector ?? "" };
@@ -67060,10 +67155,47 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
67060
67155
  externalOwner: boundary.owner
67061
67156
  };
67062
67157
  }
67063
- log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
67158
+ let lastExternalOwner = recoveredBoundary.owner;
67159
+ let lastRecovered = recovered;
67160
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
67161
+ try {
67162
+ await driver.launchApp();
67163
+ const retrySnap = await driver.snapshot();
67164
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.screenSignal, retrySnap.windowSize);
67165
+ if (!retryRecovered)
67166
+ continue;
67167
+ lastRecovered = retryRecovered;
67168
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
67169
+ setLatest(retryRecovered);
67170
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(retryBoundary)) {
67171
+ emitScreenshot(retrySnap.screenshot);
67172
+ log2(` recovered target app after ${attempt} additional activation attempt(s).`);
67173
+ return {
67174
+ resolved: retryRecovered,
67175
+ observation: retryRecovered.observation,
67176
+ externalBlocked: true,
67177
+ externalOwner: boundary.owner
67178
+ };
67179
+ }
67180
+ if (retryBoundary.kind === "transient_external") {
67181
+ log2(` recovery surfaced system overlay ${retryBoundary.owner}; not recording it as a target-app step.`);
67182
+ return {
67183
+ resolved: retryRecovered,
67184
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
67185
+ externalBlocked: true,
67186
+ transientExternal: true,
67187
+ externalOwner: boundary.owner
67188
+ };
67189
+ }
67190
+ lastExternalOwner = retryBoundary.owner;
67191
+ } catch (err) {
67192
+ log2(` target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
67193
+ }
67194
+ }
67195
+ log2(` relaunch still outside target app (${lastExternalOwner}); no target-app step recorded.`);
67064
67196
  return {
67065
- resolved: recovered,
67066
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
67197
+ resolved: lastRecovered,
67198
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no target-app step was recorded)`,
67067
67199
  externalBlocked: true,
67068
67200
  externalOwner: boundary.owner
67069
67201
  };
@@ -67277,7 +67409,19 @@ ${statePacket}` },
67277
67409
  return unknownRef(toolArgs.ref);
67278
67410
  const beforeScreenName = screenName();
67279
67411
  const before = latest();
67280
- const result = await driver.tap(element.bounds);
67412
+ let result = await driver.tap(element.bounds);
67413
+ if (!result.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(result.error, before?.screen.elements ?? [])) {
67414
+ log2(" tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
67415
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
67416
+ const retryResult = await driver.tap(element.bounds);
67417
+ result = retryResult.ok ? retryResult : {
67418
+ ...retryResult,
67419
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
67420
+ source: retryResult.source ?? dismissResult.source,
67421
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
67422
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
67423
+ };
67424
+ }
67281
67425
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
67282
67426
  if (shouldRecordAction(result, absorbed) && changedScreen(before, absorbed)) {
67283
67427
  recordAction({
@@ -315412,8 +315556,8 @@ var require_mobile_discovery_agent = __commonJS({
315412
315556
  };
315413
315557
  }
315414
315558
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
315415
- const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
315416
- log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315559
+ const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", mode, log2);
315560
+ log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : mode === "survey" && (target?.resetPolicy ?? "AUTO") === "AUTO" ? "App data reset skipped for mobile survey AUTO \u2014 exploring with preserved app data. Use resetPolicy=CLEAR_APP_DATA for a cold logged-out survey." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315417
315561
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
315418
315562
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
315419
315563
  const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
@@ -315655,10 +315799,11 @@ Fatal error: ${msg}`);
315655
315799
  };
315656
315800
  }
315657
315801
  }
315658
- async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
315802
+ async function prepareMobileAppStateForDiscovery(driver, target, phase, mode, log2) {
315659
315803
  void phase;
315660
315804
  const resetPolicy = target?.resetPolicy ?? "AUTO";
315661
- if (resetPolicy === "PRESERVE_APP_DATA") {
315805
+ const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
315806
+ if (shouldPreserveAppData) {
315662
315807
  try {
315663
315808
  await driver.relaunch();
315664
315809
  } catch (err) {
@@ -330179,7 +330324,7 @@ var require_package4 = __commonJS({
330179
330324
  "package.json"(exports2, module2) {
330180
330325
  module2.exports = {
330181
330326
  name: "@validate.qa/runner",
330182
- version: "1.0.31",
330327
+ version: "1.0.32",
330183
330328
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
330184
330329
  bin: {
330185
330330
  "validate-runner": "dist/cli.js"
@@ -330843,6 +330988,37 @@ async function swipe(sessionPath, direction, distance, windowSize) {
330843
330988
  async function pressBack(sessionPath) {
330844
330989
  await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
330845
330990
  }
330991
+ function isKeyboardAlreadyHiddenError(err) {
330992
+ const message = err instanceof Error ? err.message : String(err);
330993
+ return /keyboard.*(not|already).*(shown|visible|present|open)|no.*keyboard/i.test(message);
330994
+ }
330995
+ async function dismissKeyboard(sessionPath, platform3) {
330996
+ if (platform3 === "ANDROID") {
330997
+ await pressBack(sessionPath);
330998
+ return;
330999
+ }
331000
+ try {
331001
+ await appiumRequest(`${sessionPath}/appium/device/hide_keyboard`, {
331002
+ method: "POST",
331003
+ body: "{}"
331004
+ });
331005
+ return;
331006
+ } catch (err) {
331007
+ if (isAppiumDriverDeadError(err)) throw err;
331008
+ if (isKeyboardAlreadyHiddenError(err)) return;
331009
+ }
331010
+ try {
331011
+ await appiumRequest(`${sessionPath}/execute/sync`, {
331012
+ method: "POST",
331013
+ body: JSON.stringify({ script: "mobile: hideKeyboard", args: [{}] })
331014
+ });
331015
+ return;
331016
+ } catch (err) {
331017
+ if (isAppiumDriverDeadError(err)) throw err;
331018
+ if (isKeyboardAlreadyHiddenError(err)) return;
331019
+ }
331020
+ await pressBack(sessionPath);
331021
+ }
330846
331022
  async function pressHome(sessionPath, platform3) {
330847
331023
  if (platform3 === "IOS") {
330848
331024
  await appiumRequest(`${sessionPath}/execute/sync`, {
@@ -334836,6 +335012,10 @@ var MobileBridgeDriver = class {
334836
335012
  async back() {
334837
335013
  return this.runAction(() => pressBack(this.sessionPath));
334838
335014
  }
335015
+ /** Platform-aware software keyboard dismissal. */
335016
+ async dismissKeyboard() {
335017
+ return this.runAction(() => dismissKeyboard(this.sessionPath, this.platform));
335018
+ }
334839
335019
  /** System Home — backgrounds the app only (use `relaunch` for hermetic reset). */
334840
335020
  async home() {
334841
335021
  return this.runAction(() => pressHome(this.sessionPath, this.platform));
@@ -335221,8 +335401,12 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
335221
335401
  pageUrl: call.pageUrl ?? pageUrl
335222
335402
  }));
335223
335403
  }
335404
+ var runnerPackage = require_package4();
335224
335405
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
335225
335406
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
335407
+ var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
335408
+ var RUNNER_VERSION = runnerPackage.version ?? "0.0.0";
335409
+ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION;
335226
335410
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
335227
335411
  var DEFAULT_TYPE_LIMITS = {
335228
335412
  discovery: IS_CLOUD ? 2 : 15,
@@ -338056,6 +338240,9 @@ async function startRunner(opts) {
338056
338240
  method: "POST",
338057
338241
  headers,
338058
338242
  body: JSON.stringify({
338243
+ runnerName: RUNNER_NAME,
338244
+ runnerVersion: RUNNER_VERSION,
338245
+ runnerBuildId: RUNNER_BUILD_ID,
338059
338246
  runsCompleted,
338060
338247
  activeRuns,
338061
338248
  maxConcurrent: MAX_CONCURRENT,
package/dist/cli.mjs CHANGED
@@ -5006,6 +5006,29 @@ var require_mobile_observation = __commonJS({
5006
5006
  }
5007
5007
  });
5008
5008
 
5009
+ // ../runner-core/dist/services/mobile/mobile-keyboard.js
5010
+ var require_mobile_keyboard = __commonJS({
5011
+ "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports) {
5012
+ "use strict";
5013
+ Object.defineProperty(exports, "__esModule", { value: true });
5014
+ exports.shouldRetryTapAfterKeyboardDismiss = shouldRetryTapAfterKeyboardDismiss;
5015
+ exports.dismissKeyboardForRetry = dismissKeyboardForRetry;
5016
+ var EDITABLE_FOCUSED_ROLE_RE = /(EditText|TextField|SecureTextField|SearchField|TextView)$/i;
5017
+ var OUTSIDE_VIEWPORT_ERROR_RE = /(outside|out of).*?(bounds|viewport|visible|screen|window)|coordinate.*?(invalid|outside)/i;
5018
+ function shouldRetryTapAfterKeyboardDismiss(error2, elements) {
5019
+ if (!error2 || !OUTSIDE_VIEWPORT_ERROR_RE.test(error2))
5020
+ return false;
5021
+ return elements.some((el) => el.focused === true && EDITABLE_FOCUSED_ROLE_RE.test(el.role));
5022
+ }
5023
+ async function dismissKeyboardForRetry(driver) {
5024
+ if (typeof driver.dismissKeyboard === "function") {
5025
+ return driver.dismissKeyboard();
5026
+ }
5027
+ return driver.back();
5028
+ }
5029
+ }
5030
+ });
5031
+
5009
5032
  // ../runner-core/dist/utils/scrub-credentials.js
5010
5033
  var require_scrub_credentials = __commonJS({
5011
5034
  "../runner-core/dist/utils/scrub-credentials.js"(exports) {
@@ -61987,6 +62010,7 @@ var require_mobile_explorer = __commonJS({
61987
62010
  var mobile_source_parser_js_1 = require_mobile_source_parser();
61988
62011
  var mobile_observation_js_1 = require_mobile_observation();
61989
62012
  var mobile_boundary_js_1 = require_mobile_boundary();
62013
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
61990
62014
  var credential_tools_js_1 = require_credential_tools();
61991
62015
  var verification_inbox_js_1 = require_verification_inbox();
61992
62016
  var inbox_tools_js_1 = require_inbox_tools();
@@ -61996,6 +62020,7 @@ var require_mobile_explorer = __commonJS({
61996
62020
  var MAX_RECENT_EXCHANGES = 24;
61997
62021
  var OBSERVATION_MAX_ELEMENTS = 50;
61998
62022
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
62023
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
61999
62024
  var MOBILE_SNAPSHOT_TOOL = {
62000
62025
  type: "function",
62001
62026
  function: {
@@ -62561,9 +62586,46 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62561
62586
  externalOwner: boundary.owner
62562
62587
  };
62563
62588
  }
62564
- log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
62589
+ let lastExternalOwner = recoveredBoundary.owner;
62590
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
62591
+ try {
62592
+ await driver.launchApp();
62593
+ const retrySnap = await driver.snapshot();
62594
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.windowSize, retrySnap.screenSignal);
62595
+ if (!retryRecovered)
62596
+ continue;
62597
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
62598
+ latest = retryRecovered;
62599
+ lastSnapshotIteration = iterationForObservation;
62600
+ if (retryBoundary.kind === "target" || retryBoundary.kind === "unknown") {
62601
+ const screenId = ingestSnapshot(retryRecovered, iterationForObservation);
62602
+ captureScreenshot(retrySnap.screenshot, screenId);
62603
+ log2(` Recovered target app after ${attempt} additional activation attempt(s).`);
62604
+ return {
62605
+ observation: retryRecovered.observation,
62606
+ screenId,
62607
+ externalBlocked: true,
62608
+ externalOwner: boundary.owner
62609
+ };
62610
+ }
62611
+ if (retryBoundary.kind === "transient_external") {
62612
+ log2(` Recovery surfaced system overlay ${retryBoundary.owner}; not adding it to target-app survey evidence.`);
62613
+ return {
62614
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
62615
+ screenId: null,
62616
+ externalBlocked: true,
62617
+ transientExternal: true,
62618
+ externalOwner: boundary.owner
62619
+ };
62620
+ }
62621
+ lastExternalOwner = retryBoundary.owner;
62622
+ } catch (err) {
62623
+ log2(` Target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
62624
+ }
62625
+ }
62626
+ log2(` Relaunch still outside target app (${lastExternalOwner}); no screen recorded.`);
62565
62627
  return {
62566
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
62628
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no screen was recorded)`,
62567
62629
  screenId: null,
62568
62630
  externalBlocked: true,
62569
62631
  externalOwner: boundary.owner
@@ -62941,7 +63003,19 @@ Exploration complete: ${summary}`);
62941
63003
  const fromScreenId = state2.getCurrentScreenId();
62942
63004
  const boundaryBeforeAction = currentBoundary();
62943
63005
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
62944
- const actionResult = await driver.tap(resolved.bounds);
63006
+ let actionResult = await driver.tap(resolved.bounds);
63007
+ if (!actionResult.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(actionResult.error, latest()?.screen.elements ?? [])) {
63008
+ log2(" Tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
63009
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
63010
+ const retryResult = await driver.tap(resolved.bounds);
63011
+ actionResult = retryResult.ok ? retryResult : {
63012
+ ...retryResult,
63013
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
63014
+ source: retryResult.source ?? dismissResult.source,
63015
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
63016
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
63017
+ };
63018
+ }
62945
63019
  const absorbed = await absorbActionResult(actionResult, "mobile_tap");
62946
63020
  const { observation, screenId } = absorbed;
62947
63021
  if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
@@ -65941,6 +66015,25 @@ var require_mobile_discovery_prompts = __commonJS({
65941
66015
  function targetAppScreens(graph, appId) {
65942
66016
  return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
65943
66017
  }
66018
+ function isGenericNonFeatureScreen(state2) {
66019
+ const type = state2.screenType?.trim().toLowerCase();
66020
+ if (type === "error" || type === "permission")
66021
+ return true;
66022
+ if (type && type !== "unknown")
66023
+ return false;
66024
+ const actionableCount = state2.elements.filter((el) => el.actionable).length;
66025
+ if (actionableCount > 0)
66026
+ return false;
66027
+ const evidenceText = [
66028
+ state2.screenName,
66029
+ state2.screenType,
66030
+ ...state2.observations ?? []
66031
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ");
66032
+ return /\b(empty|loading|blank|spinner|intermediate|placeholder|no actionable)\b/i.test(evidenceText);
66033
+ }
66034
+ function plannableTargetAppScreens(graph, appId) {
66035
+ return targetAppScreens(graph, appId).filter((state2) => !isGenericNonFeatureScreen(state2));
66036
+ }
65944
66037
  exports.MOBILE_SURVEY_SYSTEM_PROMPT = `You are a feature analyst for a native mobile application (iOS or Android). You receive evidence from an AI explorer that mapped the app's screens by tapping, typing, and swiping through the live UI. Your job is to organize screens into meaningful feature groups.
65945
66038
 
65946
66039
  ## Output Discipline
@@ -65960,8 +66053,8 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65960
66053
  5. CRUD screens for the same entity belong together
65961
66054
  6. Do NOT merge screens into one feature just because they serve a similar broader goal. Treat areas as separate features when the evidence shows separate primary navigation destinations (e.g. separate tab-bar items or drawer links), separate screen families for distinct entities, or different CRUD entities/forms. Keep screens together when they share one navigational area and operate on the same entity (e.g. a list screen, its filter/sort sheet, and its detail screen all belong together).
65962
66055
  7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
65963
- 8. Do NOT create standalone features for generic error / empty screens (network-error, permission-denied). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description.
65964
- 9. COVER EVERY SCREEN: every explored screen except generic error / permission screens MUST appear in exactly one feature group's routes. Do not leave screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
66056
+ 8. Do NOT create standalone features for generic error / empty / loading / intermediate screens (network-error, permission-denied, blank WebView, spinner-only loading). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description. If a screen has no actionable controls and only says it is empty/loading/intermediate, omit it from featureGroups.routes unless it is the only evidence for a real product feature.
66057
+ 9. COVER EVERY PRODUCT SCREEN: every explored product screen except generic error / permission / empty / loading / intermediate screens MUST appear in exactly one feature group's routes. Do not leave usable screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous usable screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
65965
66058
  10. LOGGED-OUT vs LOGGED-IN SEPARATION: screens shown BEFORE the user is signed in (splash, landing/welcome, theme or language pickers, walkthroughs, sign-up, sign-in, forgot-password, demo/preview screens) must NEVER share a feature group with the signed-in app. Put sign-in / sign-up / forgot-password in "Authentication" (rule 4) and the remaining pre-login screens in an "Onboarding" group. Features named after signed-in areas (feeds, dashboards, tabs, settings) must contain ONLY screens reached after authentication. A pre-login flow is a linear sequence, not a navigation destination \u2014 do not let it swallow the app's main features.
65966
66059
 
65967
66060
  ## Output JSON Schema
@@ -66228,7 +66321,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
66228
66321
  sections.push(`## Feature: ${context.featureName}
66229
66322
  SCOPE: this is a feature-focused deep run. Plan scenarios ONLY for the "${context.featureName}" feature \u2014 its screens, forms, and flows. Screens belonging to other features are navigation context / prerequisites only; do NOT plan scenarios whose primary goal exercises another feature.`);
66230
66323
  }
66231
- const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66324
+ const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66232
66325
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66233
66326
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
66234
66327
  for (const state2 of orderedScreens) {
@@ -66337,7 +66430,7 @@ ${constraints.join("\n")}`);
66337
66430
  const now = /* @__PURE__ */ new Date();
66338
66431
  const idByScreen = /* @__PURE__ */ new Map();
66339
66432
  let pageSeq = 0;
66340
- const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66433
+ const orderedScreens = plannableTargetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66341
66434
  for (const state2 of orderedScreens) {
66342
66435
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
66343
66436
  }
@@ -66398,7 +66491,7 @@ ${constraints.join("\n")}`);
66398
66491
  }
66399
66492
  function buildMobileFeatureGroupsFromGraph(graph, appId) {
66400
66493
  const groups = /* @__PURE__ */ new Map();
66401
- const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66494
+ const orderedScreens = plannableTargetAppScreens(graph, appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66402
66495
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
66403
66496
  for (const state2 of orderedScreens) {
66404
66497
  const screenTypeLower = state2.screenType?.toLowerCase();
@@ -66445,7 +66538,7 @@ ${constraints.join("\n")}`);
66445
66538
  return [];
66446
66539
  if (input.featureName?.trim())
66447
66540
  return [];
66448
- const orderedScreens = targetAppScreens(input.graph, input.appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66541
+ const orderedScreens = plannableTargetAppScreens(input.graph, input.appId).filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66449
66542
  if (orderedScreens.length === 0)
66450
66543
  return [];
66451
66544
  const entryScreenId = orderedScreens[0].screenId;
@@ -66647,9 +66740,11 @@ var require_mobile_generator = __commonJS({
66647
66740
  var mobile_source_parser_js_2 = require_mobile_source_parser();
66648
66741
  var mobile_observation_js_1 = require_mobile_observation();
66649
66742
  var mobile_boundary_js_1 = require_mobile_boundary();
66743
+ var mobile_keyboard_js_1 = require_mobile_keyboard();
66650
66744
  var MAX_SCENARIO_ITERATIONS = 30;
66651
66745
  var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
66652
66746
  var MAX_RECENT_EXCHANGES = 24;
66747
+ var EXTERNAL_APP_RECOVERY_ATTEMPTS = 2;
66653
66748
  var OBSERVATION_MAX_ELEMENTS = 50;
66654
66749
  function finalizeAction(input) {
66655
66750
  return { ...input, url: "", selector: input.selector ?? "" };
@@ -67065,10 +67160,47 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
67065
67160
  externalOwner: boundary.owner
67066
67161
  };
67067
67162
  }
67068
- log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
67163
+ let lastExternalOwner = recoveredBoundary.owner;
67164
+ let lastRecovered = recovered;
67165
+ for (let attempt = 1; attempt <= EXTERNAL_APP_RECOVERY_ATTEMPTS; attempt += 1) {
67166
+ try {
67167
+ await driver.launchApp();
67168
+ const retrySnap = await driver.snapshot();
67169
+ const retryRecovered = buildResolved(retrySnap.xml, retrySnap.screenSignal, retrySnap.windowSize);
67170
+ if (!retryRecovered)
67171
+ continue;
67172
+ lastRecovered = retryRecovered;
67173
+ const retryBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(retryRecovered.signal, targetAppId, platform3);
67174
+ setLatest(retryRecovered);
67175
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(retryBoundary)) {
67176
+ emitScreenshot(retrySnap.screenshot);
67177
+ log2(` recovered target app after ${attempt} additional activation attempt(s).`);
67178
+ return {
67179
+ resolved: retryRecovered,
67180
+ observation: retryRecovered.observation,
67181
+ externalBlocked: true,
67182
+ externalOwner: boundary.owner
67183
+ };
67184
+ }
67185
+ if (retryBoundary.kind === "transient_external") {
67186
+ log2(` recovery surfaced system overlay ${retryBoundary.owner}; not recording it as a target-app step.`);
67187
+ return {
67188
+ resolved: retryRecovered,
67189
+ observation: `${retryRecovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(retryBoundary)}`,
67190
+ externalBlocked: true,
67191
+ transientExternal: true,
67192
+ externalOwner: boundary.owner
67193
+ };
67194
+ }
67195
+ lastExternalOwner = retryBoundary.owner;
67196
+ } catch (err) {
67197
+ log2(` target app recovery attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
67198
+ }
67199
+ }
67200
+ log2(` relaunch still outside target app (${lastExternalOwner}); no target-app step recorded.`);
67069
67201
  return {
67070
- resolved: recovered,
67071
- observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
67202
+ resolved: lastRecovered,
67203
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${lastExternalOwner}, so no target-app step was recorded)`,
67072
67204
  externalBlocked: true,
67073
67205
  externalOwner: boundary.owner
67074
67206
  };
@@ -67282,7 +67414,19 @@ ${statePacket}` },
67282
67414
  return unknownRef(toolArgs.ref);
67283
67415
  const beforeScreenName = screenName();
67284
67416
  const before = latest();
67285
- const result = await driver.tap(element.bounds);
67417
+ let result = await driver.tap(element.bounds);
67418
+ if (!result.ok && (0, mobile_keyboard_js_1.shouldRetryTapAfterKeyboardDismiss)(result.error, before?.screen.elements ?? [])) {
67419
+ log2(" tap target was outside the visible viewport while an input was focused; dismissing keyboard and retrying once.");
67420
+ const dismissResult = await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
67421
+ const retryResult = await driver.tap(element.bounds);
67422
+ result = retryResult.ok ? retryResult : {
67423
+ ...retryResult,
67424
+ error: retryResult.error ? `${retryResult.error} (retry after keyboard dismiss also failed)` : "Tap retry after keyboard dismiss failed",
67425
+ source: retryResult.source ?? dismissResult.source,
67426
+ screenshot: retryResult.screenshot ?? dismissResult.screenshot,
67427
+ screenSignal: retryResult.screenSignal ?? dismissResult.screenSignal
67428
+ };
67429
+ }
67286
67430
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
67287
67431
  if (shouldRecordAction(result, absorbed) && changedScreen(before, absorbed)) {
67288
67432
  recordAction({
@@ -315417,8 +315561,8 @@ var require_mobile_discovery_agent = __commonJS({
315417
315561
  };
315418
315562
  }
315419
315563
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
315420
- const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
315421
- log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315564
+ const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", mode, log2);
315565
+ log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : mode === "survey" && (target?.resetPolicy ?? "AUTO") === "AUTO" ? "App data reset skipped for mobile survey AUTO \u2014 exploring with preserved app data. Use resetPolicy=CLEAR_APP_DATA for a cold logged-out survey." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
315422
315566
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
315423
315567
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
315424
315568
  const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
@@ -315660,10 +315804,11 @@ Fatal error: ${msg}`);
315660
315804
  };
315661
315805
  }
315662
315806
  }
315663
- async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
315807
+ async function prepareMobileAppStateForDiscovery(driver, target, phase, mode, log2) {
315664
315808
  void phase;
315665
315809
  const resetPolicy = target?.resetPolicy ?? "AUTO";
315666
- if (resetPolicy === "PRESERVE_APP_DATA") {
315810
+ const shouldPreserveAppData = resetPolicy === "PRESERVE_APP_DATA" || mode === "survey" && resetPolicy === "AUTO";
315811
+ if (shouldPreserveAppData) {
315667
315812
  try {
315668
315813
  await driver.relaunch();
315669
315814
  } catch (err) {
@@ -330184,7 +330329,7 @@ var require_package4 = __commonJS({
330184
330329
  "package.json"(exports, module) {
330185
330330
  module.exports = {
330186
330331
  name: "@validate.qa/runner",
330187
- version: "1.0.31",
330332
+ version: "1.0.32",
330188
330333
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
330189
330334
  bin: {
330190
330335
  "validate-runner": "dist/cli.js"
@@ -330848,6 +330993,37 @@ async function swipe(sessionPath, direction, distance, windowSize) {
330848
330993
  async function pressBack(sessionPath) {
330849
330994
  await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
330850
330995
  }
330996
+ function isKeyboardAlreadyHiddenError(err) {
330997
+ const message = err instanceof Error ? err.message : String(err);
330998
+ return /keyboard.*(not|already).*(shown|visible|present|open)|no.*keyboard/i.test(message);
330999
+ }
331000
+ async function dismissKeyboard(sessionPath, platform3) {
331001
+ if (platform3 === "ANDROID") {
331002
+ await pressBack(sessionPath);
331003
+ return;
331004
+ }
331005
+ try {
331006
+ await appiumRequest(`${sessionPath}/appium/device/hide_keyboard`, {
331007
+ method: "POST",
331008
+ body: "{}"
331009
+ });
331010
+ return;
331011
+ } catch (err) {
331012
+ if (isAppiumDriverDeadError(err)) throw err;
331013
+ if (isKeyboardAlreadyHiddenError(err)) return;
331014
+ }
331015
+ try {
331016
+ await appiumRequest(`${sessionPath}/execute/sync`, {
331017
+ method: "POST",
331018
+ body: JSON.stringify({ script: "mobile: hideKeyboard", args: [{}] })
331019
+ });
331020
+ return;
331021
+ } catch (err) {
331022
+ if (isAppiumDriverDeadError(err)) throw err;
331023
+ if (isKeyboardAlreadyHiddenError(err)) return;
331024
+ }
331025
+ await pressBack(sessionPath);
331026
+ }
330851
331027
  async function pressHome(sessionPath, platform3) {
330852
331028
  if (platform3 === "IOS") {
330853
331029
  await appiumRequest(`${sessionPath}/execute/sync`, {
@@ -334844,6 +335020,10 @@ var MobileBridgeDriver = class {
334844
335020
  async back() {
334845
335021
  return this.runAction(() => pressBack(this.sessionPath));
334846
335022
  }
335023
+ /** Platform-aware software keyboard dismissal. */
335024
+ async dismissKeyboard() {
335025
+ return this.runAction(() => dismissKeyboard(this.sessionPath, this.platform));
335026
+ }
334847
335027
  /** System Home — backgrounds the app only (use `relaunch` for hermetic reset). */
334848
335028
  async home() {
334849
335029
  return this.runAction(() => pressHome(this.sessionPath, this.platform));
@@ -335229,8 +335409,12 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
335229
335409
  pageUrl: call.pageUrl ?? pageUrl
335230
335410
  }));
335231
335411
  }
335412
+ var runnerPackage = require_package4();
335232
335413
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
335233
335414
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
335415
+ var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
335416
+ var RUNNER_VERSION = runnerPackage.version ?? "0.0.0";
335417
+ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION;
335234
335418
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
335235
335419
  var DEFAULT_TYPE_LIMITS = {
335236
335420
  discovery: IS_CLOUD ? 2 : 15,
@@ -338064,6 +338248,9 @@ async function startRunner(opts) {
338064
338248
  method: "POST",
338065
338249
  headers,
338066
338250
  body: JSON.stringify({
338251
+ runnerName: RUNNER_NAME,
338252
+ runnerVersion: RUNNER_VERSION,
338253
+ runnerBuildId: RUNNER_BUILD_ID,
338067
338254
  runsCompleted,
338068
338255
  activeRuns,
338069
338256
  maxConcurrent: MAX_CONCURRENT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.31",
3
+ "version": "1.0.32",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"