@validate.qa/runner 1.0.40 → 1.0.41

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 +222 -14
  2. package/dist/cli.mjs +222 -14
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1786,6 +1786,83 @@ var init_constants = __esm({
1786
1786
  }
1787
1787
  });
1788
1788
 
1789
+ // ../shared/dist/project-key.js
1790
+ function isValidProjectKey(key) {
1791
+ return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
1792
+ }
1793
+ function randomChar(rng) {
1794
+ return KEY_ALPHABET[Math.floor(rng() * KEY_ALPHABET.length)] ?? "0";
1795
+ }
1796
+ function randomKey(length, rng) {
1797
+ let out = "";
1798
+ for (let i = 0; i < length; i++)
1799
+ out += randomChar(rng);
1800
+ return out;
1801
+ }
1802
+ function alnumLower(name) {
1803
+ return (name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
1804
+ }
1805
+ function deriveProjectKey(name, rng = Math.random) {
1806
+ const base2 = alnumLower(name).slice(0, 3);
1807
+ return base2.length >= PROJECT_KEY_MIN_LENGTH ? base2 : randomKey(3, rng);
1808
+ }
1809
+ function generateUniqueProjectKey(name, taken, rng = Math.random) {
1810
+ const isFree = (k) => isValidProjectKey(k) && !taken.has(k);
1811
+ const primary = deriveProjectKey(name, rng);
1812
+ if (isFree(primary))
1813
+ return primary;
1814
+ const prefix2 = alnumLower(name).slice(0, 2);
1815
+ for (const suffixLen of [1, 2]) {
1816
+ for (let attempt = 0; attempt < 40; attempt++) {
1817
+ const stem = prefix2.length >= 1 ? prefix2 : deriveProjectKey(name, rng).slice(0, 2);
1818
+ const candidate = stem + randomKey(suffixLen, rng);
1819
+ if (isFree(candidate))
1820
+ return candidate;
1821
+ }
1822
+ }
1823
+ for (let len = 4; len <= PROJECT_KEY_MAX_LENGTH; len++) {
1824
+ for (let attempt = 0; attempt < 100; attempt++) {
1825
+ const candidate = randomKey(len, rng);
1826
+ if (isFree(candidate))
1827
+ return candidate;
1828
+ }
1829
+ }
1830
+ throw new Error("Unable to generate a unique project key");
1831
+ }
1832
+ var RESERVED_PROJECT_KEYS, PROJECT_KEY_MIN_LENGTH, PROJECT_KEY_MAX_LENGTH, PROJECT_KEY_PATTERN, KEY_ALPHABET;
1833
+ var init_project_key = __esm({
1834
+ "../shared/dist/project-key.js"() {
1835
+ "use strict";
1836
+ RESERVED_PROJECT_KEYS = /* @__PURE__ */ new Set([
1837
+ "tests",
1838
+ "sessions",
1839
+ "settings",
1840
+ "reports",
1841
+ "account",
1842
+ "notifications",
1843
+ "support",
1844
+ "site-map",
1845
+ "api-intelligence",
1846
+ "selector-registry",
1847
+ "project-knowledge",
1848
+ "test-data",
1849
+ "audit",
1850
+ "validate-copilot",
1851
+ "qa-copilot",
1852
+ "insights",
1853
+ "regression",
1854
+ "reviews",
1855
+ "admin",
1856
+ "login",
1857
+ "admin-view"
1858
+ ]);
1859
+ PROJECT_KEY_MIN_LENGTH = 2;
1860
+ PROJECT_KEY_MAX_LENGTH = 12;
1861
+ PROJECT_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,10}[a-z0-9])$/;
1862
+ KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1863
+ }
1864
+ });
1865
+
1789
1866
  // ../shared/dist/mobile-deeplink.js
