@validate.qa/runner 1.0.40 → 1.0.42

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 +239 -15
  2. package/dist/cli.mjs +239 -15
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1583,6 +1583,9 @@ var init_types = __esm({
1583
1583
  });
1584
1584
 
1585
1585
  // ../shared/dist/constants.js
1586
+ function isEmailFromIdentity(value) {
1587
+ return typeof value === "string" && EMAIL_FROM_IDENTITIES.includes(value);
1588
+ }
1586
1589
  function isValidMobileAppId(appId) {
1587
1590
  return appId.length > 0 && appId.length <= MOBILE_APP_ID_MAX_LENGTH && MOBILE_APP_ID_PATTERN.test(appId);
1588
1591
  }
@@ -1641,13 +1644,22 @@ function escapeEnvValue(value) {
1641
1644
  const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
1642
1645
  return `"${escaped}"`;
1643
1646
  }
1644
- var API_PREFIX, SUPPORT_EMAIL, PLATFORM_ADMIN_EMAIL, AUDIO_CHUNK_DURATION_MS, ACTION_BATCH_INTERVAL_MS, ACTION_BATCH_MAX_SIZE, MOBILE_SCREENSHOT_INTERVAL_MS, MOBILE_ACTION_BATCH_MAX_SIZE, MOBILE_BRIDGE_PORT, MOBILE_SNAPSHOT_MAX_SIZE_BYTES, MOBILE_APP_ID_MAX_LENGTH, MOBILE_APP_ID_PATTERN, MOBILE_FOCUS_SETTLE_MS, MOBILE_POST_ACTION_SETTLE_MS, MOBILE_COLD_BOOT_SETTLE_MS, MOBILE_DEFAULT_SWIPE_DISTANCE, MOBILE_MAX_SWIPE_DISTANCE, SELECTOR_PRIORITIES, MOBILE_STEP_TYPES, BLOCKED_ENV_VARS, SPAWNED_ENV_ALLOWLIST;
1647
+ var API_PREFIX, SUPPORT_EMAIL, PLATFORM_ADMIN_EMAIL, NOREPLY_EMAIL, EMAIL_FROM_IDENTITIES, ADMIN_EMAIL_LIMITS, AUDIO_CHUNK_DURATION_MS, ACTION_BATCH_INTERVAL_MS, ACTION_BATCH_MAX_SIZE, MOBILE_SCREENSHOT_INTERVAL_MS, MOBILE_ACTION_BATCH_MAX_SIZE, MOBILE_BRIDGE_PORT, MOBILE_SNAPSHOT_MAX_SIZE_BYTES, MOBILE_APP_ID_MAX_LENGTH, MOBILE_APP_ID_PATTERN, MOBILE_FOCUS_SETTLE_MS, MOBILE_POST_ACTION_SETTLE_MS, MOBILE_COLD_BOOT_SETTLE_MS, MOBILE_DEFAULT_SWIPE_DISTANCE, MOBILE_MAX_SWIPE_DISTANCE, SELECTOR_PRIORITIES, MOBILE_STEP_TYPES, BLOCKED_ENV_VARS, SPAWNED_ENV_ALLOWLIST;
1645
1648
  var init_constants = __esm({
1646
1649
  "../shared/dist/constants.js"() {
1647
1650
  "use strict";
1648
1651
  API_PREFIX = "/api";
1649
1652
  SUPPORT_EMAIL = "support@validate.qa";
1650
1653
  PLATFORM_ADMIN_EMAIL = "admin@validate.qa";
1654
+ NOREPLY_EMAIL = "noreply@validate.qa";
1655
+ EMAIL_FROM_IDENTITIES = ["noreply", "support", "admin"];
1656
+ ADMIN_EMAIL_LIMITS = {
1657
+ recipientsPerSend: 100,
1658
+ recipientNameLength: 200,
1659
+ subjectLength: 200,
1660
+ bodyLength: 5e4,
1661
+ templateNameLength: 120
1662
+ };
1651
1663
  AUDIO_CHUNK_DURATION_MS = 3e4;
1652
1664
  ACTION_BATCH_INTERVAL_MS = 2e3;
1653
1665
  ACTION_BATCH_MAX_SIZE = 10;
@@ -1786,6 +1798,83 @@ var init_constants = __esm({
1786
1798
  }
1787
1799
  });
1788
1800
 
1801
+ // ../shared/dist/project-key.js
1802
+ function isValidProjectKey(key) {
1803
+ return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
1804
+ }
1805
+ function randomChar(rng) {
1806
+ return KEY_ALPHABET[Math.floor(rng() * KEY_ALPHABET.length)] ?? "0";
1807
+ }
1808
+ function randomKey(length, rng) {
1809
+ let out = "";
1810
+ for (let i = 0; i < length; i++)
1811
+ out += randomChar(rng);
1812
+ return out;
1813
+ }
1814
+ function alnumLower(name) {
1815
+ return (name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
1816
+ }
1817
+ function deriveProjectKey(name, rng = Math.random) {
1818
+ const base2 = alnumLower(name).slice(0, 3);
1819
+ return base2.length >= PROJECT_KEY_MIN_LENGTH ? base2 : randomKey(3, rng);
1820
+ }
1821
+ function generateUniqueProjectKey(name, taken, rng = Math.random) {
1822
+ const isFree = (k) => isValidProjectKey(k) && !taken.has(k);
1823
+ const primary = deriveProjectKey(name, rng);
1824
+ if (isFree(primary))
1825
+ return primary;
1826
+ const prefix2 = alnumLower(name).slice(0, 2);
1827
+ for (const suffixLen of [1, 2]) {
1828
+ for (let attempt = 0; attempt < 40; attempt++) {
1829
+ const stem = prefix2.length >= 1 ? prefix2 : deriveProjectKey(name, rng).slice(0, 2);
1830
+ const candidate = stem + randomKey(suffixLen, rng);
1831
+ if (isFree(candidate))
1832
+ return candidate;
1833
+ }
1834
+ }
1835
+ for (let len = 4; len <= PROJECT_KEY_MAX_LENGTH; len++) {
1836
+ for (let attempt = 0; attempt < 100; attempt++) {
1837
+ const candidate = randomKey(len, rng);
1838
+ if (isFree(candidate))
1839
+ return candidate;
1840
+ }
1841
+ }
1842
+ throw new Error("Unable to generate a unique project key");
1843
+ }
1844
+ var RESERVED_PROJECT_KEYS, PROJECT_KEY_MIN_LENGTH, PROJECT_KEY_MAX_LENGTH, PROJECT_KEY_PATTERN, KEY_ALPHABET;
1845
+ var init_project_key = __esm({
1846
+ "../shared/dist/project-key.js"() {
1847
+ "use strict";
1848
+ RESERVED_PROJECT_KEYS = /* @__PURE__ */ new Set([
1849
+ "tests",
1850
+ "sessions",
1851
+ "settings",
1852
+ "reports",
1853
+ "account",
1854
+ "notifications",
1855
+ "support",
1856
+ "site-map",
1857
+ "api-intelligence",
1858
+ "selector-registry",
1859
+ "project-knowledge",
1860
+ "test-data",
1861
+ "audit",
1862
+ "validate-copilot",
1863
+ "qa-copilot",
1864
+ "insights",
1865
+ "regression",
1866
+ "reviews",
1867
+ "admin",
1868
+ "login",
1869
+ "admin-view"
1870
+ ]);
1871
+ PROJECT_KEY_MIN_LENGTH = 2;
1872
+ PROJECT_KEY_MAX_LENGTH = 12;
1873
+ PROJECT_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,10}[a-z0-9])$/;
1874
+ KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1875
+ }
1876
+ });
1877
+
1789
1878
  // ../shared/dist/mobile-deeplink.js
1790
1879
  function isSafeDeepLink(value) {
1791
1880
  if (typeof value !== "string")
@@ -4941,6 +5030,7 @@ __export(dist_exports, {
4941
5030
  ACTION_BATCH_INTERVAL_MS: () => ACTION_BATCH_INTERVAL_MS,
4942
5031
  ACTION_BATCH_MAX_SIZE: () => ACTION_BATCH_MAX_SIZE,
4943
5032
  ADDON_DEFINITIONS: () => ADDON_DEFINITIONS,
5033
+ ADMIN_EMAIL_LIMITS: () => ADMIN_EMAIL_LIMITS,
4944
5034
  AI_CREDIT_MARGIN: () => AI_CREDIT_MARGIN,
4945
5035
  API_PREFIX: () => API_PREFIX,
4946
5036
  AUDIO_CHUNK_DURATION_MS: () => AUDIO_CHUNK_DURATION_MS,
@@ -4954,6 +5044,7 @@ __export(dist_exports, {
4954
5044
  DEFAULT_TTS_SPEED: () => DEFAULT_TTS_SPEED,
4955
5045
  DEFAULT_TTS_VOICE_ID: () => DEFAULT_TTS_VOICE_ID,
4956
5046
  DEFAULT_UI_VARIANT: () => DEFAULT_UI_VARIANT,
5047
+ EMAIL_FROM_IDENTITIES: () => EMAIL_FROM_IDENTITIES,
4957
5048
  ENVIRONMENTAL_ERROR_PREFIXES: () => ENVIRONMENTAL_ERROR_PREFIXES,
4958
5049
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
4959
5050
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
@@ -4972,9 +5063,14 @@ __export(dist_exports, {
4972
5063
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
4973
5064
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
4974
5065
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
5066
+ NOREPLY_EMAIL: () => NOREPLY_EMAIL,
4975
5067
  PLAN_DEFINITIONS: () => PLAN_DEFINITIONS,
4976
5068
  PLAN_ORDER: () => PLAN_ORDER,
4977
5069
  PLATFORM_ADMIN_EMAIL: () => PLATFORM_ADMIN_EMAIL,
5070
+ PROJECT_KEY_MAX_LENGTH: () => PROJECT_KEY_MAX_LENGTH,
5071
+ PROJECT_KEY_MIN_LENGTH: () => PROJECT_KEY_MIN_LENGTH,
5072
+ PROJECT_KEY_PATTERN: () => PROJECT_KEY_PATTERN,
5073
+ RESERVED_PROJECT_KEYS: () => RESERVED_PROJECT_KEYS,
4978
5074
  SELECTOR_PRIORITIES: () => SELECTOR_PRIORITIES,
4979
5075
  SHARED_PRICING_PLAN_MAP: () => SHARED_PRICING_PLAN_MAP,
4980
5076
  STT_MODEL_OPTIONS: () => STT_MODEL_OPTIONS,
@@ -5008,6 +5104,7 @@ __export(dist_exports, {
5008
5104
  controlSignature: () => controlSignature,
5009
5105
  countInnerTests: () => countInnerTests,
5010
5106
  decideHarPosted: () => decideHarPosted,
5107
+ deriveProjectKey: () => deriveProjectKey,
5011
5108
  detectCrossOriginHtmlAsJson: () => detectCrossOriginHtmlAsJson,
5012
5109
  detectsErrorBannerAssertion: () => detectsErrorBannerAssertion,
5013
5110
  effectiveTestCount: () => effectiveTestCount,
@@ -5024,6 +5121,7 @@ __export(dist_exports, {
5024
5121
  formatLimit: () => formatLimit,
5025
5122
  fromDbPricingPlan: () => fromDbPricingPlan,
5026
5123
  generateMobileTestFromSession: () => generateMobileTestFromSession,
5124
+ generateUniqueProjectKey: () => generateUniqueProjectKey,
5027
5125
  getDefaultPlanForTier: () => getDefaultPlanForTier,
5028
5126
  getDeploymentTierForPlan: () => getDeploymentTierForPlan,
5029
5127
  getPlanChangeType: () => getPlanChangeType,
@@ -5042,6 +5140,7 @@ __export(dist_exports, {
5042
5140
  isCreditPackAmountCents: () => isCreditPackAmountCents,
5043
5141
  isDbPricingPlan: () => isDbPricingPlan,
5044
5142
  isEditableMobileClassName: () => isEditableMobileClassName,
5143
+ isEmailFromIdentity: () => isEmailFromIdentity,
5045
5144
  isEnvironmentalError: () => isEnvironmentalError,
5046
5145
  isMobilePlatform: () => isMobilePlatform,
5047
5146
  isPricingPlan: () => isPricingPlan,
@@ -5053,6 +5152,7 @@ __export(dist_exports, {
5053
5152
  isTrialExpired: () => isTrialExpired,
5054
5153
  isUIVariant: () => isUIVariant,
5055
5154
  isValidMobileAppId: () => isValidMobileAppId,
5155
+ isValidProjectKey: () => isValidProjectKey,
5056
5156
  isValidSttProvider: () => isValidSttProvider,
5057
5157
  isValidTtsModel: () => isValidTtsModel,
5058
5158
  isValidTtsVoiceId: () => isValidTtsVoiceId,
@@ -5098,6 +5198,7 @@ var init_dist = __esm({
5098
5198
  "use strict";
5099
5199
  init_types();
5100
5200
  init_constants();
5201
+ init_project_key();
5101
5202
  init_mobile_test_generation();
5102
5203
  init_mobile_step_healer();
5103
5204
  init_mobile_cross_platform();
@@ -5888,17 +5989,27 @@ var require_mobile_session_keepalive = __commonJS({
5888
5989
  "use strict";
5889
5990
  Object.defineProperty(exports2, "__esModule", { value: true });
5890
5991
  exports2.MOBILE_KEEPALIVE_INTERVAL_MS = void 0;
5992
+ exports2.computeKeepAliveIntervalMs = computeKeepAliveIntervalMs;
5891
5993
  exports2.withMobileSessionKeepAlive = withMobileSessionKeepAlive;
5892
- exports2.MOBILE_KEEPALIVE_INTERVAL_MS = (() => {
5893
- const raw = Number(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS);
5894
- return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 12e4;
5895
- })();
5994
+ function computeKeepAliveIntervalMs(keepaliveMsRaw, newCommandTimeoutSecondsRaw) {
5995
+ const explicit = keepaliveMsRaw?.trim() ? Number(keepaliveMsRaw) : NaN;
5996
+ if (Number.isFinite(explicit) && explicit >= 0)
5997
+ return Math.floor(explicit);
5998
+ const timeoutSeconds = Number(newCommandTimeoutSecondsRaw);
5999
+ const reapWindowMs = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? Math.floor(timeoutSeconds) * 1e3 : 6e5;
6000
+ return Math.min(12e4, Math.max(5e3, Math.floor(reapWindowMs / 3)));
6001
+ }
6002
+ exports2.MOBILE_KEEPALIVE_INTERVAL_MS = computeKeepAliveIntervalMs(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS, process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
5896
6003
  async function withMobileSessionKeepAlive(driver, fn, intervalMs = exports2.MOBILE_KEEPALIVE_INTERVAL_MS) {
5897
6004
  if (intervalMs <= 0)
5898
6005
  return fn();
5899
6006
  const timer = setInterval(() => {
5900
- void driver.windowSize().catch(() => {
5901
- });
6007
+ try {
6008
+ const probe = driver.keepAlivePing ? driver.keepAlivePing() : driver.windowSize();
6009
+ void probe.catch(() => {
6010
+ });
6011
+ } catch {
6012
+ }
5902
6013
  }, intervalMs);
5903
6014
  timer.unref?.();
5904
6015
  try {
@@ -73993,6 +74104,7 @@ var require_mobile_heal_loop = __commonJS({
73993
74104
  var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
73994
74105
  var mobile_observation_js_1 = require_mobile_observation();
73995
74106
  var selector_registry_context_js_1 = require_selector_registry_context();
74107
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
73996
74108
  exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test. You receive: the test STEPS as JSON, the failure ERROR + step-level results, and the PAGE-SOURCE accessibility tree captured at the moment of failure (this tree is your 'screenshot'), plus optional known-good selector candidates from a registry.
73997
74109
  ## You MUST try to fix the steps
73998
74110
  Reason over steps + error + tree + registry and return working healed steps. Do NOT return needs_device unless you have genuinely exhausted that reasoning (and NEVER before your final attempt).
@@ -74884,6 +74996,19 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74884
74996
  };
74885
74997
  }
74886
74998
  const maxPasses = envInt2("MOBILE_HEAL_PHASE2_MAX_PASSES") ?? DEFAULT_PHASE2_MAX_PASSES;
74999
+ const keepAlivePingable = {
75000
+ ...typeof liveDriver.keepAlivePing === "function" ? { keepAlivePing: liveDriver.keepAlivePing.bind(liveDriver) } : {},
75001
+ // WARNING: this fallback routes through snapshot(), which sits inside
75002
+ // withSessionRecovery on the real driver — a failed tick could consume the
75003
+ // bounded recovery budget, the exact bug keepAlivePing exists to avoid.
75004
+ // Every current MobileLiveDriverLike provides keepAlivePing, so this only
75005
+ // exists for structural compatibility; implement keepAlivePing on any new
75006
+ // facade rather than relying on it.
75007
+ windowSize: async () => {
75008
+ await liveDriver.snapshot();
75009
+ return null;
75010
+ }
75011
+ };
74887
75012
  let baseSteps = args.steps;
74888
75013
  let priorError = args.priorError ?? null;
74889
75014
  let bestSteps;
@@ -74918,10 +75043,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74918
75043
  ];
74919
75044
  let content;
74920
75045
  try {
74921
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
75046
+ const response = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(keepAlivePingable, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
74922
75047
  arm();
74923
75048
  return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
74924
- }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true });
75049
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true }));
74925
75050
  content = response?.choices?.[0]?.message?.content ?? "";
74926
75051
  } catch {
74927
75052
  continue;
@@ -84724,6 +84849,51 @@ var require_discover_generator = __commonJS({
84724
84849
  }
84725
84850
  return s.length;
84726
84851
  }
84852
+ function lineParenDeficit(line) {
84853
+ const stack = [];
84854
+ let regexSkips = 0;
84855
+ for (let i = 0; i < line.length; i++) {
84856
+ const ch = line[i];
84857
+ if (ch === "'" || ch === '"' || ch === "`") {
84858
+ i = skipQuotedLiteral(line, i) - 1;
84859
+ continue;
84860
+ }
84861
+ if (ch === "/" && line[i + 1] === "/") {
84862
+ i = skipLineComment(line, i) - 1;
84863
+ continue;
84864
+ }
84865
+ if (ch === "/" && line[i + 1] === "*") {
84866
+ i = skipBlockComment(line, i) - 1;
84867
+ continue;
84868
+ }
84869
+ if (ch === "/" && isRegexStart(line, i)) {
84870
+ regexSkips++;
84871
+ i = skipRegexLiteral(line, i) - 1;
84872
+ continue;
84873
+ }
84874
+ if (ch === "(" || ch === "{" || ch === "[") {
84875
+ stack.push(ch);
84876
+ continue;
84877
+ }
84878
+ if (ch === ")" || ch === "}" || ch === "]") {
84879
+ if (stack[stack.length - 1] !== CLOSE_TO_OPEN[ch])
84880
+ return { deficit: 0, regexSkips };
84881
+ stack.pop();
84882
+ }
84883
+ }
84884
+ const deficit = stack.length > 0 && stack.every((open) => open === "(") ? stack.length : 0;
84885
+ return { deficit, regexSkips };
84886
+ }
84887
+ function parsesAsAsyncStatement(line) {
84888
+ try {
84889
+ new Function(`return (async () => {
84890
+ ${line}
84891
+ })`);
84892
+ return true;
84893
+ } catch {
84894
+ return false;
84895
+ }
84896
+ }
84727
84897
  function closeUnclosedRegexLiterals(code) {
84728
84898
  const anchors = [
84729
84899
  { rx: /\b(?:waitForURL|toHaveURL)\(\//g, mode: "url" },
@@ -84762,12 +84932,33 @@ var require_discover_generator = __commonJS({
84762
84932
  const term = findRegexTerminator(code, slash, TERMINATORS[mode]);
84763
84933
  out.push(code.slice(cursor, slash));
84764
84934
  const raw = code.slice(slash + 1, term).replace(/\s+$/, "");
84935
+ let closedLiteral;
84765
84936
  if (mode === "url") {
84766
84937
  const escaped = raw.replace(/(?<!\\)\//g, "\\/");
84767
84938
  const finalBody = escaped.startsWith("\\/") ? escaped : `\\/${escaped}`;
84768
- out.push(`/${finalBody}/`);
84939
+ closedLiteral = `/${finalBody}/`;
84769
84940
  } else {
84770
- out.push(`/${raw}/`);
84941
+ closedLiteral = `/${raw}/`;
84942
+ }
84943
+ out.push(closedLiteral);
84944
+ if (code[term] === ")") {
84945
+ const lineStart = code.lastIndexOf("\n", slash) + 1;
84946
+ const nextNewline = code.indexOf("\n", term);
84947
+ const lineEnd = nextNewline === -1 ? code.length : nextNewline;
84948
+ const tail = code.slice(term, lineEnd);
84949
+ const physicalLine = code.slice(lineStart, lineEnd);
84950
+ const lineIsPlain = !physicalLine.includes("`") && !physicalLine.includes("/*") && !physicalLine.includes("*/");
84951
+ const soleAnchorOnLine = !hits.some((h) => h.slash !== slash && h.slash >= lineStart && h.slash < lineEnd);
84952
+ if (lineIsPlain && soleAnchorOnLine && /;\s*$/.test(tail)) {
84953
+ const repairedLine = code.slice(lineStart, slash) + "/x/" + tail;
84954
+ const { deficit, regexSkips } = lineParenDeficit(repairedLine);
84955
+ if (deficit > 0 && deficit <= 3 && regexSkips === 1) {
84956
+ const candidateLine = code.slice(lineStart, slash) + closedLiteral + ")".repeat(deficit) + tail;
84957
+ if (parsesAsAsyncStatement(candidateLine)) {
84958
+ out.push(")".repeat(deficit));
84959
+ }
84960
+ }
84961
+ }
84771
84962
  }
84772
84963
  cursor = term;
84773
84964
  }
@@ -337145,7 +337336,7 @@ var require_package4 = __commonJS({
337145
337336
  "package.json"(exports2, module2) {
337146
337337
  module2.exports = {
337147
337338
  name: "@validate.qa/runner",
337148
- version: "1.0.40",
337339
+ version: "1.0.42",
337149
337340
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
337150
337341
  bin: {
337151
337342
  "validate-runner": "dist/cli.js"
@@ -337154,7 +337345,7 @@ var require_package4 = __commonJS({
337154
337345
  build: "tsup",
337155
337346
  dev: "tsx src/cli.ts",
337156
337347
  test: "node --import tsx --test src/version-check.test.ts",
337157
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
337348
+ "test:mobile": "node --import tsx --test --test-concurrency=2 src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
337158
337349
  prepublishOnly: "npm run build"
337159
337350
  },
337160
337351
  files: [
@@ -343302,6 +343493,17 @@ var MobileBridgeDriver = class {
343302
343493
  return size;
343303
343494
  });
343304
343495
  }
343496
+ /**
343497
+ * Recovery-free liveness probe for keep-alive ticks (mobile-session-keepalive).
343498
+ * Deliberately NOT wrapped in withSessionRecovery: keep-alive ticks are
343499
+ * fire-and-forget, so a failing probe must never consume the bounded recovery
343500
+ * budget or relaunch the app from the background — a genuinely dead session
343501
+ * surfaces (with its precise error) on the caller's next REAL command, which
343502
+ * still runs the full recovery path.
343503
+ */
343504
+ async keepAlivePing() {
343505
+ await this.assertSessionReachable();
343506
+ }
343305
343507
  // ── Action result wrapper ──────────────────────────────
343306
343508
  /**
343307
343509
  * Run an action body, let the UI tree settle, then capture the post-action
@@ -345859,6 +346061,7 @@ ${finalVerifyResult.logs ?? ""}`;
345859
346061
  setExecutorBusy(true);
345860
346062
  let session = null;
345861
346063
  let capture = null;
346064
+ let discoveryTeardownStarted = false;
345862
346065
  try {
345863
346066
  const discoverySessionTarget = {
345864
346067
  appId: mobileTarget.appId,
@@ -345910,6 +346113,9 @@ ${finalVerifyResult.logs ?? ""}`;
345910
346113
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
345911
346114
  maxSessionRecoveries: 1,
345912
346115
  recoverSession: async (cause) => {
346116
+ if (discoveryTeardownStarted) {
346117
+ throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
346118
+ }
345913
346119
  const lost = session;
345914
346120
  mobileOnLog(
345915
346121
  `[appium] discovery session lost (${cause.message}); recreating once on the leased device`
@@ -345923,6 +346129,13 @@ ${finalVerifyResult.logs ?? ""}`;
345923
346129
  }
345924
346130
  }
345925
346131
  const replacement = await openDiscoverySession();
346132
+ if (discoveryTeardownStarted) {
346133
+ try {
346134
+ await replacement.stop();
346135
+ } catch {
346136
+ }
346137
+ throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
346138
+ }
345926
346139
  session = replacement;
345927
346140
  attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
345928
346141
  liveBrowserHandle.patch({
@@ -345962,6 +346175,7 @@ ${finalVerifyResult.logs ?? ""}`;
345962
346175
  onPlanReady
345963
346176
  });
345964
346177
  } finally {
346178
+ discoveryTeardownStarted = true;
345965
346179
  const TEARDOWN_STEP_TIMEOUT = /* @__PURE__ */ Symbol("vqa-teardown-timeout");
345966
346180
  const timeboxed = async (step) => Promise.race([
345967
346181
  step,
@@ -345997,8 +346211,12 @@ ${finalVerifyResult.logs ?? ""}`;
345997
346211
  }
345998
346212
  if (session) {
345999
346213
  detachMirrorSession(session.deviceId);
346214
+ const closing = session;
346000
346215
  try {
346001
- await timeboxed(session.stop());
346216
+ const stopped = await timeboxed(closing.stop());
346217
+ if (stopped === TEARDOWN_STEP_TIMEOUT) {
346218
+ mobileOnLog(`[appium] session ${closing.sessionId} stop timed out after 45s \u2014 releasing device ${closing.deviceId} anyway; Appium may hold the session until newCommandTimeout reaps it`);
346219
+ }
346002
346220
  } catch {
346003
346221
  }
346004
346222
  }
@@ -346436,7 +346654,13 @@ ${finalVerifyResult.logs ?? ""}`;
346436
346654
  relaunch: async () => {
346437
346655
  await currentDriver().relaunch();
346438
346656
  },
346439
- resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState()
346657
+ resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState(),
346658
+ // Recovery-free liveness probe so the loop's keep-alive can span
346659
+ // long AI waits without touching the driver's recovery machinery
346660
+ // (see mobile-session-keepalive.ts).
346661
+ keepAlivePing: async () => {
346662
+ await currentDriver().keepAlivePing();
346663
+ }
346440
346664
  };
346441
346665
  const runNativeWithSessionHandoff = async (verifyArgs) => {
346442
346666
  await closeLiveSession();
package/dist/cli.mjs CHANGED
@@ -1588,6 +1588,9 @@ var init_types = __esm({
1588
1588
  });
1589
1589
 
1590
1590
  // ../shared/dist/constants.js
1591
+ function isEmailFromIdentity(value) {
1592
+ return typeof value === "string" && EMAIL_FROM_IDENTITIES.includes(value);
1593
+ }
1591
1594
  function isValidMobileAppId(appId) {
1592
1595
  return appId.length > 0 && appId.length <= MOBILE_APP_ID_MAX_LENGTH && MOBILE_APP_ID_PATTERN.test(appId);
1593
1596
  }
@@ -1646,13 +1649,22 @@ function escapeEnvValue(value) {
1646
1649
  const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
1647
1650
  return `"${escaped}"`;
1648
1651
  }
1649
- var API_PREFIX, SUPPORT_EMAIL, PLATFORM_ADMIN_EMAIL, AUDIO_CHUNK_DURATION_MS, ACTION_BATCH_INTERVAL_MS, ACTION_BATCH_MAX_SIZE, MOBILE_SCREENSHOT_INTERVAL_MS, MOBILE_ACTION_BATCH_MAX_SIZE, MOBILE_BRIDGE_PORT, MOBILE_SNAPSHOT_MAX_SIZE_BYTES, MOBILE_APP_ID_MAX_LENGTH, MOBILE_APP_ID_PATTERN, MOBILE_FOCUS_SETTLE_MS, MOBILE_POST_ACTION_SETTLE_MS, MOBILE_COLD_BOOT_SETTLE_MS, MOBILE_DEFAULT_SWIPE_DISTANCE, MOBILE_MAX_SWIPE_DISTANCE, SELECTOR_PRIORITIES, MOBILE_STEP_TYPES, BLOCKED_ENV_VARS, SPAWNED_ENV_ALLOWLIST;
1652
+ var API_PREFIX, SUPPORT_EMAIL, PLATFORM_ADMIN_EMAIL, NOREPLY_EMAIL, EMAIL_FROM_IDENTITIES, ADMIN_EMAIL_LIMITS, AUDIO_CHUNK_DURATION_MS, ACTION_BATCH_INTERVAL_MS, ACTION_BATCH_MAX_SIZE, MOBILE_SCREENSHOT_INTERVAL_MS, MOBILE_ACTION_BATCH_MAX_SIZE, MOBILE_BRIDGE_PORT, MOBILE_SNAPSHOT_MAX_SIZE_BYTES, MOBILE_APP_ID_MAX_LENGTH, MOBILE_APP_ID_PATTERN, MOBILE_FOCUS_SETTLE_MS, MOBILE_POST_ACTION_SETTLE_MS, MOBILE_COLD_BOOT_SETTLE_MS, MOBILE_DEFAULT_SWIPE_DISTANCE, MOBILE_MAX_SWIPE_DISTANCE, SELECTOR_PRIORITIES, MOBILE_STEP_TYPES, BLOCKED_ENV_VARS, SPAWNED_ENV_ALLOWLIST;
1650
1653
  var init_constants = __esm({
1651
1654
  "../shared/dist/constants.js"() {
1652
1655
  "use strict";
1653
1656
  API_PREFIX = "/api";
1654
1657
  SUPPORT_EMAIL = "support@validate.qa";
1655
1658
  PLATFORM_ADMIN_EMAIL = "admin@validate.qa";
1659
+ NOREPLY_EMAIL = "noreply@validate.qa";
1660
+ EMAIL_FROM_IDENTITIES = ["noreply", "support", "admin"];
1661
+ ADMIN_EMAIL_LIMITS = {
1662
+ recipientsPerSend: 100,
1663
+ recipientNameLength: 200,
1664
+ subjectLength: 200,
1665
+ bodyLength: 5e4,
1666
+ templateNameLength: 120
1667
+ };
1656
1668
  AUDIO_CHUNK_DURATION_MS = 3e4;
1657
1669
  ACTION_BATCH_INTERVAL_MS = 2e3;
1658
1670
  ACTION_BATCH_MAX_SIZE = 10;
@@ -1791,6 +1803,83 @@ var init_constants = __esm({
1791
1803
  }
1792
1804
  });
1793
1805
 
1806
+ // ../shared/dist/project-key.js
1807
+ function isValidProjectKey(key) {
1808
+ return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
1809
+ }
1810
+ function randomChar(rng) {
1811
+ return KEY_ALPHABET[Math.floor(rng() * KEY_ALPHABET.length)] ?? "0";
1812
+ }
1813
+ function randomKey(length, rng) {
1814
+ let out = "";
1815
+ for (let i = 0; i < length; i++)
1816
+ out += randomChar(rng);
1817
+ return out;
1818
+ }
1819
+ function alnumLower(name) {
1820
+ return (name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
1821
+ }
1822
+ function deriveProjectKey(name, rng = Math.random) {
1823
+ const base2 = alnumLower(name).slice(0, 3);
1824
+ return base2.length >= PROJECT_KEY_MIN_LENGTH ? base2 : randomKey(3, rng);
1825
+ }
1826
+ function generateUniqueProjectKey(name, taken, rng = Math.random) {
1827
+ const isFree = (k) => isValidProjectKey(k) && !taken.has(k);
1828
+ const primary = deriveProjectKey(name, rng);
1829
+ if (isFree(primary))
1830
+ return primary;
1831
+ const prefix2 = alnumLower(name).slice(0, 2);
1832
+ for (const suffixLen of [1, 2]) {
1833
+ for (let attempt = 0; attempt < 40; attempt++) {
1834
+ const stem = prefix2.length >= 1 ? prefix2 : deriveProjectKey(name, rng).slice(0, 2);
1835
+ const candidate = stem + randomKey(suffixLen, rng);
1836
+ if (isFree(candidate))
1837
+ return candidate;
1838
+ }
1839
+ }
1840
+ for (let len = 4; len <= PROJECT_KEY_MAX_LENGTH; len++) {
1841
+ for (let attempt = 0; attempt < 100; attempt++) {
1842
+ const candidate = randomKey(len, rng);
1843
+ if (isFree(candidate))
1844
+ return candidate;
1845
+ }
1846
+ }
1847
+ throw new Error("Unable to generate a unique project key");
1848
+ }
1849
+ var RESERVED_PROJECT_KEYS, PROJECT_KEY_MIN_LENGTH, PROJECT_KEY_MAX_LENGTH, PROJECT_KEY_PATTERN, KEY_ALPHABET;
1850
+ var init_project_key = __esm({
1851
+ "../shared/dist/project-key.js"() {
1852
+ "use strict";
1853
+ RESERVED_PROJECT_KEYS = /* @__PURE__ */ new Set([
1854
+ "tests",
1855
+ "sessions",
1856
+ "settings",
1857
+ "reports",
1858
+ "account",
1859
+ "notifications",
1860
+ "support",
1861
+ "site-map",
1862
+ "api-intelligence",
1863
+ "selector-registry",
1864
+ "project-knowledge",
1865
+ "test-data",
1866
+ "audit",
1867
+ "validate-copilot",
1868
+ "qa-copilot",
1869
+ "insights",
1870
+ "regression",
1871
+ "reviews",
1872
+ "admin",
1873
+ "login",
1874
+ "admin-view"
1875
+ ]);
1876
+ PROJECT_KEY_MIN_LENGTH = 2;
1877
+ PROJECT_KEY_MAX_LENGTH = 12;
1878
+ PROJECT_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,10}[a-z0-9])$/;
1879
+ KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1880
+ }
1881
+ });
1882
+
1794
1883
  // ../shared/dist/mobile-deeplink.js
1795
1884
  function isSafeDeepLink(value) {
1796
1885
  if (typeof value !== "string")
@@ -4946,6 +5035,7 @@ __export(dist_exports, {
4946
5035
  ACTION_BATCH_INTERVAL_MS: () => ACTION_BATCH_INTERVAL_MS,
4947
5036
  ACTION_BATCH_MAX_SIZE: () => ACTION_BATCH_MAX_SIZE,
4948
5037
  ADDON_DEFINITIONS: () => ADDON_DEFINITIONS,
5038
+ ADMIN_EMAIL_LIMITS: () => ADMIN_EMAIL_LIMITS,
4949
5039
  AI_CREDIT_MARGIN: () => AI_CREDIT_MARGIN,
4950
5040
  API_PREFIX: () => API_PREFIX,
4951
5041
  AUDIO_CHUNK_DURATION_MS: () => AUDIO_CHUNK_DURATION_MS,
@@ -4959,6 +5049,7 @@ __export(dist_exports, {
4959
5049
  DEFAULT_TTS_SPEED: () => DEFAULT_TTS_SPEED,
4960
5050
  DEFAULT_TTS_VOICE_ID: () => DEFAULT_TTS_VOICE_ID,
4961
5051
  DEFAULT_UI_VARIANT: () => DEFAULT_UI_VARIANT,
5052
+ EMAIL_FROM_IDENTITIES: () => EMAIL_FROM_IDENTITIES,
4962
5053
  ENVIRONMENTAL_ERROR_PREFIXES: () => ENVIRONMENTAL_ERROR_PREFIXES,
4963
5054
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
4964
5055
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
@@ -4977,9 +5068,14 @@ __export(dist_exports, {
4977
5068
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
4978
5069
  MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
4979
5070
  MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
5071
+ NOREPLY_EMAIL: () => NOREPLY_EMAIL,
4980
5072
  PLAN_DEFINITIONS: () => PLAN_DEFINITIONS,
4981
5073
  PLAN_ORDER: () => PLAN_ORDER,
4982
5074
  PLATFORM_ADMIN_EMAIL: () => PLATFORM_ADMIN_EMAIL,
5075
+ PROJECT_KEY_MAX_LENGTH: () => PROJECT_KEY_MAX_LENGTH,
5076
+ PROJECT_KEY_MIN_LENGTH: () => PROJECT_KEY_MIN_LENGTH,
5077
+ PROJECT_KEY_PATTERN: () => PROJECT_KEY_PATTERN,
5078
+ RESERVED_PROJECT_KEYS: () => RESERVED_PROJECT_KEYS,
4983
5079
  SELECTOR_PRIORITIES: () => SELECTOR_PRIORITIES,
4984
5080
  SHARED_PRICING_PLAN_MAP: () => SHARED_PRICING_PLAN_MAP,
4985
5081
  STT_MODEL_OPTIONS: () => STT_MODEL_OPTIONS,
@@ -5013,6 +5109,7 @@ __export(dist_exports, {
5013
5109
  controlSignature: () => controlSignature,
5014
5110
  countInnerTests: () => countInnerTests,
5015
5111
  decideHarPosted: () => decideHarPosted,
5112
+ deriveProjectKey: () => deriveProjectKey,
5016
5113
  detectCrossOriginHtmlAsJson: () => detectCrossOriginHtmlAsJson,
5017
5114
  detectsErrorBannerAssertion: () => detectsErrorBannerAssertion,
5018
5115
  effectiveTestCount: () => effectiveTestCount,
@@ -5029,6 +5126,7 @@ __export(dist_exports, {
5029
5126
  formatLimit: () => formatLimit,
5030
5127
  fromDbPricingPlan: () => fromDbPricingPlan,
5031
5128
  generateMobileTestFromSession: () => generateMobileTestFromSession,
5129
+ generateUniqueProjectKey: () => generateUniqueProjectKey,
5032
5130
  getDefaultPlanForTier: () => getDefaultPlanForTier,
5033
5131
  getDeploymentTierForPlan: () => getDeploymentTierForPlan,
5034
5132
  getPlanChangeType: () => getPlanChangeType,
@@ -5047,6 +5145,7 @@ __export(dist_exports, {
5047
5145
  isCreditPackAmountCents: () => isCreditPackAmountCents,
5048
5146
  isDbPricingPlan: () => isDbPricingPlan,
5049
5147
  isEditableMobileClassName: () => isEditableMobileClassName,
5148
+ isEmailFromIdentity: () => isEmailFromIdentity,
5050
5149
  isEnvironmentalError: () => isEnvironmentalError,
5051
5150
  isMobilePlatform: () => isMobilePlatform,
5052
5151
  isPricingPlan: () => isPricingPlan,
@@ -5058,6 +5157,7 @@ __export(dist_exports, {
5058
5157
  isTrialExpired: () => isTrialExpired,
5059
5158
  isUIVariant: () => isUIVariant,
5060
5159
  isValidMobileAppId: () => isValidMobileAppId,
5160
+ isValidProjectKey: () => isValidProjectKey,
5061
5161
  isValidSttProvider: () => isValidSttProvider,
5062
5162
  isValidTtsModel: () => isValidTtsModel,
5063
5163
  isValidTtsVoiceId: () => isValidTtsVoiceId,
@@ -5103,6 +5203,7 @@ var init_dist = __esm({
5103
5203
  "use strict";
5104
5204
  init_types();
5105
5205
  init_constants();
5206
+ init_project_key();
5106
5207
  init_mobile_test_generation();
5107
5208
  init_mobile_step_healer();
5108
5209
  init_mobile_cross_platform();
@@ -5893,17 +5994,27 @@ var require_mobile_session_keepalive = __commonJS({
5893
5994
  "use strict";
5894
5995
  Object.defineProperty(exports, "__esModule", { value: true });
5895
5996
  exports.MOBILE_KEEPALIVE_INTERVAL_MS = void 0;
5997
+ exports.computeKeepAliveIntervalMs = computeKeepAliveIntervalMs;
5896
5998
  exports.withMobileSessionKeepAlive = withMobileSessionKeepAlive;
5897
- exports.MOBILE_KEEPALIVE_INTERVAL_MS = (() => {
5898
- const raw = Number(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS);
5899
- return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 12e4;
5900
- })();
5999
+ function computeKeepAliveIntervalMs(keepaliveMsRaw, newCommandTimeoutSecondsRaw) {
6000
+ const explicit = keepaliveMsRaw?.trim() ? Number(keepaliveMsRaw) : NaN;
6001
+ if (Number.isFinite(explicit) && explicit >= 0)
6002
+ return Math.floor(explicit);
6003
+ const timeoutSeconds = Number(newCommandTimeoutSecondsRaw);
6004
+ const reapWindowMs = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? Math.floor(timeoutSeconds) * 1e3 : 6e5;
6005
+ return Math.min(12e4, Math.max(5e3, Math.floor(reapWindowMs / 3)));
6006
+ }
6007
+ exports.MOBILE_KEEPALIVE_INTERVAL_MS = computeKeepAliveIntervalMs(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS, process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
5901
6008
  async function withMobileSessionKeepAlive(driver, fn, intervalMs = exports.MOBILE_KEEPALIVE_INTERVAL_MS) {
5902
6009
  if (intervalMs <= 0)
5903
6010
  return fn();
5904
6011
  const timer = setInterval(() => {
5905
- void driver.windowSize().catch(() => {
5906
- });
6012
+ try {
6013
+ const probe = driver.keepAlivePing ? driver.keepAlivePing() : driver.windowSize();
6014
+ void probe.catch(() => {
6015
+ });
6016
+ } catch {
6017
+ }
5907
6018
  }, intervalMs);
5908
6019
  timer.unref?.();
5909
6020
  try {
@@ -73998,6 +74109,7 @@ var require_mobile_heal_loop = __commonJS({
73998
74109
  var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
73999
74110
  var mobile_observation_js_1 = require_mobile_observation();
74000
74111
  var selector_registry_context_js_1 = require_selector_registry_context();
74112
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
74001
74113
  exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test. You receive: the test STEPS as JSON, the failure ERROR + step-level results, and the PAGE-SOURCE accessibility tree captured at the moment of failure (this tree is your 'screenshot'), plus optional known-good selector candidates from a registry.
74002
74114
  ## You MUST try to fix the steps
74003
74115
  Reason over steps + error + tree + registry and return working healed steps. Do NOT return needs_device unless you have genuinely exhausted that reasoning (and NEVER before your final attempt).
@@ -74889,6 +75001,19 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74889
75001
  };
74890
75002
  }
74891
75003
  const maxPasses = envInt2("MOBILE_HEAL_PHASE2_MAX_PASSES") ?? DEFAULT_PHASE2_MAX_PASSES;
75004
+ const keepAlivePingable = {
75005
+ ...typeof liveDriver.keepAlivePing === "function" ? { keepAlivePing: liveDriver.keepAlivePing.bind(liveDriver) } : {},
75006
+ // WARNING: this fallback routes through snapshot(), which sits inside
75007
+ // withSessionRecovery on the real driver — a failed tick could consume the
75008
+ // bounded recovery budget, the exact bug keepAlivePing exists to avoid.
75009
+ // Every current MobileLiveDriverLike provides keepAlivePing, so this only
75010
+ // exists for structural compatibility; implement keepAlivePing on any new
75011
+ // facade rather than relying on it.
75012
+ windowSize: async () => {
75013
+ await liveDriver.snapshot();
75014
+ return null;
75015
+ }
75016
+ };
74892
75017
  let baseSteps = args.steps;
74893
75018
  let priorError = args.priorError ?? null;
74894
75019
  let bestSteps;
@@ -74923,10 +75048,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74923
75048
  ];
74924
75049
  let content;
74925
75050
  try {
74926
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
75051
+ const response = await (0, mobile_session_keepalive_js_1.withMobileSessionKeepAlive)(keepAlivePingable, () => (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
74927
75052
  arm();
74928
75053
  return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
74929
- }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true });
75054
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true }));
74930
75055
  content = response?.choices?.[0]?.message?.content ?? "";
74931
75056
  } catch {
74932
75057
  continue;
@@ -84729,6 +84854,51 @@ var require_discover_generator = __commonJS({
84729
84854
  }
84730
84855
  return s.length;
84731
84856
  }
84857
+ function lineParenDeficit(line) {
84858
+ const stack = [];
84859
+ let regexSkips = 0;
84860
+ for (let i = 0; i < line.length; i++) {
84861
+ const ch = line[i];
84862
+ if (ch === "'" || ch === '"' || ch === "`") {
84863
+ i = skipQuotedLiteral(line, i) - 1;
84864
+ continue;
84865
+ }
84866
+ if (ch === "/" && line[i + 1] === "/") {
84867
+ i = skipLineComment(line, i) - 1;
84868
+ continue;
84869
+ }
84870
+ if (ch === "/" && line[i + 1] === "*") {
84871
+ i = skipBlockComment(line, i) - 1;
84872
+ continue;
84873
+ }
84874
+ if (ch === "/" && isRegexStart(line, i)) {
84875
+ regexSkips++;
84876
+ i = skipRegexLiteral(line, i) - 1;
84877
+ continue;
84878
+ }
84879
+ if (ch === "(" || ch === "{" || ch === "[") {
84880
+ stack.push(ch);
84881
+ continue;
84882
+ }
84883
+ if (ch === ")" || ch === "}" || ch === "]") {
84884
+ if (stack[stack.length - 1] !== CLOSE_TO_OPEN[ch])
84885
+ return { deficit: 0, regexSkips };
84886
+ stack.pop();
84887
+ }
84888
+ }
84889
+ const deficit = stack.length > 0 && stack.every((open) => open === "(") ? stack.length : 0;
84890
+ return { deficit, regexSkips };
84891
+ }
84892
+ function parsesAsAsyncStatement(line) {
84893
+ try {
84894
+ new Function(`return (async () => {
84895
+ ${line}
84896
+ })`);
84897
+ return true;
84898
+ } catch {
84899
+ return false;
84900
+ }
84901
+ }
84732
84902
  function closeUnclosedRegexLiterals(code) {
84733
84903
  const anchors = [
84734
84904
  { rx: /\b(?:waitForURL|toHaveURL)\(\//g, mode: "url" },
@@ -84767,12 +84937,33 @@ var require_discover_generator = __commonJS({
84767
84937
  const term = findRegexTerminator(code, slash, TERMINATORS[mode]);
84768
84938
  out.push(code.slice(cursor, slash));
84769
84939
  const raw = code.slice(slash + 1, term).replace(/\s+$/, "");
84940
+ let closedLiteral;
84770
84941
  if (mode === "url") {
84771
84942
  const escaped = raw.replace(/(?<!\\)\//g, "\\/");
84772
84943
  const finalBody = escaped.startsWith("\\/") ? escaped : `\\/${escaped}`;
84773
- out.push(`/${finalBody}/`);
84944
+ closedLiteral = `/${finalBody}/`;
84774
84945
  } else {
84775
- out.push(`/${raw}/`);
84946
+ closedLiteral = `/${raw}/`;
84947
+ }
84948
+ out.push(closedLiteral);
84949
+ if (code[term] === ")") {
84950
+ const lineStart = code.lastIndexOf("\n", slash) + 1;
84951
+ const nextNewline = code.indexOf("\n", term);
84952
+ const lineEnd = nextNewline === -1 ? code.length : nextNewline;
84953
+ const tail = code.slice(term, lineEnd);
84954
+ const physicalLine = code.slice(lineStart, lineEnd);
84955
+ const lineIsPlain = !physicalLine.includes("`") && !physicalLine.includes("/*") && !physicalLine.includes("*/");
84956
+ const soleAnchorOnLine = !hits.some((h) => h.slash !== slash && h.slash >= lineStart && h.slash < lineEnd);
84957
+ if (lineIsPlain && soleAnchorOnLine && /;\s*$/.test(tail)) {
84958
+ const repairedLine = code.slice(lineStart, slash) + "/x/" + tail;
84959
+ const { deficit, regexSkips } = lineParenDeficit(repairedLine);
84960
+ if (deficit > 0 && deficit <= 3 && regexSkips === 1) {
84961
+ const candidateLine = code.slice(lineStart, slash) + closedLiteral + ")".repeat(deficit) + tail;
84962
+ if (parsesAsAsyncStatement(candidateLine)) {
84963
+ out.push(")".repeat(deficit));
84964
+ }
84965
+ }
84966
+ }
84776
84967
  }
84777
84968
  cursor = term;
84778
84969
  }
@@ -337150,7 +337341,7 @@ var require_package4 = __commonJS({
337150
337341
  "package.json"(exports, module) {
337151
337342
  module.exports = {
337152
337343
  name: "@validate.qa/runner",
337153
- version: "1.0.40",
337344
+ version: "1.0.42",
337154
337345
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
337155
337346
  bin: {
337156
337347
  "validate-runner": "dist/cli.js"
@@ -337159,7 +337350,7 @@ var require_package4 = __commonJS({
337159
337350
  build: "tsup",
337160
337351
  dev: "tsx src/cli.ts",
337161
337352
  test: "node --import tsx --test src/version-check.test.ts",
337162
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
337353
+ "test:mobile": "node --import tsx --test --test-concurrency=2 src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
337163
337354
  prepublishOnly: "npm run build"
337164
337355
  },
337165
337356
  files: [
@@ -343310,6 +343501,17 @@ var MobileBridgeDriver = class {
343310
343501
  return size;
343311
343502
  });
343312
343503
  }
343504
+ /**
343505
+ * Recovery-free liveness probe for keep-alive ticks (mobile-session-keepalive).
343506
+ * Deliberately NOT wrapped in withSessionRecovery: keep-alive ticks are
343507
+ * fire-and-forget, so a failing probe must never consume the bounded recovery
343508
+ * budget or relaunch the app from the background — a genuinely dead session
343509
+ * surfaces (with its precise error) on the caller's next REAL command, which
343510
+ * still runs the full recovery path.
343511
+ */
343512
+ async keepAlivePing() {
343513
+ await this.assertSessionReachable();
343514
+ }
343313
343515
  // ── Action result wrapper ──────────────────────────────
343314
343516
  /**
343315
343517
  * Run an action body, let the UI tree settle, then capture the post-action
@@ -345867,6 +346069,7 @@ ${finalVerifyResult.logs ?? ""}`;
345867
346069
  setExecutorBusy(true);
345868
346070
  let session = null;
345869
346071
  let capture = null;
346072
+ let discoveryTeardownStarted = false;
345870
346073
  try {
345871
346074
  const discoverySessionTarget = {
345872
346075
  appId: mobileTarget.appId,
@@ -345918,6 +346121,9 @@ ${finalVerifyResult.logs ?? ""}`;
345918
346121
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
345919
346122
  maxSessionRecoveries: 1,
345920
346123
  recoverSession: async (cause) => {
346124
+ if (discoveryTeardownStarted) {
346125
+ throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
346126
+ }
345921
346127
  const lost = session;
345922
346128
  mobileOnLog(
345923
346129
  `[appium] discovery session lost (${cause.message}); recreating once on the leased device`
@@ -345931,6 +346137,13 @@ ${finalVerifyResult.logs ?? ""}`;
345931
346137
  }
345932
346138
  }
345933
346139
  const replacement = await openDiscoverySession();
346140
+ if (discoveryTeardownStarted) {
346141
+ try {
346142
+ await replacement.stop();
346143
+ } catch {
346144
+ }
346145
+ throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
346146
+ }
345934
346147
  session = replacement;
345935
346148
  attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
345936
346149
  liveBrowserHandle.patch({
@@ -345970,6 +346183,7 @@ ${finalVerifyResult.logs ?? ""}`;
345970
346183
  onPlanReady
345971
346184
  });
345972
346185
  } finally {
346186
+ discoveryTeardownStarted = true;
345973
346187
  const TEARDOWN_STEP_TIMEOUT = /* @__PURE__ */ Symbol("vqa-teardown-timeout");
345974
346188
  const timeboxed = async (step) => Promise.race([
345975
346189
  step,
@@ -346005,8 +346219,12 @@ ${finalVerifyResult.logs ?? ""}`;
346005
346219
  }
346006
346220
  if (session) {
346007
346221
  detachMirrorSession(session.deviceId);
346222
+ const closing = session;
346008
346223
  try {
346009
- await timeboxed(session.stop());
346224
+ const stopped = await timeboxed(closing.stop());
346225
+ if (stopped === TEARDOWN_STEP_TIMEOUT) {
346226
+ mobileOnLog(`[appium] session ${closing.sessionId} stop timed out after 45s \u2014 releasing device ${closing.deviceId} anyway; Appium may hold the session until newCommandTimeout reaps it`);
346227
+ }
346010
346228
  } catch {
346011
346229
  }
346012
346230
  }
@@ -346444,7 +346662,13 @@ ${finalVerifyResult.logs ?? ""}`;
346444
346662
  relaunch: async () => {
346445
346663
  await currentDriver().relaunch();
346446
346664
  },
346447
- resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState()
346665
+ resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState(),
346666
+ // Recovery-free liveness probe so the loop's keep-alive can span
346667
+ // long AI waits without touching the driver's recovery machinery
346668
+ // (see mobile-session-keepalive.ts).
346669
+ keepAlivePing: async () => {
346670
+ await currentDriver().keepAlivePing();
346671
+ }
346448
346672
  };
346449
346673
  const runNativeWithSessionHandoff = async (verifyArgs) => {
346450
346674
  await closeLiveSession();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.40",
3
+ "version": "1.0.42",
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"
@@ -9,7 +9,7 @@
9
9
  "build": "tsup",
10
10
  "dev": "tsx src/cli.ts",
11
11
  "test": "node --import tsx --test src/version-check.test.ts",
12
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
12
+ "test:mobile": "node --import tsx --test --test-concurrency=2 src/runner.mobile-ai-env.test.ts src/mobile-device-queue.test.ts src/device-lease.test.ts src/result-payload-budget.test.ts src/services/appium-*.test.ts src/services/*mobile*.test.ts src/services/mobile/*.test.ts",
13
13
  "prepublishOnly": "npm run build"
14
14
  },
15
15
  "files": [