1790
1867
  function isSafeDeepLink(value) {
1791
1868
  if (typeof value !== "string")
@@ -4975,6 +5052,10 @@ __export(dist_exports, {
4975
5052
  PLAN_DEFINITIONS: () => PLAN_DEFINITIONS,
4976
5053
  PLAN_ORDER: () => PLAN_ORDER,
4977
5054
  PLATFORM_ADMIN_EMAIL: () => PLATFORM_ADMIN_EMAIL,
5055
+ PROJECT_KEY_MAX_LENGTH: () => PROJECT_KEY_MAX_LENGTH,
5056
+ PROJECT_KEY_MIN_LENGTH: () => PROJECT_KEY_MIN_LENGTH,
5057
+ PROJECT_KEY_PATTERN: () => PROJECT_KEY_PATTERN,
5058
+ RESERVED_PROJECT_KEYS: () => RESERVED_PROJECT_KEYS,
4978
5059
  SELECTOR_PRIORITIES: () => SELECTOR_PRIORITIES,
4979
5060
  SHARED_PRICING_PLAN_MAP: () => SHARED_PRICING_PLAN_MAP,
4980
5061
  STT_MODEL_OPTIONS: () => STT_MODEL_OPTIONS,
@@ -5008,6 +5089,7 @@ __export(dist_exports, {
5008
5089
  controlSignature: () => controlSignature,
5009
5090
  countInnerTests: () => countInnerTests,
5010
5091
  decideHarPosted: () => decideHarPosted,
5092
+ deriveProjectKey: () => deriveProjectKey,
5011
5093
  detectCrossOriginHtmlAsJson: () => detectCrossOriginHtmlAsJson,
5012
5094
  detectsErrorBannerAssertion: () => detectsErrorBannerAssertion,
5013
5095
  effectiveTestCount: () => effectiveTestCount,
@@ -5024,6 +5106,7 @@ __export(dist_exports, {
5024
5106
  formatLimit: () => formatLimit,
5025
5107
  fromDbPricingPlan: () => fromDbPricingPlan,
5026
5108
  generateMobileTestFromSession: () => generateMobileTestFromSession,
5109
+ generateUniqueProjectKey: () => generateUniqueProjectKey,
5027
5110
  getDefaultPlanForTier: () => getDefaultPlanForTier,
5028
5111
  getDeploymentTierForPlan: () => getDeploymentTierForPlan,
5029
5112
  getPlanChangeType: () => getPlanChangeType,
@@ -5053,6 +5136,7 @@ __export(dist_exports, {
5053
5136
  isTrialExpired: () => isTrialExpired,
5054
5137
  isUIVariant: () => isUIVariant,
5055
5138
  isValidMobileAppId: () => isValidMobileAppId,
5139
+ isValidProjectKey: () => isValidProjectKey,
5056
5140
  isValidSttProvider: () => isValidSttProvider,
5057
5141
  isValidTtsModel: () => isValidTtsModel,
5058
5142
  isValidTtsVoiceId: () => isValidTtsVoiceId,
@@ -5098,6 +5182,7 @@ var init_dist = __esm({
5098
5182
  "use strict";
5099
5183
  init_types();
5100
5184
  init_constants();
5185
+ init_project_key();
5101
5186
  init_mobile_test_generation();
5102
5187
  init_mobile_step_healer();
5103
5188
  init_mobile_cross_platform();
@@ -5888,17 +5973,27 @@ var require_mobile_session_keepalive = __commonJS({
5888
5973
  "use strict";
5889
5974
  Object.defineProperty(exports2, "__esModule", { value: true });
5890
5975
  exports2.MOBILE_KEEPALIVE_INTERVAL_MS = void 0;
5976
+ exports2.computeKeepAliveIntervalMs = computeKeepAliveIntervalMs;
5891
5977
  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
- })();
5978
+ function computeKeepAliveIntervalMs(keepaliveMsRaw, newCommandTimeoutSecondsRaw) {
5979
+ const explicit = keepaliveMsRaw?.trim() ? Number(keepaliveMsRaw) : NaN;
5980
+ if (Number.isFinite(explicit) && explicit >= 0)
5981
+ return Math.floor(explicit);
5982
+ const timeoutSeconds = Number(newCommandTimeoutSecondsRaw);
5983
+ const reapWindowMs = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? Math.floor(timeoutSeconds) * 1e3 : 6e5;
5984
+ return Math.min(12e4, Math.max(5e3, Math.floor(reapWindowMs / 3)));
5985
+ }
5986
+ exports2.MOBILE_KEEPALIVE_INTERVAL_MS = computeKeepAliveIntervalMs(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS, process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
5896
5987
  async function withMobileSessionKeepAlive(driver, fn, intervalMs = exports2.MOBILE_KEEPALIVE_INTERVAL_MS) {
5897
5988
  if (intervalMs <= 0)
5898
5989
  return fn();
5899
5990
  const timer = setInterval(() => {
5900
- void driver.windowSize().catch(() => {
5901
- });
5991
+ try {
5992
+ const probe = driver.keepAlivePing ? driver.keepAlivePing() : driver.windowSize();
5993
+ void probe.catch(() => {
5994
+ });
5995
+ } catch {
5996
+ }
5902
5997
  }, intervalMs);
5903
5998
  timer.unref?.();
5904
5999
  try {
@@ -73993,6 +74088,7 @@ var require_mobile_heal_loop = __commonJS({
73993
74088
  var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
73994
74089
  var mobile_observation_js_1 = require_mobile_observation();
73995
74090
  var selector_registry_context_js_1 = require_selector_registry_context();
74091
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
73996
74092
  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
74093
  ## You MUST try to fix the steps
73998
74094
  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 +74980,19 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74884
74980
  };
74885
74981
  }
74886
74982
  const maxPasses = envInt2("MOBILE_HEAL_PHASE2_MAX_PASSES") ?? DEFAULT_PHASE2_MAX_PASSES;
74983
+ const keepAlivePingable = {
74984
+ ...typeof liveDriver.keepAlivePing === "function" ? { keepAlivePing: liveDriver.keepAlivePing.bind(liveDriver) } : {},
74985
+ // WARNING: this fallback routes through snapshot(), which sits inside
74986
+ // withSessionRecovery on the real driver — a failed tick could consume the
74987
+ // bounded recovery budget, the exact bug keepAlivePing exists to avoid.
74988
+ // Every current MobileLiveDriverLike provides keepAlivePing, so this only
74989
+ // exists for structural compatibility; implement keepAlivePing on any new
74990
+ // facade rather than relying on it.
74991
+ windowSize: async () => {
74992
+ await liveDriver.snapshot();
74993
+ return null;
74994
+ }
74995
+ };
74887
74996
  let baseSteps = args.steps;
74888
74997
  let priorError = args.priorError ?? null;
74889
74998
  let bestSteps;
@@ -74918,10 +75027,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74918
75027
  ];
74919
75028
  let content;
74920
75029
  try {
74921
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
75030
+ 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
75031
  arm();
74923
75032
  return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
74924
- }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true });
75033
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true }));
74925
75034
  content = response?.choices?.[0]?.message?.content ?? "";
74926
75035
  } catch {
74927
75036
  continue;
@@ -84724,6 +84833,51 @@ var require_discover_generator = __commonJS({
84724
84833
  }
84725
84834
  return s.length;
84726
84835
  }
84836
+ function lineParenDeficit(line) {
84837
+ const stack = [];
84838
+ let regexSkips = 0;
84839
+ for (let i = 0; i < line.length; i++) {
84840
+ const ch = line[i];
84841
+ if (ch === "'" || ch === '"' || ch === "`") {
84842
+ i = skipQuotedLiteral(line, i) - 1;
84843
+ continue;
84844
+ }
84845
+ if (ch === "/" && line[i + 1] === "/") {
84846
+ i = skipLineComment(line, i) - 1;
84847
+ continue;
84848
+ }
84849
+ if (ch === "/" && line[i + 1] === "*") {
84850
+ i = skipBlockComment(line, i) - 1;
84851
+ continue;
84852
+ }
84853
+ if (ch === "/" && isRegexStart(line, i)) {
84854
+ regexSkips++;
84855
+ i = skipRegexLiteral(line, i) - 1;
84856
+ continue;
84857
+ }
84858
+ if (ch === "(" || ch === "{" || ch === "[") {
84859
+ stack.push(ch);
84860
+ continue;
84861
+ }
84862
+ if (ch === ")" || ch === "}" || ch === "]") {
84863
+ if (stack[stack.length - 1] !== CLOSE_TO_OPEN[ch])
84864
+ return { deficit: 0, regexSkips };
84865
+ stack.pop();
84866
+ }
84867
+ }
84868
+ const deficit = stack.length > 0 && stack.every((open) => open === "(") ? stack.length : 0;
84869
+ return { deficit, regexSkips };
84870
+ }
84871
+ function parsesAsAsyncStatement(line) {
84872
+ try {
84873
+ new Function(`return (async () => {
84874
+ ${line}
84875
+ })`);
84876
+ return true;
84877
+ } catch {
84878
+ return false;
84879
+ }
84880
+ }
84727
84881
  function closeUnclosedRegexLiterals(code) {
84728
84882
  const anchors = [
84729
84883
  { rx: /\b(?:waitForURL|toHaveURL)\(\//g, mode: "url" },
@@ -84762,12 +84916,33 @@ var require_discover_generator = __commonJS({
84762
84916
  const term = findRegexTerminator(code, slash, TERMINATORS[mode]);
84763
84917
  out.push(code.slice(cursor, slash));
84764
84918
  const raw = code.slice(slash + 1, term).replace(/\s+$/, "");
84919
+ let closedLiteral;
84765
84920
  if (mode === "url") {
84766
84921
  const escaped = raw.replace(/(?<!\\)\//g, "\\/");
84767
84922
  const finalBody = escaped.startsWith("\\/") ? escaped : `\\/${escaped}`;
84768
- out.push(`/${finalBody}/`);
84923
+ closedLiteral = `/${finalBody}/`;
84769
84924
  } else {
84770
- out.push(`/${raw}/`);
84925
+ closedLiteral = `/${raw}/`;
84926
+ }
84927
+ out.push(closedLiteral);
84928
+ if (code[term] === ")") {
84929
+ const lineStart = code.lastIndexOf("\n", slash) + 1;
84930
+ const nextNewline = code.indexOf("\n", term);
84931
+ const lineEnd = nextNewline === -1 ? code.length : nextNewline;
84932
+ const tail = code.slice(term, lineEnd);
84933
+ const physicalLine = code.slice(lineStart, lineEnd);
84934
+ const lineIsPlain = !physicalLine.includes("`") && !physicalLine.includes("/*") && !physicalLine.includes("*/");
84935
+ const soleAnchorOnLine = !hits.some((h) => h.slash !== slash && h.slash >= lineStart && h.slash < lineEnd);
84936
+ if (lineIsPlain && soleAnchorOnLine && /;\s*$/.test(tail)) {
84937
+ const repairedLine = code.slice(lineStart, slash) + "/x/" + tail;
84938
+ const { deficit, regexSkips } = lineParenDeficit(repairedLine);
84939
+ if (deficit > 0 && deficit <= 3 && regexSkips === 1) {
84940
+ const candidateLine = code.slice(lineStart, slash) + closedLiteral + ")".repeat(deficit) + tail;
84941
+ if (parsesAsAsyncStatement(candidateLine)) {
84942
+ out.push(")".repeat(deficit));
84943
+ }
84944
+ }
84945
+ }
84771
84946
  }
84772
84947
  cursor = term;
84773
84948
  }
@@ -337145,7 +337320,7 @@ var require_package4 = __commonJS({
337145
337320
  "package.json"(exports2, module2) {
337146
337321
  module2.exports = {
337147
337322
  name: "@validate.qa/runner",
337148
- version: "1.0.40",
337323
+ version: "1.0.41",
337149
337324
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
337150
337325
  bin: {
337151
337326
  "validate-runner": "dist/cli.js"
@@ -337154,7 +337329,7 @@ var require_package4 = __commonJS({
337154
337329
  build: "tsup",
337155
337330
  dev: "tsx src/cli.ts",
337156
337331
  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",
337332
+ "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
337333
  prepublishOnly: "npm run build"
337159
337334
  },
337160
337335
  files: [
@@ -343302,6 +343477,17 @@ var MobileBridgeDriver = class {
343302
343477
  return size;
343303
343478
  });
343304
343479
  }
343480
+ /**
343481
+ * Recovery-free liveness probe for keep-alive ticks (mobile-session-keepalive).
343482
+ * Deliberately NOT wrapped in withSessionRecovery: keep-alive ticks are
343483
+ * fire-and-forget, so a failing probe must never consume the bounded recovery
343484
+ * budget or relaunch the app from the background — a genuinely dead session
343485
+ * surfaces (with its precise error) on the caller's next REAL command, which
343486
+ * still runs the full recovery path.
343487
+ */
343488
+ async keepAlivePing() {
343489
+ await this.assertSessionReachable();
343490
+ }
343305
343491
  // ── Action result wrapper ──────────────────────────────
343306
343492
  /**
343307
343493
  * Run an action body, let the UI tree settle, then capture the post-action
@@ -345859,6 +346045,7 @@ ${finalVerifyResult.logs ?? ""}`;
345859
346045
  setExecutorBusy(true);
345860
346046
  let session = null;
345861
346047
  let capture = null;
346048
+ let discoveryTeardownStarted = false;
345862
346049
  try {
345863
346050
  const discoverySessionTarget = {
345864
346051
  appId: mobileTarget.appId,
@@ -345910,6 +346097,9 @@ ${finalVerifyResult.logs ?? ""}`;
345910
346097
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
345911
346098
  maxSessionRecoveries: 1,
345912
346099
  recoverSession: async (cause) => {
346100
+ if (discoveryTeardownStarted) {
346101
+ throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
346102
+ }
345913
346103
  const lost = session;
345914
346104
  mobileOnLog(
345915
346105
  `[appium] discovery session lost (${cause.message}); recreating once on the leased device`
@@ -345923,6 +346113,13 @@ ${finalVerifyResult.logs ?? ""}`;
345923
346113
  }
345924
346114
  }
345925
346115
  const replacement = await openDiscoverySession();
346116
+ if (discoveryTeardownStarted) {
346117
+ try {
346118
+ await replacement.stop();
346119
+ } catch {
346120
+ }
346121
+ throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
346122
+ }
345926
346123
  session = replacement;
345927
346124
  attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
345928
346125
  liveBrowserHandle.patch({
@@ -345962,6 +346159,7 @@ ${finalVerifyResult.logs ?? ""}`;
345962
346159
  onPlanReady
345963
346160
  });
345964
346161
  } finally {
346162
+ discoveryTeardownStarted = true;
345965
346163
  const TEARDOWN_STEP_TIMEOUT = /* @__PURE__ */ Symbol("vqa-teardown-timeout");
345966
346164
  const timeboxed = async (step) => Promise.race([
345967
346165
  step,
@@ -345997,8 +346195,12 @@ ${finalVerifyResult.logs ?? ""}`;
345997
346195
  }
345998
346196
  if (session) {
345999
346197
  detachMirrorSession(session.deviceId);
346198
+ const closing = session;
346000
346199
  try {
346001
- await timeboxed(session.stop());
346200
+ const stopped = await timeboxed(closing.stop());
346201
+ if (stopped === TEARDOWN_STEP_TIMEOUT) {
346202
+ 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`);
346203
+ }
346002
346204
  } catch {
346003
346205
  }
346004
346206
  }
@@ -346436,7 +346638,13 @@ ${finalVerifyResult.logs ?? ""}`;
346436
346638
  relaunch: async () => {
346437
346639
  await currentDriver().relaunch();
346438
346640
  },
346439
- resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState()
346641
+ resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState(),
346642
+ // Recovery-free liveness probe so the loop's keep-alive can span
346643
+ // long AI waits without touching the driver's recovery machinery
346644
+ // (see mobile-session-keepalive.ts).
346645
+ keepAlivePing: async () => {
346646
+ await currentDriver().keepAlivePing();
346647
+ }
346440
346648
  };
346441
346649
  const runNativeWithSessionHandoff = async (verifyArgs) => {
346442
346650
  await closeLiveSession();
package/dist/cli.mjs CHANGED
@@ -1791,6 +1791,83 @@ var init_constants = __esm({
1791
1791
  }
1792
1792
  });
1793
1793
 
1794
+ // ../shared/dist/project-key.js
1795
+ function isValidProjectKey(key) {
1796
+ return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
1797
+ }
1798
+ function randomChar(rng) {
1799
+ return KEY_ALPHABET[Math.floor(rng() * KEY_ALPHABET.length)] ?? "0";
1800
+ }
1801
+ function randomKey(length, rng) {
1802
+ let out = "";
1803
+ for (let i = 0; i < length; i++)
1804
+ out += randomChar(rng);
1805
+ return out;
1806
+ }
1807
+ function alnumLower(name) {
1808
+ return (name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
1809
+ }
1810
+ function deriveProjectKey(name, rng = Math.random) {
1811
+ const base2 = alnumLower(name).slice(0, 3);
1812
+ return base2.length >= PROJECT_KEY_MIN_LENGTH ? base2 : randomKey(3, rng);
1813
+ }
1814
+ function generateUniqueProjectKey(name, taken, rng = Math.random) {
1815
+ const isFree = (k) => isValidProjectKey(k) && !taken.has(k);
1816
+ const primary = deriveProjectKey(name, rng);
1817
+ if (isFree(primary))
1818
+ return primary;
1819
+ const prefix2 = alnumLower(name).slice(0, 2);
1820
+ for (const suffixLen of [1, 2]) {
1821
+ for (let attempt = 0; attempt < 40; attempt++) {
1822
+ const stem = prefix2.length >= 1 ? prefix2 : deriveProjectKey(name, rng).slice(0, 2);
1823
+ const candidate = stem + randomKey(suffixLen, rng);
1824
+ if (isFree(candidate))
1825
+ return candidate;
1826
+ }
1827
+ }
1828
+ for (let len = 4; len <= PROJECT_KEY_MAX_LENGTH; len++) {
1829
+ for (let attempt = 0; attempt < 100; attempt++) {
1830
+ const candidate = randomKey(len, rng);
1831
+ if (isFree(candidate))
1832
+ return candidate;
1833
+ }
1834
+ }
1835
+ throw new Error("Unable to generate a unique project key");
1836
+ }
1837
+ var RESERVED_PROJECT_KEYS, PROJECT_KEY_MIN_LENGTH, PROJECT_KEY_MAX_LENGTH, PROJECT_KEY_PATTERN, KEY_ALPHABET;
1838
+ var init_project_key = __esm({
1839
+ "../shared/dist/project-key.js"() {
1840
+ "use strict";
1841
+ RESERVED_PROJECT_KEYS = /* @__PURE__ */ new Set([
1842
+ "tests",
1843
+ "sessions",
1844
+ "settings",
1845
+ "reports",
1846
+ "account",
1847
+ "notifications",
1848
+ "support",
1849
+ "site-map",
1850
+ "api-intelligence",
1851
+ "selector-registry",
1852
+ "project-knowledge",
1853
+ "test-data",
1854
+ "audit",
1855
+ "validate-copilot",
1856
+ "qa-copilot",
1857
+ "insights",
1858
+ "regression",
1859
+ "reviews",
1860
+ "admin",
1861
+ "login",
1862
+ "admin-view"
1863
+ ]);
1864
+ PROJECT_KEY_MIN_LENGTH = 2;
1865
+ PROJECT_KEY_MAX_LENGTH = 12;
1866
+ PROJECT_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,10}[a-z0-9])$/;
1867
+ KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1868
+ }
1869
+ });
1870
+
1794
1871
  // ../shared/dist/mobile-deeplink.js
1795
1872
  function isSafeDeepLink(value) {
1796
1873
  if (typeof value !== "string")
@@ -4980,6 +5057,10 @@ __export(dist_exports, {
4980
5057
  PLAN_DEFINITIONS: () => PLAN_DEFINITIONS,
4981
5058
  PLAN_ORDER: () => PLAN_ORDER,
4982
5059
  PLATFORM_ADMIN_EMAIL: () => PLATFORM_ADMIN_EMAIL,
5060
+ PROJECT_KEY_MAX_LENGTH: () => PROJECT_KEY_MAX_LENGTH,
5061
+ PROJECT_KEY_MIN_LENGTH: () => PROJECT_KEY_MIN_LENGTH,
5062
+ PROJECT_KEY_PATTERN: () => PROJECT_KEY_PATTERN,
5063
+ RESERVED_PROJECT_KEYS: () => RESERVED_PROJECT_KEYS,
4983
5064
  SELECTOR_PRIORITIES: () => SELECTOR_PRIORITIES,
4984
5065
  SHARED_PRICING_PLAN_MAP: () => SHARED_PRICING_PLAN_MAP,
4985
5066
  STT_MODEL_OPTIONS: () => STT_MODEL_OPTIONS,
@@ -5013,6 +5094,7 @@ __export(dist_exports, {
5013
5094
  controlSignature: () => controlSignature,
5014
5095
  countInnerTests: () => countInnerTests,
5015
5096
  decideHarPosted: () => decideHarPosted,
5097
+ deriveProjectKey: () => deriveProjectKey,
5016
5098
  detectCrossOriginHtmlAsJson: () => detectCrossOriginHtmlAsJson,
5017
5099
  detectsErrorBannerAssertion: () => detectsErrorBannerAssertion,
5018
5100
  effectiveTestCount: () => effectiveTestCount,
@@ -5029,6 +5111,7 @@ __export(dist_exports, {
5029
5111
  formatLimit: () => formatLimit,
5030
5112
  fromDbPricingPlan: () => fromDbPricingPlan,
5031
5113
  generateMobileTestFromSession: () => generateMobileTestFromSession,
5114
+ generateUniqueProjectKey: () => generateUniqueProjectKey,
5032
5115
  getDefaultPlanForTier: () => getDefaultPlanForTier,
5033
5116
  getDeploymentTierForPlan: () => getDeploymentTierForPlan,
5034
5117
  getPlanChangeType: () => getPlanChangeType,
@@ -5058,6 +5141,7 @@ __export(dist_exports, {
5058
5141
  isTrialExpired: () => isTrialExpired,
5059
5142
  isUIVariant: () => isUIVariant,
5060
5143
  isValidMobileAppId: () => isValidMobileAppId,
5144
+ isValidProjectKey: () => isValidProjectKey,
5061
5145
  isValidSttProvider: () => isValidSttProvider,
5062
5146
  isValidTtsModel: () => isValidTtsModel,
5063
5147
  isValidTtsVoiceId: () => isValidTtsVoiceId,
@@ -5103,6 +5187,7 @@ var init_dist = __esm({
5103
5187
  "use strict";
5104
5188
  init_types();
5105
5189
  init_constants();
5190
+ init_project_key();
5106
5191
  init_mobile_test_generation();
5107
5192
  init_mobile_step_healer();
5108
5193
  init_mobile_cross_platform();
@@ -5893,17 +5978,27 @@ var require_mobile_session_keepalive = __commonJS({
5893
5978
  "use strict";
5894
5979
  Object.defineProperty(exports, "__esModule", { value: true });
5895
5980
  exports.MOBILE_KEEPALIVE_INTERVAL_MS = void 0;
5981
+ exports.computeKeepAliveIntervalMs = computeKeepAliveIntervalMs;
5896
5982
  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
- })();
5983
+ function computeKeepAliveIntervalMs(keepaliveMsRaw, newCommandTimeoutSecondsRaw) {
5984
+ const explicit = keepaliveMsRaw?.trim() ? Number(keepaliveMsRaw) : NaN;
5985
+ if (Number.isFinite(explicit) && explicit >= 0)
5986
+ return Math.floor(explicit);
5987
+ const timeoutSeconds = Number(newCommandTimeoutSecondsRaw);
5988
+ const reapWindowMs = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? Math.floor(timeoutSeconds) * 1e3 : 6e5;
5989
+ return Math.min(12e4, Math.max(5e3, Math.floor(reapWindowMs / 3)));
5990
+ }
5991
+ exports.MOBILE_KEEPALIVE_INTERVAL_MS = computeKeepAliveIntervalMs(process.env.VALIDATEQA_MOBILE_KEEPALIVE_MS, process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
5901
5992
  async function withMobileSessionKeepAlive(driver, fn, intervalMs = exports.MOBILE_KEEPALIVE_INTERVAL_MS) {
5902
5993
  if (intervalMs <= 0)
5903
5994
  return fn();
5904
5995
  const timer = setInterval(() => {
5905
- void driver.windowSize().catch(() => {
5906
- });
5996
+ try {
5997
+ const probe = driver.keepAlivePing ? driver.keepAlivePing() : driver.windowSize();
5998
+ void probe.catch(() => {
5999
+ });
6000
+ } catch {
6001
+ }
5907
6002
  }, intervalMs);
5908
6003
  timer.unref?.();
5909
6004
  try {
@@ -73998,6 +74093,7 @@ var require_mobile_heal_loop = __commonJS({
73998
74093
  var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
73999
74094
  var mobile_observation_js_1 = require_mobile_observation();
74000
74095
  var selector_registry_context_js_1 = require_selector_registry_context();
74096
+ var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
74001
74097
  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
74098
  ## You MUST try to fix the steps
74003
74099
  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 +74985,19 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74889
74985
  };
74890
74986
  }
74891
74987
  const maxPasses = envInt2("MOBILE_HEAL_PHASE2_MAX_PASSES") ?? DEFAULT_PHASE2_MAX_PASSES;
74988
+ const keepAlivePingable = {
74989
+ ...typeof liveDriver.keepAlivePing === "function" ? { keepAlivePing: liveDriver.keepAlivePing.bind(liveDriver) } : {},
74990
+ // WARNING: this fallback routes through snapshot(), which sits inside
74991
+ // withSessionRecovery on the real driver — a failed tick could consume the
74992
+ // bounded recovery budget, the exact bug keepAlivePing exists to avoid.
74993
+ // Every current MobileLiveDriverLike provides keepAlivePing, so this only
74994
+ // exists for structural compatibility; implement keepAlivePing on any new
74995
+ // facade rather than relying on it.
74996
+ windowSize: async () => {
74997
+ await liveDriver.snapshot();
74998
+ return null;
74999
+ }
75000
+ };
74892
75001
  let baseSteps = args.steps;
74893
75002
  let priorError = args.priorError ?? null;
74894
75003
  let bestSteps;
@@ -74923,10 +75032,10 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
74923
75032
  ];
74924
75033
  let content;
74925
75034
  try {
74926
- const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
75035
+ 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
75036
  arm();
74928
75037
  return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
74929
- }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true });
75038
+ }), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true }));
74930
75039
  content = response?.choices?.[0]?.message?.content ?? "";
74931
75040
  } catch {
74932
75041
  continue;
@@ -84729,6 +84838,51 @@ var require_discover_generator = __commonJS({
84729
84838
  }
84730
84839
  return s.length;
84731
84840
  }
84841
+ function lineParenDeficit(line) {
84842
+ const stack = [];
84843
+ let regexSkips = 0;
84844
+ for (let i = 0; i < line.length; i++) {
84845
+ const ch = line[i];
84846
+ if (ch === "'" || ch === '"' || ch === "`") {
84847
+ i = skipQuotedLiteral(line, i) - 1;
84848
+ continue;
84849
+ }
84850
+ if (ch === "/" && line[i + 1] === "/") {
84851
+ i = skipLineComment(line, i) - 1;
84852
+ continue;
84853
+ }
84854
+ if (ch === "/" && line[i + 1] === "*") {
84855
+ i = skipBlockComment(line, i) - 1;
84856
+ continue;
84857
+ }
84858
+ if (ch === "/" && isRegexStart(line, i)) {
84859
+ regexSkips++;
84860
+ i = skipRegexLiteral(line, i) - 1;
84861
+ continue;
84862
+ }
84863
+ if (ch === "(" || ch === "{" || ch === "[") {
84864
+ stack.push(ch);
84865
+ continue;
84866
+ }
84867
+ if (ch === ")" || ch === "}" || ch === "]") {
84868
+ if (stack[stack.length - 1] !== CLOSE_TO_OPEN[ch])
84869
+ return { deficit: 0, regexSkips };
84870
+ stack.pop();
84871
+ }
84872
+ }
84873
+ const deficit = stack.length > 0 && stack.every((open) => open === "(") ? stack.length : 0;
84874
+ return { deficit, regexSkips };
84875
+ }
84876
+ function parsesAsAsyncStatement(line) {
84877
+ try {
84878
+ new Function(`return (async () => {
84879
+ ${line}
84880
+ })`);
84881
+ return true;
84882
+ } catch {
84883
+ return false;
84884
+ }
84885
+ }
84732
84886
  function closeUnclosedRegexLiterals(code) {
84733
84887
  const anchors = [
84734
84888
  { rx: /\b(?:waitForURL|toHaveURL)\(\//g, mode: "url" },
@@ -84767,12 +84921,33 @@ var require_discover_generator = __commonJS({
84767
84921
  const term = findRegexTerminator(code, slash, TERMINATORS[mode]);
84768
84922
  out.push(code.slice(cursor, slash));
84769
84923
  const raw = code.slice(slash + 1, term).replace(/\s+$/, "");
84924
+ let closedLiteral;
84770
84925
  if (mode === "url") {
84771
84926
  const escaped = raw.replace(/(?<!\\)\//g, "\\/");
84772
84927
  const finalBody = escaped.startsWith("\\/") ? escaped : `\\/${escaped}`;
84773
- out.push(`/${finalBody}/`);
84928
+ closedLiteral = `/${finalBody}/`;
84774
84929
  } else {
84775
- out.push(`/${raw}/`);
84930
+ closedLiteral = `/${raw}/`;
84931
+ }
84932
+ out.push(closedLiteral);
84933
+ if (code[term] === ")") {
84934
+ const lineStart = code.lastIndexOf("\n", slash) + 1;
84935
+ const nextNewline = code.indexOf("\n", term);
84936
+ const lineEnd = nextNewline === -1 ? code.length : nextNewline;
84937
+ const tail = code.slice(term, lineEnd);
84938
+ const physicalLine = code.slice(lineStart, lineEnd);
84939
+ const lineIsPlain = !physicalLine.includes("`") && !physicalLine.includes("/*") && !physicalLine.includes("*/");
84940
+ const soleAnchorOnLine = !hits.some((h) => h.slash !== slash && h.slash >= lineStart && h.slash < lineEnd);
84941
+ if (lineIsPlain && soleAnchorOnLine && /;\s*$/.test(tail)) {
84942
+ const repairedLine = code.slice(lineStart, slash) + "/x/" + tail;
84943
+ const { deficit, regexSkips } = lineParenDeficit(repairedLine);
84944
+ if (deficit > 0 && deficit <= 3 && regexSkips === 1) {
84945
+ const candidateLine = code.slice(lineStart, slash) + closedLiteral + ")".repeat(deficit) + tail;
84946
+ if (parsesAsAsyncStatement(candidateLine)) {
84947
+ out.push(")".repeat(deficit));
84948
+ }
84949
+ }
84950
+ }
84776
84951
  }
84777
84952
  cursor = term;
84778
84953
  }
@@ -337150,7 +337325,7 @@ var require_package4 = __commonJS({
337150
337325
  "package.json"(exports, module) {
337151
337326
  module.exports = {
337152
337327
  name: "@validate.qa/runner",
337153
- version: "1.0.40",
337328
+ version: "1.0.41",
337154
337329
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
337155
337330
  bin: {
337156
337331
  "validate-runner": "dist/cli.js"
@@ -337159,7 +337334,7 @@ var require_package4 = __commonJS({
337159
337334
  build: "tsup",
337160
337335
  dev: "tsx src/cli.ts",
337161
337336
  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",
337337
+ "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
337338
  prepublishOnly: "npm run build"
337164
337339
  },
337165
337340
  files: [
@@ -343310,6 +343485,17 @@ var MobileBridgeDriver = class {
343310
343485
  return size;
343311
343486
  });
343312
343487
  }
343488
+ /**
343489
+ * Recovery-free liveness probe for keep-alive ticks (mobile-session-keepalive).
343490
+ * Deliberately NOT wrapped in withSessionRecovery: keep-alive ticks are
343491
+ * fire-and-forget, so a failing probe must never consume the bounded recovery
343492
+ * budget or relaunch the app from the background — a genuinely dead session
343493
+ * surfaces (with its precise error) on the caller's next REAL command, which
343494
+ * still runs the full recovery path.
343495
+ */
343496
+ async keepAlivePing() {
343497
+ await this.assertSessionReachable();
343498
+ }
343313
343499
  // ── Action result wrapper ──────────────────────────────
343314
343500
  /**
343315
343501
  * Run an action body, let the UI tree settle, then capture the post-action
@@ -345867,6 +346053,7 @@ ${finalVerifyResult.logs ?? ""}`;
345867
346053
  setExecutorBusy(true);
345868
346054
  let session = null;
345869
346055
  let capture = null;
346056
+ let discoveryTeardownStarted = false;
345870
346057
  try {
345871
346058
  const discoverySessionTarget = {
345872
346059
  appId: mobileTarget.appId,
@@ -345918,6 +346105,9 @@ ${finalVerifyResult.logs ?? ""}`;
345918
346105
  allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId),
345919
346106
  maxSessionRecoveries: 1,
345920
346107
  recoverSession: async (cause) => {
346108
+ if (discoveryTeardownStarted) {
346109
+ throw new Error("discovery teardown already started \u2014 not recreating the Appium session");
346110
+ }
345921
346111
  const lost = session;
345922
346112
  mobileOnLog(
345923
346113
  `[appium] discovery session lost (${cause.message}); recreating once on the leased device`
@@ -345931,6 +346121,13 @@ ${finalVerifyResult.logs ?? ""}`;
345931
346121
  }
345932
346122
  }
345933
346123
  const replacement = await openDiscoverySession();
346124
+ if (discoveryTeardownStarted) {
346125
+ try {
346126
+ await replacement.stop();
346127
+ } catch {
346128
+ }
346129
+ throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
346130
+ }
345934
346131
  session = replacement;
345935
346132
  attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
345936
346133
  liveBrowserHandle.patch({
@@ -345970,6 +346167,7 @@ ${finalVerifyResult.logs ?? ""}`;
345970
346167
  onPlanReady
345971
346168
  });
345972
346169
  } finally {
346170
+ discoveryTeardownStarted = true;
345973
346171
  const TEARDOWN_STEP_TIMEOUT = /* @__PURE__ */ Symbol("vqa-teardown-timeout");
345974
346172
  const timeboxed = async (step) => Promise.race([
345975
346173
  step,
@@ -346005,8 +346203,12 @@ ${finalVerifyResult.logs ?? ""}`;
346005
346203
  }
346006
346204
  if (session) {
346007
346205
  detachMirrorSession(session.deviceId);
346206
+ const closing = session;
346008
346207
  try {
346009
- await timeboxed(session.stop());
346208
+ const stopped = await timeboxed(closing.stop());
346209
+ if (stopped === TEARDOWN_STEP_TIMEOUT) {
346210
+ 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`);
346211
+ }
346010
346212
  } catch {
346011
346213
  }
346012
346214
  }
@@ -346444,7 +346646,13 @@ ${finalVerifyResult.logs ?? ""}`;
346444
346646
  relaunch: async () => {
346445
346647
  await currentDriver().relaunch();
346446
346648
  },
346447
- resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState()
346649
+ resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : currentDriver().resetAppState(),
346650
+ // Recovery-free liveness probe so the loop's keep-alive can span
346651
+ // long AI waits without touching the driver's recovery machinery
346652
+ // (see mobile-session-keepalive.ts).
346653
+ keepAlivePing: async () => {
346654
+ await currentDriver().keepAlivePing();
346655
+ }
346448
346656
  };
346449
346657
  const runNativeWithSessionHandoff = async (verifyArgs) => {
346450
346658
  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.41",
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": [