@validate.qa/runner 1.0.71 → 1.0.73

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 +315 -88
  2. package/dist/cli.mjs +315 -88
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5854,6 +5854,29 @@ function normalizeTtsVoiceId(voiceId) {
5854
5854
  return null;
5855
5855
  return LEGACY_TTS_VOICE_ID_MAP[trimmed] ?? trimmed;
5856
5856
  }
5857
+ function normalizeTtsModel(model) {
5858
+ if (!model)
5859
+ return null;
5860
+ const trimmed = model.trim();
5861
+ if (!trimmed)
5862
+ return null;
5863
+ return LEGACY_TTS_MODEL_MAP[trimmed] ?? trimmed;
5864
+ }
5865
+ function ttsFallbackModel(primary) {
5866
+ const normalized = normalizeTtsModel(primary);
5867
+ if (!normalized)
5868
+ return null;
5869
+ return TTS_FALLBACK_MODEL_MAP[normalized] ?? null;
5870
+ }
5871
+ function resolveTtsModelChain(model) {
5872
+ const normalized = normalizeTtsModel(model);
5873
+ const primary = normalized && normalized in TTS_FALLBACK_MODEL_MAP ? normalized : DEFAULT_TTS_MODEL;
5874
+ const chain = [primary];
5875
+ const fallback = TTS_FALLBACK_MODEL_MAP[primary];
5876
+ if (fallback && fallback !== primary)
5877
+ chain.push(fallback);
5878
+ return chain;
5879
+ }
5857
5880
  function sttModelsForProvider(provider) {
5858
5881
  if (provider === "openai") {
5859
5882
  return STT_MODEL_OPTIONS.filter((m) => m.provider === "openai");
@@ -5901,16 +5924,18 @@ function isValidTtsVoiceId(value) {
5901
5924
  return TTS_VOICE_OPTIONS.some((v) => v.id === normalized);
5902
5925
  }
5903
5926
  function isValidTtsModel(value) {
5904
- if (!value)
5927
+ const normalized = normalizeTtsModel(value);
5928
+ if (!normalized)
5905
5929
  return false;
5906
- return TTS_MODEL_OPTIONS.some((m) => m.id === value);
5930
+ return TTS_MODEL_OPTIONS.some((m) => m.id === normalized);
5907
5931
  }
5908
- var VOICE_PREVIEW_SAMPLE, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE_ID, DEFAULT_TTS_SPEED, LEGACY_TTS_VOICE_ID_MAP, DEFAULT_STT_MODEL_BY_PROVIDER, STT_PROVIDER_OPTIONS, STT_MODEL_OPTIONS, TTS_MODEL_OPTIONS, TTS_VOICE_OPTIONS;
5932
+ var VOICE_PREVIEW_SAMPLE, DEFAULT_TTS_MODEL, DEFAULT_TTS_TURBO_MODEL, DEFAULT_TTS_VOICE_ID, DEFAULT_TTS_SPEED, LEGACY_TTS_VOICE_ID_MAP, LEGACY_TTS_MODEL_MAP, TTS_FALLBACK_MODEL_MAP, DEFAULT_STT_MODEL_BY_PROVIDER, STT_PROVIDER_OPTIONS, STT_MODEL_OPTIONS, TTS_MODEL_OPTIONS, TTS_VOICE_OPTIONS;
5909
5933
  var init_voice_config = __esm({
5910
5934
  "../shared/dist/voice-config.js"() {
5911
5935
  "use strict";
5912
5936
  VOICE_PREVIEW_SAMPLE = "Hello! I am your Validate Copilot assistant. I can help you run tests, review failures, and generate new coverage for your project.";
5913
- DEFAULT_TTS_MODEL = "speech-2.6-hd";
5937
+ DEFAULT_TTS_MODEL = "speech-2.8-hd";
5938
+ DEFAULT_TTS_TURBO_MODEL = "speech-2.8-turbo";
5914
5939
  DEFAULT_TTS_VOICE_ID = "English_Graceful_Lady";
5915
5940
  DEFAULT_TTS_SPEED = 1;
5916
5941
  LEGACY_TTS_VOICE_ID_MAP = {
@@ -5919,6 +5944,18 @@ var init_voice_config = __esm({
5919
5944
  English_Deep_Voice_Man: "English_ManWithDeepVoice",
5920
5945
  English_CasualMan: "English_Jovialman"
5921
5946
  };
5947
+ LEGACY_TTS_MODEL_MAP = {
5948
+ "speech-2.6-hd": "speech-2.8-hd",
5949
+ "speech-2.6-turbo": "speech-2.8-turbo",
5950
+ "speech-02-hd": "speech-2.8-hd",
5951
+ "speech-02-turbo": "speech-2.8-turbo",
5952
+ "speech-01-hd": "speech-2.8-hd",
5953
+ "speech-01-turbo": "speech-2.8-turbo"
5954
+ };
5955
+ TTS_FALLBACK_MODEL_MAP = {
5956
+ "speech-2.8-hd": "speech-2.6-hd",
5957
+ "speech-2.8-turbo": "speech-2.6-turbo"
5958
+ };
5922
5959
  DEFAULT_STT_MODEL_BY_PROVIDER = {
5923
5960
  groq: "whisper-large-v3-turbo",
5924
5961
  openai: "whisper-1"
@@ -5971,49 +6008,13 @@ var init_voice_config = __esm({
5971
6008
  id: "speech-2.8-hd",
5972
6009
  label: "Speech 2.8 HD",
5973
6010
  provider: "minimax",
5974
- description: "Latest high-definition model with interjection tags (recommended)."
6011
+ description: "High-definition quality (default). Auto-falls back to Speech 2.6 HD."
5975
6012
  },
5976
6013
  {
5977
6014
  id: "speech-2.8-turbo",
5978
6015
  label: "Speech 2.8 Turbo",
5979
6016
  provider: "minimax",
5980
- description: "Latest low-latency model with interjection tags."
5981
- },
5982
- {
5983
- id: "speech-2.6-hd",
5984
- label: "Speech 2.6 HD",
5985
- provider: "minimax",
5986
- description: "High-definition MiniMax voice model."
5987
- },
5988
- {
5989
- id: "speech-2.6-turbo",
5990
- label: "Speech 2.6 Turbo",
5991
- provider: "minimax",
5992
- description: "Lower-latency Speech 2.6 variant."
5993
- },
5994
- {
5995
- id: "speech-02-hd",
5996
- label: "Speech 02 HD",
5997
- provider: "minimax",
5998
- description: "Previous-generation HD model."
5999
- },
6000
- {
6001
- id: "speech-02-turbo",
6002
- label: "Speech 02 Turbo",
6003
- provider: "minimax",
6004
- description: "Lower-latency Speech 02 variant."
6005
- },
6006
- {
6007
- id: "speech-01-hd",
6008
- label: "Speech 01 HD",
6009
- provider: "minimax",
6010
- description: "Legacy HD model."
6011
- },
6012
- {
6013
- id: "speech-01-turbo",
6014
- label: "Speech 01 Turbo",
6015
- provider: "minimax",
6016
- description: "Legacy fast model."
6017
+ description: "Lower latency. Auto-falls back to Speech 2.6 Turbo."
6017
6018
  }
6018
6019
  ];
6019
6020
  TTS_VOICE_OPTIONS = [
@@ -6297,6 +6298,7 @@ __export(dist_exports, {
6297
6298
  DEFAULT_STT_MODEL_BY_PROVIDER: () => DEFAULT_STT_MODEL_BY_PROVIDER,
6298
6299
  DEFAULT_TTS_MODEL: () => DEFAULT_TTS_MODEL,
6299
6300
  DEFAULT_TTS_SPEED: () => DEFAULT_TTS_SPEED,
6301
+ DEFAULT_TTS_TURBO_MODEL: () => DEFAULT_TTS_TURBO_MODEL,
6300
6302
  DEFAULT_TTS_VOICE_ID: () => DEFAULT_TTS_VOICE_ID,
6301
6303
  DEFAULT_UI_VARIANT: () => DEFAULT_UI_VARIANT,
6302
6304
  EMAIL_FROM_IDENTITIES: () => EMAIL_FROM_IDENTITIES,
@@ -6306,6 +6308,7 @@ __export(dist_exports, {
6306
6308
  FLOW_SERVER_TYPES: () => FLOW_SERVER_TYPES,
6307
6309
  GROUPABLE_CALL_TYPES: () => GROUPABLE_CALL_TYPES,
6308
6310
  GROUP_LABELS: () => GROUP_LABELS,
6311
+ LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6309
6312
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6310
6313
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
6311
6314
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
@@ -6339,6 +6342,7 @@ __export(dist_exports, {
6339
6342
  TEST_USER_ENV_KEYS: () => TEST_USER_ENV_KEYS,
6340
6343
  TRANSIENT_CONTROL_ROLES: () => TRANSIENT_CONTROL_ROLES,
6341
6344
  TRIAL_DURATION_DAYS: () => TRIAL_DURATION_DAYS,
6345
+ TTS_FALLBACK_MODEL_MAP: () => TTS_FALLBACK_MODEL_MAP,
6342
6346
  TTS_MODEL_OPTIONS: () => TTS_MODEL_OPTIONS,
6343
6347
  TTS_VOICE_OPTIONS: () => TTS_VOICE_OPTIONS,
6344
6348
  UI_VARIANTS: () => UI_VARIANTS,
@@ -6441,6 +6445,7 @@ __export(dist_exports, {
6441
6445
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
6442
6446
  mobileWanderTraceEnabled: () => mobileWanderTraceEnabled,
6443
6447
  normalizeControlName: () => normalizeControlName,
6448
+ normalizeTtsModel: () => normalizeTtsModel,
6444
6449
  normalizeTtsVoiceId: () => normalizeTtsVoiceId,
6445
6450
  otherMobilePlatform: () => otherMobilePlatform,
6446
6451
  parenDepthDelta: () => parenDepthDelta,
@@ -6459,6 +6464,7 @@ __export(dist_exports, {
6459
6464
  resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
6460
6465
  resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
6461
6466
  resolveSttModelForProvider: () => resolveSttModelForProvider,
6467
+ resolveTtsModelChain: () => resolveTtsModelChain,
6462
6468
  safeCurrentUrl: () => safeCurrentUrl,
6463
6469
  safeOrigin: () => safeOrigin,
6464
6470
  sanitizeSttErrorMessage: () => sanitizeSttErrorMessage,
@@ -6481,6 +6487,7 @@ __export(dist_exports, {
6481
6487
  summarizeMobileReadiness: () => summarizeMobileReadiness,
6482
6488
  summarizeRunHealth: () => summarizeRunHealth,
6483
6489
  toDbPricingPlan: () => toDbPricingPlan,
6490
+ ttsFallbackModel: () => ttsFallbackModel,
6484
6491
  validateAssertionStrength: () => validateAssertionStrength
6485
6492
  });
6486
6493
  var init_dist = __esm({
@@ -11491,7 +11498,7 @@ var require_thread_stream = __commonJS({
11491
11498
  "use strict";
11492
11499
  var { version } = require_package();
11493
11500
  var { EventEmitter: EventEmitter2 } = require("events");
11494
- var { Worker } = require("worker_threads");
11501
+ var { Worker: Worker2 } = require("worker_threads");
11495
11502
  var { join: join8 } = require("path");
11496
11503
  var { pathToFileURL } = require("url");
11497
11504
  var { wait } = require_wait();
@@ -11544,7 +11551,7 @@ var require_thread_stream = __commonJS({
11544
11551
  const { filename, workerData } = opts;
11545
11552
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
11546
11553
  const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11547
- const worker = new Worker(toExecute, {
11554
+ const worker = new Worker2(toExecute, {
11548
11555
  ...opts.workerOpts,
11549
11556
  name: opts.workerOpts?.name || "thread-stream",
11550
11557
  trackUnmanagedFds: false,
@@ -74644,6 +74651,7 @@ var require_mobile_explorer = __commonJS({
74644
74651
  Object.defineProperty(exports2, "__esModule", { value: true });
74645
74652
  exports2.computeMobileSurveyBreadthContinuation = computeMobileSurveyBreadthContinuation;
74646
74653
  exports2.computeMobileBreadthFinishRedirectDecision = computeMobileBreadthFinishRedirectDecision;
74654
+ exports2.hybridScreenFingerprint = hybridScreenFingerprint;
74647
74655
  exports2.runMobileExplorePhase = runMobileExplorePhase;
74648
74656
  var crypto_1 = require("crypto");
74649
74657
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
@@ -75097,11 +75105,12 @@ var require_mobile_explorer = __commonJS({
75097
75105
  const validSurfaceDigest = /^[0-9a-f]{16,64}$/.test(normalizedSurfaceDigest) ? normalizedSurfaceDigest : void 0;
75098
75106
  const legacyElementDigests = [...new Set(elements.map((element) => element.pageIdentityDigest?.trim().toLowerCase() ?? "").filter((digest) => /^[0-9a-f]{16,64}$/.test(digest)))];
75099
75107
  const pageIdentityDigest = validSurfaceDigest ?? (legacyElementDigests.length === 1 ? legacyElementDigests[0] : void 0);
75108
+ const hasStableControlIdentity = elements.some((element) => Boolean(safeHybridIdentity(element.id, credentials) || safeHybridIdentity(element.name, credentials)));
75100
75109
  const descriptors = elements.map((element) => {
75101
75110
  const editable = element.editable === true || /^(?:input|textarea|select)$/i.test(element.tag) || /(?:textbox|searchbox|combobox)/i.test(element.role);
75102
75111
  const id = safeHybridIdentity(element.id, credentials)?.toLowerCase() ?? "";
75103
75112
  const name = safeHybridIdentity(element.name, credentials)?.toLowerCase() ?? "";
75104
- const semanticText = editable || id || name ? "" : stableHybridSemanticText(element.text, credentials);
75113
+ const semanticText = editable || id || name || hasStableControlIdentity ? "" : stableHybridSemanticText(element.text, credentials);
75105
75114
  return [
75106
75115
  element.tag.trim().toLowerCase(),
75107
75116
  element.role.trim().toLowerCase(),
@@ -83740,6 +83749,14 @@ ${statePacket}`, redactionCredentials) },
83740
83749
  return actions.filter((_, index) => !toDrop.has(index));
83741
83750
  }
83742
83751
  var MAX_DETOUR_LENGTH = 6;
83752
+ function isDetourClosingAction(action) {
83753
+ if (action.type === "BACK" || action.type === "HOME")
83754
+ return true;
83755
+ return isNavigationChromeTap(action);
83756
+ }
83757
+ function isEvidenceAction(action) {
83758
+ return action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "WAIT_FOR_ELEMENT";
83759
+ }
83743
83760
  function elideNoOpDetours(actions) {
83744
83761
  if (actions.length < 2)
83745
83762
  return actions;
@@ -83747,12 +83764,16 @@ ${statePacket}`, redactionCredentials) },
83747
83764
  for (let j = 0; j < actions.length; j++) {
83748
83765
  if (toDrop.has(j))
83749
83766
  continue;
83750
- if (actions[j].type !== "BACK" && actions[j].type !== "HOME")
83767
+ if (!isDetourClosingAction(actions[j]))
83751
83768
  continue;
83752
83769
  const keyAfter = actions[j].metadata.structuralKey;
83753
83770
  if (!keyAfter)
83754
83771
  continue;
83772
+ const next = actions[j + 1];
83773
+ if (next && isEvidenceAction(next))
83774
+ continue;
83755
83775
  let bestStart = null;
83776
+ let leftScreen = false;
83756
83777
  const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83757
83778
  for (let i = j; i >= earliestStart; i--) {
83758
83779
  if (i < j) {
@@ -83760,11 +83781,14 @@ ${statePacket}`, redactionCredentials) },
83760
83781
  if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83761
83782
  break;
83762
83783
  }
83784
+ const insideKey = inside.metadata.structuralKey;
83785
+ if (insideKey && insideKey !== keyAfter)
83786
+ leftScreen = true;
83763
83787
  }
83764
83788
  if (i === 0)
83765
83789
  continue;
83766
83790
  const keyBefore = actions[i - 1].metadata.structuralKey;
83767
- if (i < j && keyBefore && keyBefore === keyAfter) {
83791
+ if (i < j && leftScreen && keyBefore && keyBefore === keyAfter) {
83768
83792
  bestStart = i;
83769
83793
  }
83770
83794
  }
@@ -352990,7 +353014,7 @@ var require_package4 = __commonJS({
352990
353014
  "package.json"(exports2, module2) {
352991
353015
  module2.exports = {
352992
353016
  name: "@validate.qa/runner",
352993
- version: "1.0.71",
353017
+ version: "1.0.73",
352994
353018
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
352995
353019
  bin: {
352996
353020
  "validate-runner": "dist/cli.js"
@@ -354654,6 +354678,27 @@ async function clickCssInCurrentContext(sessionPath, css) {
354654
354678
  body: "{}"
354655
354679
  });
354656
354680
  }
354681
+ async function clickByVisibleTextInCurrentContext(sessionPath, text) {
354682
+ const script = `
354683
+ var t = String(arguments[0]).trim().replace(/\\s+/g, ' ');
354684
+ var sel = 'button, a, [role="tab"], [role="button"], [onclick], .stream-tab, .ux-interactive, input[type="submit"], input[type="button"]';
354685
+ var norm = function (e) { return (e.textContent || e.value || '').trim().replace(/\\s+/g, ' '); };
354686
+ var vis = function (e) { var r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
354687
+ var all = Array.prototype.slice.call(document.querySelectorAll(sel));
354688
+ var match = all.filter(function (e) { return norm(e) === t && vis(e); });
354689
+ if (match.length !== 1) { return match.length; }
354690
+ match[0].click();
354691
+ return 1;
354692
+ `;
354693
+ const result = await appiumRequest(`${sessionPath}/execute/sync`, {
354694
+ method: "POST",
354695
+ body: JSON.stringify({ script, args: [text] })
354696
+ });
354697
+ const count = readValue(result);
354698
+ if (count !== 1) {
354699
+ throw new Error(`WEBVIEW text click found ${count === 0 || count === void 0 ? "no" : String(count)} actionable match(es) for "${text}"`);
354700
+ }
354701
+ }
354657
354702
  async function typeCssInCurrentContext(sessionPath, css, text) {
354658
354703
  const elResult = await appiumRequest(`${sessionPath}/element`, {
354659
354704
  method: "POST",
@@ -354802,7 +354847,11 @@ async function pressHome(sessionPath, platform3) {
354802
354847
  }
354803
354848
 
354804
354849
  // src/services/device-quarantine.ts
354805
- var quarantinedDevices = /* @__PURE__ */ new Set();
354850
+ var QUARANTINE_MAX_MS = (() => {
354851
+ const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
354852
+ return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
354853
+ })();
354854
+ var quarantinedAt = /* @__PURE__ */ new Map();
354806
354855
  var DeviceQuarantinedError = class extends Error {
354807
354856
  constructor(deviceId) {
354808
354857
  super(
@@ -354811,17 +354860,27 @@ var DeviceQuarantinedError = class extends Error {
354811
354860
  this.name = "DeviceQuarantinedError";
354812
354861
  }
354813
354862
  };
354814
- function quarantineDevice(deviceId) {
354815
- quarantinedDevices.add(deviceId);
354863
+ function quarantineDevice(deviceId, now = Date.now()) {
354864
+ quarantinedAt.set(deviceId, now);
354816
354865
  }
354817
354866
  function unquarantineDevice(deviceId) {
354818
- quarantinedDevices.delete(deviceId);
354867
+ quarantinedAt.delete(deviceId);
354819
354868
  }
354820
- function isDeviceQuarantined(deviceId) {
354821
- return typeof deviceId === "string" && quarantinedDevices.has(deviceId);
354869
+ function isDeviceQuarantined(deviceId, now = Date.now()) {
354870
+ if (typeof deviceId !== "string") return false;
354871
+ const since = quarantinedAt.get(deviceId);
354872
+ if (since === void 0) return false;
354873
+ if (now - since >= QUARANTINE_MAX_MS) {
354874
+ quarantinedAt.delete(deviceId);
354875
+ console.warn(
354876
+ `[device-quarantine] Quarantine on ${deviceId} exceeded its ${Math.round(QUARANTINE_MAX_MS / 6e4)}min ceiling without the original run settling \u2014 lifting it so the device is usable again. If this recurs, the zombie run is wedged in non-device work (check the runner log).`
354877
+ );
354878
+ return false;
354879
+ }
354880
+ return true;
354822
354881
  }
354823
- function assertDeviceNotQuarantined(deviceId) {
354824
- if (isDeviceQuarantined(deviceId)) {
354882
+ function assertDeviceNotQuarantined(deviceId, now = Date.now()) {
354883
+ if (isDeviceQuarantined(deviceId, now)) {
354825
354884
  throw new DeviceQuarantinedError(deviceId);
354826
354885
  }
354827
354886
  }
@@ -356998,6 +357057,48 @@ function stepWebViewContext(step) {
356998
357057
  const meta = step.metadata && typeof step.metadata === "object" ? step.metadata : null;
356999
357058
  return typeof meta?.webviewContext === "string" && meta.webviewContext.trim() ? meta.webviewContext.trim() : null;
357000
357059
  }
357060
+ var DOM_ID_RE = /^[A-Za-z_][\w-]*$/;
357061
+ var SYSTEM_RESOURCE_ID_RE = /^(?:android|com\.android\.|com\.google\.android\.)/i;
357062
+ function webViewCssFallbackFromNativeStep(step) {
357063
+ if (stepWebViewCss(step)) return null;
357064
+ const target = step.target;
357065
+ if (!target) return null;
357066
+ const bundle = target.locatorBundle;
357067
+ const resourceId = typeof bundle?.resourceId === "string" ? bundle.resourceId.trim() : "";
357068
+ const accessibilityId = typeof bundle?.accessibilityId === "string" ? bundle.accessibilityId.trim() : "";
357069
+ const primary = typeof target.primary === "string" ? target.primary.trim() : "";
357070
+ const fromResourceId = (raw) => {
357071
+ if (!raw) return null;
357072
+ if (raw.includes(":id/")) {
357073
+ if (SYSTEM_RESOURCE_ID_RE.test(raw)) return null;
357074
+ const local = raw.slice(raw.indexOf(":id/") + 4);
357075
+ return DOM_ID_RE.test(local) ? local : null;
357076
+ }
357077
+ return DOM_ID_RE.test(raw) ? raw : null;
357078
+ };
357079
+ const domId = fromResourceId(resourceId) ?? fromResourceId(accessibilityId) ?? (primary && !/[=~/[\].\s]/.test(primary) && DOM_ID_RE.test(primary) ? primary : null);
357080
+ return domId ? `#${domId}` : null;
357081
+ }
357082
+ function webViewTextFallbackFromNativeStep(step) {
357083
+ if (stepWebViewCss(step)) return null;
357084
+ if (step.action !== "tap") return null;
357085
+ const target = step.target;
357086
+ if (!target) return null;
357087
+ const bundle = target.locatorBundle;
357088
+ const text = typeof bundle?.text === "string" && bundle.text.trim() ? bundle.text.trim() : typeof bundle?.contentDesc === "string" && bundle.contentDesc.trim() ? bundle.contentDesc.trim() : typeof target.primary === "string" && target.primary.trim() && !/[=~/[\]]|:id\//.test(target.primary) ? target.primary.trim() : "";
357089
+ const normalized = text.replace(/\s+/g, " ");
357090
+ if (!normalized || normalized.length > 40 || /[\n\r]/.test(text)) return null;
357091
+ if (SYSTEM_PERMISSION_BUTTON_RE.test(text)) return null;
357092
+ return normalized;
357093
+ }
357094
+ async function hasLiveWebViewContext(sessionId) {
357095
+ try {
357096
+ const contexts = await listAppContexts(`/session/${sessionId}`);
357097
+ return contexts.some((c) => /^WEBVIEW/i.test(c));
357098
+ } catch {
357099
+ return false;
357100
+ }
357101
+ }
357001
357102
  var CONTAINER_ONLY_ENTRY_ID_RE = /(?:^|[:/])id\/(webview|content|decor|decor_content_parent|action_bar_root|statusBarBackground|navigationBarBackground)$|^(?:main-content|root|app|app-root|__next|react-root|application)$/i;
357002
357103
  function isContainerOnlyEntryTarget(target) {
357003
357104
  const primary = target?.primary?.trim() ?? "";
@@ -357085,6 +357186,7 @@ function isWebViewHydrationTransient(err) {
357085
357186
  return /no such element|NoSuchElement|element not interactable|stale element|WEBVIEW step requires|Empty page dictionary|not connectable|no WEBVIEW context|chrome not reachable/i.test(message);
357086
357187
  }
357087
357188
  var WEBVIEW_HYDRATION_TIMEOUT_MS = Number(process.env.VALIDATEQA_WEBVIEW_HYDRATION_TIMEOUT_MS) || 3e4;
357189
+ var PERMISSION_DIALOG_TIMEOUT_MS = Number(process.env.VALIDATEQA_PERMISSION_DIALOG_TIMEOUT_MS) || 3e3;
357088
357190
  async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, body, timeoutMs) {
357089
357191
  const deadline = Date.now() + Math.max(1e3, timeoutMs);
357090
357192
  for (; ; ) {
@@ -357097,6 +357199,17 @@ async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, b
357097
357199
  }
357098
357200
  }
357099
357201
  }
357202
+ var SYSTEM_PERMISSION_BUTTON_RE = /com\.android\.permissioncontroller:id\/permission_(allow|deny)|com\.android\.packageinstaller:id\/permission_|:id\/permission_allow_(foreground_only|one_time)_button|^(?:com\.apple\.springboard|SpringBoard)/i;
357203
+ function isSystemPermissionButtonTarget(target) {
357204
+ if (!target) return false;
357205
+ const bundle = target.locatorBundle;
357206
+ const candidates = [
357207
+ target.primary,
357208
+ typeof bundle?.resourceId === "string" ? bundle.resourceId : "",
357209
+ typeof bundle?.xpath === "string" ? bundle.xpath : ""
357210
+ ];
357211
+ return candidates.some((c) => typeof c === "string" && SYSTEM_PERMISSION_BUTTON_RE.test(c));
357212
+ }
357100
357213
  async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
357101
357214
  const webCss = stepWebViewCss(step);
357102
357215
  if (webCss) {
@@ -357109,10 +357222,11 @@ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
357109
357222
  );
357110
357223
  return;
357111
357224
  }
357225
+ const isPermissionTap = isSystemPermissionButtonTarget(step.target);
357112
357226
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
357113
357227
  if (step.target) {
357114
357228
  try {
357115
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357229
+ const elementId = await findElement(sessionId, step.target, isPermissionTap ? Math.min(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, PERMISSION_DIALOG_TIMEOUT_MS) : readStepTimeout(step), {
357116
357230
  platform: platform3,
357117
357231
  recordedBounds: bounds
357118
357232
  });
@@ -357122,7 +357236,23 @@ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
357122
357236
  });
357123
357237
  return;
357124
357238
  } catch (error2) {
357125
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
357239
+ if (isTransportError(error2) || error2 instanceof MobileAmbiguousMatchError) throw error2;
357240
+ if (isPermissionTap) return;
357241
+ const cssFallback = webViewCssFallbackFromNativeStep(step);
357242
+ const textFallback = cssFallback ? null : webViewTextFallbackFromNativeStep(step);
357243
+ if ((cssFallback || textFallback) && await hasLiveWebViewContext(sessionId)) {
357244
+ try {
357245
+ if (cssFallback) {
357246
+ await executeInWebViewContext(sessionId, step, cssFallback, (sessionPath, css) => clickCssInCurrentContext(sessionPath, css));
357247
+ } else if (textFallback) {
357248
+ await executeInWebViewContext(sessionId, step, textFallback, (sessionPath) => clickByVisibleTextInCurrentContext(sessionPath, textFallback));
357249
+ }
357250
+ return;
357251
+ } catch (webErr) {
357252
+ if (isTransportError(webErr)) throw webErr;
357253
+ }
357254
+ }
357255
+ if (!bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
357126
357256
  }
357127
357257
  }
357128
357258
  if (!bounds) {
@@ -357180,6 +357310,18 @@ async function executeType(sessionId, step, context) {
357180
357310
  return;
357181
357311
  } catch (error2) {
357182
357312
  if (isTransportError(error2)) throw error2;
357313
+ if (!focusEstablished && !keyboardSubmitOnly) {
357314
+ const cssFallback = webViewCssFallbackFromNativeStep(step);
357315
+ if (cssFallback && await hasLiveWebViewContext(sessionId)) {
357316
+ try {
357317
+ context.log?.(` Native locate failed; retrying as WebView ${cssFallback} (hybrid mirror fallback)`);
357318
+ await executeInWebViewContext(sessionId, step, cssFallback, (sessionPath2, css) => typeCssInCurrentContext(sessionPath2, css, value));
357319
+ return;
357320
+ } catch (webErr) {
357321
+ if (isTransportError(webErr)) throw webErr;
357322
+ }
357323
+ }
357324
+ }
357183
357325
  if (context.platform === "ANDROID" && focusEstablished) {
357184
357326
  try {
357185
357327
  context.log?.(" Appium active-element send failed; using Android focused-keyboard input fallback");
@@ -358158,6 +358300,86 @@ function validateRunnerMobileTarget(value) {
358158
358300
  };
358159
358301
  }
358160
358302
 
358303
+ // src/services/heartbeat-keepalive.ts
358304
+ var import_node_worker_threads = require("worker_threads");
358305
+ var KEEPALIVE_INTERVAL_MS = (() => {
358306
+ const raw = Number(process.env.VALIDATEQA_HEARTBEAT_KEEPALIVE_MS);
358307
+ return Number.isFinite(raw) && raw > 0 ? raw : 2e4;
358308
+ })();
358309
+ function isHeartbeatKeepaliveEnabled(env = process.env) {
358310
+ return (env.VALIDATEQA_HEARTBEAT_KEEPALIVE ?? "").trim().toLowerCase() !== "off";
358311
+ }
358312
+ var WORKER_SOURCE = `
358313
+ const { parentPort } = require('node:worker_threads');
358314
+ let url = null;
358315
+ let headers = null;
358316
+ let body = null;
358317
+ let intervalMs = 20000;
358318
+ let timer = null;
358319
+
358320
+ async function post() {
358321
+ if (!url || !headers || !body) return; // inert until the first real heartbeat
358322
+ try {
358323
+ await fetch(url, {
358324
+ method: 'POST',
358325
+ headers,
358326
+ body,
358327
+ signal: AbortSignal.timeout(10000),
358328
+ });
358329
+ } catch {
358330
+ // Best-effort by design: the next tick retries; the main loop's own
358331
+ // heartbeat error handling owns logging/upgrade signals.
358332
+ }
358333
+ }
358334
+
358335
+ parentPort.on('message', (msg) => {
358336
+ if (!msg || typeof msg !== 'object') return;
358337
+ if (msg.type === 'update') {
358338
+ url = msg.url;
358339
+ headers = msg.headers;
358340
+ body = msg.body;
358341
+ intervalMs = msg.intervalMs || intervalMs;
358342
+ if (!timer) {
358343
+ timer = setInterval(post, intervalMs);
358344
+ if (timer.unref) timer.unref();
358345
+ }
358346
+ } else if (msg.type === 'stop') {
358347
+ if (timer) clearInterval(timer);
358348
+ timer = null;
358349
+ parentPort.close();
358350
+ }
358351
+ });
358352
+ `;
358353
+ var NOOP_KEEPALIVE = { update: () => {
358354
+ }, stop: () => {
358355
+ } };
358356
+ function startHeartbeatKeepalive(options = {}) {
358357
+ if (!isHeartbeatKeepaliveEnabled(options.env)) return NOOP_KEEPALIVE;
358358
+ const intervalMs = options.intervalMs ?? KEEPALIVE_INTERVAL_MS;
358359
+ let worker;
358360
+ try {
358361
+ worker = new import_node_worker_threads.Worker(WORKER_SOURCE, { eval: true });
358362
+ worker.unref();
358363
+ worker.on("error", () => {
358364
+ worker = null;
358365
+ });
358366
+ } catch {
358367
+ return NOOP_KEEPALIVE;
358368
+ }
358369
+ return {
358370
+ update(url, headers, body) {
358371
+ worker?.postMessage({ type: "update", url, headers, body, intervalMs });
358372
+ },
358373
+ stop() {
358374
+ try {
358375
+ worker?.postMessage({ type: "stop" });
358376
+ } catch {
358377
+ }
358378
+ worker = null;
358379
+ }
358380
+ };
358381
+ }
358382
+
358161
358383
  // src/mobile-device-queue.ts
358162
358384
  function isNativeMobileDiscoveryContext(ctx) {
358163
358385
  if (!ctx) return false;
@@ -363762,6 +363984,7 @@ async function startRunner(opts) {
363762
363984
  }
363763
363985
  }
363764
363986
  };
363987
+ const heartbeatKeepalive = startHeartbeatKeepalive();
363765
363988
  const sendHeartbeat = async () => {
363766
363989
  try {
363767
363990
  const devices = enableMobile ? getCachedDeviceSnapshot() : [];
@@ -363776,41 +363999,44 @@ async function startRunner(opts) {
363776
363999
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
363777
364000
  lastInventoryKinds.clear();
363778
364001
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
363779
- const response = await fetch(`${serverUrl}/api/runner/heartbeat`, {
364002
+ const heartbeatUrl = `${serverUrl}/api/runner/heartbeat`;
364003
+ const heartbeatBody = JSON.stringify({
364004
+ runnerName: RUNNER_NAME,
364005
+ runnerVersion: RUNNER_VERSION,
364006
+ runnerBuildId: RUNNER_BUILD_ID,
364007
+ runsCompleted,
364008
+ activeRuns,
364009
+ maxConcurrent: MAX_CONCURRENT,
364010
+ uptimeMs: Date.now() - startedAt,
364011
+ capabilities,
364012
+ devices,
364013
+ mobileBridge: bridge ? {
364014
+ online: bridge.running,
364015
+ port: bridge.port,
364016
+ tunnelUrl: bridge.tunnelUrl,
364017
+ tunnelToken: bridge.tunnelToken
364018
+ } : void 0,
364019
+ activeByType: Object.fromEntries(activeByType),
364020
+ activeByProject: Object.fromEntries(activeByProject),
364021
+ typeLimits: TYPE_LIMITS,
364022
+ maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
364023
+ resourceSnapshot: resourceStatus.snapshot,
364024
+ resourceGovernor: resourceStatus.governor,
364025
+ processInventory,
364026
+ aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
364027
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
364028
+ // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
364029
+ // Cached & cheap — see getCachedDoctor above.
364030
+ doctor: getCachedDoctor()
364031
+ });
364032
+ const response = await fetch(heartbeatUrl, {
363780
364033
  method: "POST",
363781
364034
  headers,
363782
- body: JSON.stringify({
363783
- runnerName: RUNNER_NAME,
363784
- runnerVersion: RUNNER_VERSION,
363785
- runnerBuildId: RUNNER_BUILD_ID,
363786
- runsCompleted,
363787
- activeRuns,
363788
- maxConcurrent: MAX_CONCURRENT,
363789
- uptimeMs: Date.now() - startedAt,
363790
- capabilities,
363791
- devices,
363792
- mobileBridge: bridge ? {
363793
- online: bridge.running,
363794
- port: bridge.port,
363795
- tunnelUrl: bridge.tunnelUrl,
363796
- tunnelToken: bridge.tunnelToken
363797
- } : void 0,
363798
- activeByType: Object.fromEntries(activeByType),
363799
- activeByProject: Object.fromEntries(activeByProject),
363800
- typeLimits: TYPE_LIMITS,
363801
- maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
363802
- resourceSnapshot: resourceStatus.snapshot,
363803
- resourceGovernor: resourceStatus.governor,
363804
- processInventory,
363805
- aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
363806
- liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
363807
- // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
363808
- // Cached & cheap — see getCachedDoctor above.
363809
- doctor: getCachedDoctor()
363810
- }),
364035
+ body: heartbeatBody,
363811
364036
  signal: AbortSignal.timeout(1e4)
363812
364037
  });
363813
364038
  if (response.ok) {
364039
+ heartbeatKeepalive.update(heartbeatUrl, headers, heartbeatBody);
363814
364040
  const body = await response.json().catch(() => null);
363815
364041
  if (body && Array.isArray(body.commands) && body.commands.length > 0) {
363816
364042
  void handleControlCommands(body.commands);
@@ -363881,6 +364107,7 @@ async function startRunner(opts) {
363881
364107
  process.exit(1);
363882
364108
  }, SHUTDOWN_TIMEOUT_MS).unref();
363883
364109
  clearInterval(heartbeat);
364110
+ heartbeatKeepalive.stop();
363884
364111
  if (liveBrowserHeartbeatTimer) {
363885
364112
  clearTimeout(liveBrowserHeartbeatTimer);
363886
364113
  liveBrowserHeartbeatTimer = null;
package/dist/cli.mjs CHANGED
@@ -5859,6 +5859,29 @@ function normalizeTtsVoiceId(voiceId) {
5859
5859
  return null;
5860
5860
  return LEGACY_TTS_VOICE_ID_MAP[trimmed] ?? trimmed;
5861
5861
  }
5862
+ function normalizeTtsModel(model) {
5863
+ if (!model)
5864
+ return null;
5865
+ const trimmed = model.trim();
5866
+ if (!trimmed)
5867
+ return null;
5868
+ return LEGACY_TTS_MODEL_MAP[trimmed] ?? trimmed;
5869
+ }
5870
+ function ttsFallbackModel(primary) {
5871
+ const normalized = normalizeTtsModel(primary);
5872
+ if (!normalized)
5873
+ return null;
5874
+ return TTS_FALLBACK_MODEL_MAP[normalized] ?? null;
5875
+ }
5876
+ function resolveTtsModelChain(model) {
5877
+ const normalized = normalizeTtsModel(model);
5878
+ const primary = normalized && normalized in TTS_FALLBACK_MODEL_MAP ? normalized : DEFAULT_TTS_MODEL;
5879
+ const chain = [primary];
5880
+ const fallback = TTS_FALLBACK_MODEL_MAP[primary];
5881
+ if (fallback && fallback !== primary)
5882
+ chain.push(fallback);
5883
+ return chain;
5884
+ }
5862
5885
  function sttModelsForProvider(provider) {
5863
5886
  if (provider === "openai") {
5864
5887
  return STT_MODEL_OPTIONS.filter((m) => m.provider === "openai");
@@ -5906,16 +5929,18 @@ function isValidTtsVoiceId(value) {
5906
5929
  return TTS_VOICE_OPTIONS.some((v) => v.id === normalized);
5907
5930
  }
5908
5931
  function isValidTtsModel(value) {
5909
- if (!value)
5932
+ const normalized = normalizeTtsModel(value);
5933
+ if (!normalized)
5910
5934
  return false;
5911
- return TTS_MODEL_OPTIONS.some((m) => m.id === value);
5935
+ return TTS_MODEL_OPTIONS.some((m) => m.id === normalized);
5912
5936
  }
5913
- var VOICE_PREVIEW_SAMPLE, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE_ID, DEFAULT_TTS_SPEED, LEGACY_TTS_VOICE_ID_MAP, DEFAULT_STT_MODEL_BY_PROVIDER, STT_PROVIDER_OPTIONS, STT_MODEL_OPTIONS, TTS_MODEL_OPTIONS, TTS_VOICE_OPTIONS;
5937
+ var VOICE_PREVIEW_SAMPLE, DEFAULT_TTS_MODEL, DEFAULT_TTS_TURBO_MODEL, DEFAULT_TTS_VOICE_ID, DEFAULT_TTS_SPEED, LEGACY_TTS_VOICE_ID_MAP, LEGACY_TTS_MODEL_MAP, TTS_FALLBACK_MODEL_MAP, DEFAULT_STT_MODEL_BY_PROVIDER, STT_PROVIDER_OPTIONS, STT_MODEL_OPTIONS, TTS_MODEL_OPTIONS, TTS_VOICE_OPTIONS;
5914
5938
  var init_voice_config = __esm({
5915
5939
  "../shared/dist/voice-config.js"() {
5916
5940
  "use strict";
5917
5941
  VOICE_PREVIEW_SAMPLE = "Hello! I am your Validate Copilot assistant. I can help you run tests, review failures, and generate new coverage for your project.";
5918
- DEFAULT_TTS_MODEL = "speech-2.6-hd";
5942
+ DEFAULT_TTS_MODEL = "speech-2.8-hd";
5943
+ DEFAULT_TTS_TURBO_MODEL = "speech-2.8-turbo";
5919
5944
  DEFAULT_TTS_VOICE_ID = "English_Graceful_Lady";
5920
5945
  DEFAULT_TTS_SPEED = 1;
5921
5946
  LEGACY_TTS_VOICE_ID_MAP = {
@@ -5924,6 +5949,18 @@ var init_voice_config = __esm({
5924
5949
  English_Deep_Voice_Man: "English_ManWithDeepVoice",
5925
5950
  English_CasualMan: "English_Jovialman"
5926
5951
  };
5952
+ LEGACY_TTS_MODEL_MAP = {
5953
+ "speech-2.6-hd": "speech-2.8-hd",
5954
+ "speech-2.6-turbo": "speech-2.8-turbo",
5955
+ "speech-02-hd": "speech-2.8-hd",
5956
+ "speech-02-turbo": "speech-2.8-turbo",
5957
+ "speech-01-hd": "speech-2.8-hd",
5958
+ "speech-01-turbo": "speech-2.8-turbo"
5959
+ };
5960
+ TTS_FALLBACK_MODEL_MAP = {
5961
+ "speech-2.8-hd": "speech-2.6-hd",
5962
+ "speech-2.8-turbo": "speech-2.6-turbo"
5963
+ };
5927
5964
  DEFAULT_STT_MODEL_BY_PROVIDER = {
5928
5965
  groq: "whisper-large-v3-turbo",
5929
5966
  openai: "whisper-1"
@@ -5976,49 +6013,13 @@ var init_voice_config = __esm({
5976
6013
  id: "speech-2.8-hd",
5977
6014
  label: "Speech 2.8 HD",
5978
6015
  provider: "minimax",
5979
- description: "Latest high-definition model with interjection tags (recommended)."
6016
+ description: "High-definition quality (default). Auto-falls back to Speech 2.6 HD."
5980
6017
  },
5981
6018
  {
5982
6019
  id: "speech-2.8-turbo",
5983
6020
  label: "Speech 2.8 Turbo",
5984
6021
  provider: "minimax",
5985
- description: "Latest low-latency model with interjection tags."
5986
- },
5987
- {
5988
- id: "speech-2.6-hd",
5989
- label: "Speech 2.6 HD",
5990
- provider: "minimax",
5991
- description: "High-definition MiniMax voice model."
5992
- },
5993
- {
5994
- id: "speech-2.6-turbo",
5995
- label: "Speech 2.6 Turbo",
5996
- provider: "minimax",
5997
- description: "Lower-latency Speech 2.6 variant."
5998
- },
5999
- {
6000
- id: "speech-02-hd",
6001
- label: "Speech 02 HD",
6002
- provider: "minimax",
6003
- description: "Previous-generation HD model."
6004
- },
6005
- {
6006
- id: "speech-02-turbo",
6007
- label: "Speech 02 Turbo",
6008
- provider: "minimax",
6009
- description: "Lower-latency Speech 02 variant."
6010
- },
6011
- {
6012
- id: "speech-01-hd",
6013
- label: "Speech 01 HD",
6014
- provider: "minimax",
6015
- description: "Legacy HD model."
6016
- },
6017
- {
6018
- id: "speech-01-turbo",
6019
- label: "Speech 01 Turbo",
6020
- provider: "minimax",
6021
- description: "Legacy fast model."
6022
+ description: "Lower latency. Auto-falls back to Speech 2.6 Turbo."
6022
6023
  }
6023
6024
  ];
6024
6025
  TTS_VOICE_OPTIONS = [
@@ -6302,6 +6303,7 @@ __export(dist_exports, {
6302
6303
  DEFAULT_STT_MODEL_BY_PROVIDER: () => DEFAULT_STT_MODEL_BY_PROVIDER,
6303
6304
  DEFAULT_TTS_MODEL: () => DEFAULT_TTS_MODEL,
6304
6305
  DEFAULT_TTS_SPEED: () => DEFAULT_TTS_SPEED,
6306
+ DEFAULT_TTS_TURBO_MODEL: () => DEFAULT_TTS_TURBO_MODEL,
6305
6307
  DEFAULT_TTS_VOICE_ID: () => DEFAULT_TTS_VOICE_ID,
6306
6308
  DEFAULT_UI_VARIANT: () => DEFAULT_UI_VARIANT,
6307
6309
  EMAIL_FROM_IDENTITIES: () => EMAIL_FROM_IDENTITIES,
@@ -6311,6 +6313,7 @@ __export(dist_exports, {
6311
6313
  FLOW_SERVER_TYPES: () => FLOW_SERVER_TYPES,
6312
6314
  GROUPABLE_CALL_TYPES: () => GROUPABLE_CALL_TYPES,
6313
6315
  GROUP_LABELS: () => GROUP_LABELS,
6316
+ LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6314
6317
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6315
6318
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
6316
6319
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
@@ -6344,6 +6347,7 @@ __export(dist_exports, {
6344
6347
  TEST_USER_ENV_KEYS: () => TEST_USER_ENV_KEYS,
6345
6348
  TRANSIENT_CONTROL_ROLES: () => TRANSIENT_CONTROL_ROLES,
6346
6349
  TRIAL_DURATION_DAYS: () => TRIAL_DURATION_DAYS,
6350
+ TTS_FALLBACK_MODEL_MAP: () => TTS_FALLBACK_MODEL_MAP,
6347
6351
  TTS_MODEL_OPTIONS: () => TTS_MODEL_OPTIONS,
6348
6352
  TTS_VOICE_OPTIONS: () => TTS_VOICE_OPTIONS,
6349
6353
  UI_VARIANTS: () => UI_VARIANTS,
@@ -6446,6 +6450,7 @@ __export(dist_exports, {
6446
6450
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
6447
6451
  mobileWanderTraceEnabled: () => mobileWanderTraceEnabled,
6448
6452
  normalizeControlName: () => normalizeControlName,
6453
+ normalizeTtsModel: () => normalizeTtsModel,
6449
6454
  normalizeTtsVoiceId: () => normalizeTtsVoiceId,
6450
6455
  otherMobilePlatform: () => otherMobilePlatform,
6451
6456
  parenDepthDelta: () => parenDepthDelta,
@@ -6464,6 +6469,7 @@ __export(dist_exports, {
6464
6469
  resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
6465
6470
  resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
6466
6471
  resolveSttModelForProvider: () => resolveSttModelForProvider,
6472
+ resolveTtsModelChain: () => resolveTtsModelChain,
6467
6473
  safeCurrentUrl: () => safeCurrentUrl,
6468
6474
  safeOrigin: () => safeOrigin,
6469
6475
  sanitizeSttErrorMessage: () => sanitizeSttErrorMessage,
@@ -6486,6 +6492,7 @@ __export(dist_exports, {
6486
6492
  summarizeMobileReadiness: () => summarizeMobileReadiness,
6487
6493
  summarizeRunHealth: () => summarizeRunHealth,
6488
6494
  toDbPricingPlan: () => toDbPricingPlan,
6495
+ ttsFallbackModel: () => ttsFallbackModel,
6489
6496
  validateAssertionStrength: () => validateAssertionStrength
6490
6497
  });
6491
6498
  var init_dist = __esm({
@@ -11496,7 +11503,7 @@ var require_thread_stream = __commonJS({
11496
11503
  "use strict";
11497
11504
  var { version } = require_package();
11498
11505
  var { EventEmitter: EventEmitter2 } = __require("events");
11499
- var { Worker } = __require("worker_threads");
11506
+ var { Worker: Worker2 } = __require("worker_threads");
11500
11507
  var { join: join8 } = __require("path");
11501
11508
  var { pathToFileURL } = __require("url");
11502
11509
  var { wait } = require_wait();
@@ -11549,7 +11556,7 @@ var require_thread_stream = __commonJS({
11549
11556
  const { filename, workerData } = opts;
11550
11557
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
11551
11558
  const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11552
- const worker = new Worker(toExecute, {
11559
+ const worker = new Worker2(toExecute, {
11553
11560
  ...opts.workerOpts,
11554
11561
  name: opts.workerOpts?.name || "thread-stream",
11555
11562
  trackUnmanagedFds: false,
@@ -74649,6 +74656,7 @@ var require_mobile_explorer = __commonJS({
74649
74656
  Object.defineProperty(exports, "__esModule", { value: true });
74650
74657
  exports.computeMobileSurveyBreadthContinuation = computeMobileSurveyBreadthContinuation;
74651
74658
  exports.computeMobileBreadthFinishRedirectDecision = computeMobileBreadthFinishRedirectDecision;
74659
+ exports.hybridScreenFingerprint = hybridScreenFingerprint;
74652
74660
  exports.runMobileExplorePhase = runMobileExplorePhase;
74653
74661
  var crypto_1 = __require("crypto");
74654
74662
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
@@ -75102,11 +75110,12 @@ var require_mobile_explorer = __commonJS({
75102
75110
  const validSurfaceDigest = /^[0-9a-f]{16,64}$/.test(normalizedSurfaceDigest) ? normalizedSurfaceDigest : void 0;
75103
75111
  const legacyElementDigests = [...new Set(elements.map((element) => element.pageIdentityDigest?.trim().toLowerCase() ?? "").filter((digest) => /^[0-9a-f]{16,64}$/.test(digest)))];
75104
75112
  const pageIdentityDigest = validSurfaceDigest ?? (legacyElementDigests.length === 1 ? legacyElementDigests[0] : void 0);
75113
+ const hasStableControlIdentity = elements.some((element) => Boolean(safeHybridIdentity(element.id, credentials) || safeHybridIdentity(element.name, credentials)));
75105
75114
  const descriptors = elements.map((element) => {
75106
75115
  const editable = element.editable === true || /^(?:input|textarea|select)$/i.test(element.tag) || /(?:textbox|searchbox|combobox)/i.test(element.role);
75107
75116
  const id = safeHybridIdentity(element.id, credentials)?.toLowerCase() ?? "";
75108
75117
  const name = safeHybridIdentity(element.name, credentials)?.toLowerCase() ?? "";
75109
- const semanticText = editable || id || name ? "" : stableHybridSemanticText(element.text, credentials);
75118
+ const semanticText = editable || id || name || hasStableControlIdentity ? "" : stableHybridSemanticText(element.text, credentials);
75110
75119
  return [
75111
75120
  element.tag.trim().toLowerCase(),
75112
75121
  element.role.trim().toLowerCase(),
@@ -83745,6 +83754,14 @@ ${statePacket}`, redactionCredentials) },
83745
83754
  return actions.filter((_, index) => !toDrop.has(index));
83746
83755
  }
83747
83756
  var MAX_DETOUR_LENGTH = 6;
83757
+ function isDetourClosingAction(action) {
83758
+ if (action.type === "BACK" || action.type === "HOME")
83759
+ return true;
83760
+ return isNavigationChromeTap(action);
83761
+ }
83762
+ function isEvidenceAction(action) {
83763
+ return action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "WAIT_FOR_ELEMENT";
83764
+ }
83748
83765
  function elideNoOpDetours(actions) {
83749
83766
  if (actions.length < 2)
83750
83767
  return actions;
@@ -83752,12 +83769,16 @@ ${statePacket}`, redactionCredentials) },
83752
83769
  for (let j = 0; j < actions.length; j++) {
83753
83770
  if (toDrop.has(j))
83754
83771
  continue;
83755
- if (actions[j].type !== "BACK" && actions[j].type !== "HOME")
83772
+ if (!isDetourClosingAction(actions[j]))
83756
83773
  continue;
83757
83774
  const keyAfter = actions[j].metadata.structuralKey;
83758
83775
  if (!keyAfter)
83759
83776
  continue;
83777
+ const next = actions[j + 1];
83778
+ if (next && isEvidenceAction(next))
83779
+ continue;
83760
83780
  let bestStart = null;
83781
+ let leftScreen = false;
83761
83782
  const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83762
83783
  for (let i = j; i >= earliestStart; i--) {
83763
83784
  if (i < j) {
@@ -83765,11 +83786,14 @@ ${statePacket}`, redactionCredentials) },
83765
83786
  if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83766
83787
  break;
83767
83788
  }
83789
+ const insideKey = inside.metadata.structuralKey;
83790
+ if (insideKey && insideKey !== keyAfter)
83791
+ leftScreen = true;
83768
83792
  }
83769
83793
  if (i === 0)
83770
83794
  continue;
83771
83795
  const keyBefore = actions[i - 1].metadata.structuralKey;
83772
- if (i < j && keyBefore && keyBefore === keyAfter) {
83796
+ if (i < j && leftScreen && keyBefore && keyBefore === keyAfter) {
83773
83797
  bestStart = i;
83774
83798
  }
83775
83799
  }
@@ -352995,7 +353019,7 @@ var require_package4 = __commonJS({
352995
353019
  "package.json"(exports, module) {
352996
353020
  module.exports = {
352997
353021
  name: "@validate.qa/runner",
352998
- version: "1.0.71",
353022
+ version: "1.0.73",
352999
353023
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353000
353024
  bin: {
353001
353025
  "validate-runner": "dist/cli.js"
@@ -354659,6 +354683,27 @@ async function clickCssInCurrentContext(sessionPath, css) {
354659
354683
  body: "{}"
354660
354684
  });
354661
354685
  }
354686
+ async function clickByVisibleTextInCurrentContext(sessionPath, text) {
354687
+ const script = `
354688
+ var t = String(arguments[0]).trim().replace(/\\s+/g, ' ');
354689
+ var sel = 'button, a, [role="tab"], [role="button"], [onclick], .stream-tab, .ux-interactive, input[type="submit"], input[type="button"]';
354690
+ var norm = function (e) { return (e.textContent || e.value || '').trim().replace(/\\s+/g, ' '); };
354691
+ var vis = function (e) { var r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
354692
+ var all = Array.prototype.slice.call(document.querySelectorAll(sel));
354693
+ var match = all.filter(function (e) { return norm(e) === t && vis(e); });
354694
+ if (match.length !== 1) { return match.length; }
354695
+ match[0].click();
354696
+ return 1;
354697
+ `;
354698
+ const result = await appiumRequest(`${sessionPath}/execute/sync`, {
354699
+ method: "POST",
354700
+ body: JSON.stringify({ script, args: [text] })
354701
+ });
354702
+ const count = readValue(result);
354703
+ if (count !== 1) {
354704
+ throw new Error(`WEBVIEW text click found ${count === 0 || count === void 0 ? "no" : String(count)} actionable match(es) for "${text}"`);
354705
+ }
354706
+ }
354662
354707
  async function typeCssInCurrentContext(sessionPath, css, text) {
354663
354708
  const elResult = await appiumRequest(`${sessionPath}/element`, {
354664
354709
  method: "POST",
@@ -354807,7 +354852,11 @@ async function pressHome(sessionPath, platform3) {
354807
354852
  }
354808
354853
 
354809
354854
  // src/services/device-quarantine.ts
354810
- var quarantinedDevices = /* @__PURE__ */ new Set();
354855
+ var QUARANTINE_MAX_MS = (() => {
354856
+ const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
354857
+ return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
354858
+ })();
354859
+ var quarantinedAt = /* @__PURE__ */ new Map();
354811
354860
  var DeviceQuarantinedError = class extends Error {
354812
354861
  constructor(deviceId) {
354813
354862
  super(
@@ -354816,17 +354865,27 @@ var DeviceQuarantinedError = class extends Error {
354816
354865
  this.name = "DeviceQuarantinedError";
354817
354866
  }
354818
354867
  };
354819
- function quarantineDevice(deviceId) {
354820
- quarantinedDevices.add(deviceId);
354868
+ function quarantineDevice(deviceId, now = Date.now()) {
354869
+ quarantinedAt.set(deviceId, now);
354821
354870
  }
354822
354871
  function unquarantineDevice(deviceId) {
354823
- quarantinedDevices.delete(deviceId);
354872
+ quarantinedAt.delete(deviceId);
354824
354873
  }
354825
- function isDeviceQuarantined(deviceId) {
354826
- return typeof deviceId === "string" && quarantinedDevices.has(deviceId);
354874
+ function isDeviceQuarantined(deviceId, now = Date.now()) {
354875
+ if (typeof deviceId !== "string") return false;
354876
+ const since = quarantinedAt.get(deviceId);
354877
+ if (since === void 0) return false;
354878
+ if (now - since >= QUARANTINE_MAX_MS) {
354879
+ quarantinedAt.delete(deviceId);
354880
+ console.warn(
354881
+ `[device-quarantine] Quarantine on ${deviceId} exceeded its ${Math.round(QUARANTINE_MAX_MS / 6e4)}min ceiling without the original run settling \u2014 lifting it so the device is usable again. If this recurs, the zombie run is wedged in non-device work (check the runner log).`
354882
+ );
354883
+ return false;
354884
+ }
354885
+ return true;
354827
354886
  }
354828
- function assertDeviceNotQuarantined(deviceId) {
354829
- if (isDeviceQuarantined(deviceId)) {
354887
+ function assertDeviceNotQuarantined(deviceId, now = Date.now()) {
354888
+ if (isDeviceQuarantined(deviceId, now)) {
354830
354889
  throw new DeviceQuarantinedError(deviceId);
354831
354890
  }
354832
354891
  }
@@ -357006,6 +357065,48 @@ function stepWebViewContext(step) {
357006
357065
  const meta = step.metadata && typeof step.metadata === "object" ? step.metadata : null;
357007
357066
  return typeof meta?.webviewContext === "string" && meta.webviewContext.trim() ? meta.webviewContext.trim() : null;
357008
357067
  }
357068
+ var DOM_ID_RE = /^[A-Za-z_][\w-]*$/;
357069
+ var SYSTEM_RESOURCE_ID_RE = /^(?:android|com\.android\.|com\.google\.android\.)/i;
357070
+ function webViewCssFallbackFromNativeStep(step) {
357071
+ if (stepWebViewCss(step)) return null;
357072
+ const target = step.target;
357073
+ if (!target) return null;
357074
+ const bundle = target.locatorBundle;
357075
+ const resourceId = typeof bundle?.resourceId === "string" ? bundle.resourceId.trim() : "";
357076
+ const accessibilityId = typeof bundle?.accessibilityId === "string" ? bundle.accessibilityId.trim() : "";
357077
+ const primary = typeof target.primary === "string" ? target.primary.trim() : "";
357078
+ const fromResourceId = (raw) => {
357079
+ if (!raw) return null;
357080
+ if (raw.includes(":id/")) {
357081
+ if (SYSTEM_RESOURCE_ID_RE.test(raw)) return null;
357082
+ const local = raw.slice(raw.indexOf(":id/") + 4);
357083
+ return DOM_ID_RE.test(local) ? local : null;
357084
+ }
357085
+ return DOM_ID_RE.test(raw) ? raw : null;
357086
+ };
357087
+ const domId = fromResourceId(resourceId) ?? fromResourceId(accessibilityId) ?? (primary && !/[=~/[\].\s]/.test(primary) && DOM_ID_RE.test(primary) ? primary : null);
357088
+ return domId ? `#${domId}` : null;
357089
+ }
357090
+ function webViewTextFallbackFromNativeStep(step) {
357091
+ if (stepWebViewCss(step)) return null;
357092
+ if (step.action !== "tap") return null;
357093
+ const target = step.target;
357094
+ if (!target) return null;
357095
+ const bundle = target.locatorBundle;
357096
+ const text = typeof bundle?.text === "string" && bundle.text.trim() ? bundle.text.trim() : typeof bundle?.contentDesc === "string" && bundle.contentDesc.trim() ? bundle.contentDesc.trim() : typeof target.primary === "string" && target.primary.trim() && !/[=~/[\]]|:id\//.test(target.primary) ? target.primary.trim() : "";
357097
+ const normalized = text.replace(/\s+/g, " ");
357098
+ if (!normalized || normalized.length > 40 || /[\n\r]/.test(text)) return null;
357099
+ if (SYSTEM_PERMISSION_BUTTON_RE.test(text)) return null;
357100
+ return normalized;
357101
+ }
357102
+ async function hasLiveWebViewContext(sessionId) {
357103
+ try {
357104
+ const contexts = await listAppContexts(`/session/${sessionId}`);
357105
+ return contexts.some((c) => /^WEBVIEW/i.test(c));
357106
+ } catch {
357107
+ return false;
357108
+ }
357109
+ }
357009
357110
  var CONTAINER_ONLY_ENTRY_ID_RE = /(?:^|[:/])id\/(webview|content|decor|decor_content_parent|action_bar_root|statusBarBackground|navigationBarBackground)$|^(?:main-content|root|app|app-root|__next|react-root|application)$/i;
357010
357111
  function isContainerOnlyEntryTarget(target) {
357011
357112
  const primary = target?.primary?.trim() ?? "";
@@ -357093,6 +357194,7 @@ function isWebViewHydrationTransient(err) {
357093
357194
  return /no such element|NoSuchElement|element not interactable|stale element|WEBVIEW step requires|Empty page dictionary|not connectable|no WEBVIEW context|chrome not reachable/i.test(message);
357094
357195
  }
357095
357196
  var WEBVIEW_HYDRATION_TIMEOUT_MS = Number(process.env.VALIDATEQA_WEBVIEW_HYDRATION_TIMEOUT_MS) || 3e4;
357197
+ var PERMISSION_DIALOG_TIMEOUT_MS = Number(process.env.VALIDATEQA_PERMISSION_DIALOG_TIMEOUT_MS) || 3e3;
357096
357198
  async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, body, timeoutMs) {
357097
357199
  const deadline = Date.now() + Math.max(1e3, timeoutMs);
357098
357200
  for (; ; ) {
@@ -357105,6 +357207,17 @@ async function executeInWebViewContextWithHydrationRetry(sessionId, step, css, b
357105
357207
  }
357106
357208
  }
357107
357209
  }
357210
+ var SYSTEM_PERMISSION_BUTTON_RE = /com\.android\.permissioncontroller:id\/permission_(allow|deny)|com\.android\.packageinstaller:id\/permission_|:id\/permission_allow_(foreground_only|one_time)_button|^(?:com\.apple\.springboard|SpringBoard)/i;
357211
+ function isSystemPermissionButtonTarget(target) {
357212
+ if (!target) return false;
357213
+ const bundle = target.locatorBundle;
357214
+ const candidates = [
357215
+ target.primary,
357216
+ typeof bundle?.resourceId === "string" ? bundle.resourceId : "",
357217
+ typeof bundle?.xpath === "string" ? bundle.xpath : ""
357218
+ ];
357219
+ return candidates.some((c) => typeof c === "string" && SYSTEM_PERMISSION_BUTTON_RE.test(c));
357220
+ }
357108
357221
  async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
357109
357222
  const webCss = stepWebViewCss(step);
357110
357223
  if (webCss) {
@@ -357117,10 +357230,11 @@ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
357117
357230
  );
357118
357231
  return;
357119
357232
  }
357233
+ const isPermissionTap = isSystemPermissionButtonTarget(step.target);
357120
357234
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
357121
357235
  if (step.target) {
357122
357236
  try {
357123
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step), {
357237
+ const elementId = await findElement(sessionId, step.target, isPermissionTap ? Math.min(readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS, PERMISSION_DIALOG_TIMEOUT_MS) : readStepTimeout(step), {
357124
357238
  platform: platform3,
357125
357239
  recordedBounds: bounds
357126
357240
  });
@@ -357130,7 +357244,23 @@ async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
357130
357244
  });
357131
357245
  return;
357132
357246
  } catch (error2) {
357133
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
357247
+ if (isTransportError(error2) || error2 instanceof MobileAmbiguousMatchError) throw error2;
357248
+ if (isPermissionTap) return;
357249
+ const cssFallback = webViewCssFallbackFromNativeStep(step);
357250
+ const textFallback = cssFallback ? null : webViewTextFallbackFromNativeStep(step);
357251
+ if ((cssFallback || textFallback) && await hasLiveWebViewContext(sessionId)) {
357252
+ try {
357253
+ if (cssFallback) {
357254
+ await executeInWebViewContext(sessionId, step, cssFallback, (sessionPath, css) => clickCssInCurrentContext(sessionPath, css));
357255
+ } else if (textFallback) {
357256
+ await executeInWebViewContext(sessionId, step, textFallback, (sessionPath) => clickByVisibleTextInCurrentContext(sessionPath, textFallback));
357257
+ }
357258
+ return;
357259
+ } catch (webErr) {
357260
+ if (isTransportError(webErr)) throw webErr;
357261
+ }
357262
+ }
357263
+ if (!bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
357134
357264
  }
357135
357265
  }
357136
357266
  if (!bounds) {
@@ -357188,6 +357318,18 @@ async function executeType(sessionId, step, context) {
357188
357318
  return;
357189
357319
  } catch (error2) {
357190
357320
  if (isTransportError(error2)) throw error2;
357321
+ if (!focusEstablished && !keyboardSubmitOnly) {
357322
+ const cssFallback = webViewCssFallbackFromNativeStep(step);
357323
+ if (cssFallback && await hasLiveWebViewContext(sessionId)) {
357324
+ try {
357325
+ context.log?.(` Native locate failed; retrying as WebView ${cssFallback} (hybrid mirror fallback)`);
357326
+ await executeInWebViewContext(sessionId, step, cssFallback, (sessionPath2, css) => typeCssInCurrentContext(sessionPath2, css, value));
357327
+ return;
357328
+ } catch (webErr) {
357329
+ if (isTransportError(webErr)) throw webErr;
357330
+ }
357331
+ }
357332
+ }
357191
357333
  if (context.platform === "ANDROID" && focusEstablished) {
357192
357334
  try {
357193
357335
  context.log?.(" Appium active-element send failed; using Android focused-keyboard input fallback");
@@ -358166,6 +358308,86 @@ function validateRunnerMobileTarget(value) {
358166
358308
  };
358167
358309
  }
358168
358310
 
358311
+ // src/services/heartbeat-keepalive.ts
358312
+ import { Worker } from "worker_threads";
358313
+ var KEEPALIVE_INTERVAL_MS = (() => {
358314
+ const raw = Number(process.env.VALIDATEQA_HEARTBEAT_KEEPALIVE_MS);
358315
+ return Number.isFinite(raw) && raw > 0 ? raw : 2e4;
358316
+ })();
358317
+ function isHeartbeatKeepaliveEnabled(env = process.env) {
358318
+ return (env.VALIDATEQA_HEARTBEAT_KEEPALIVE ?? "").trim().toLowerCase() !== "off";
358319
+ }
358320
+ var WORKER_SOURCE = `
358321
+ const { parentPort } = require('node:worker_threads');
358322
+ let url = null;
358323
+ let headers = null;
358324
+ let body = null;
358325
+ let intervalMs = 20000;
358326
+ let timer = null;
358327
+
358328
+ async function post() {
358329
+ if (!url || !headers || !body) return; // inert until the first real heartbeat
358330
+ try {
358331
+ await fetch(url, {
358332
+ method: 'POST',
358333
+ headers,
358334
+ body,
358335
+ signal: AbortSignal.timeout(10000),
358336
+ });
358337
+ } catch {
358338
+ // Best-effort by design: the next tick retries; the main loop's own
358339
+ // heartbeat error handling owns logging/upgrade signals.
358340
+ }
358341
+ }
358342
+
358343
+ parentPort.on('message', (msg) => {
358344
+ if (!msg || typeof msg !== 'object') return;
358345
+ if (msg.type === 'update') {
358346
+ url = msg.url;
358347
+ headers = msg.headers;
358348
+ body = msg.body;
358349
+ intervalMs = msg.intervalMs || intervalMs;
358350
+ if (!timer) {
358351
+ timer = setInterval(post, intervalMs);
358352
+ if (timer.unref) timer.unref();
358353
+ }
358354
+ } else if (msg.type === 'stop') {
358355
+ if (timer) clearInterval(timer);
358356
+ timer = null;
358357
+ parentPort.close();
358358
+ }
358359
+ });
358360
+ `;
358361
+ var NOOP_KEEPALIVE = { update: () => {
358362
+ }, stop: () => {
358363
+ } };
358364
+ function startHeartbeatKeepalive(options = {}) {
358365
+ if (!isHeartbeatKeepaliveEnabled(options.env)) return NOOP_KEEPALIVE;
358366
+ const intervalMs = options.intervalMs ?? KEEPALIVE_INTERVAL_MS;
358367
+ let worker;
358368
+ try {
358369
+ worker = new Worker(WORKER_SOURCE, { eval: true });
358370
+ worker.unref();
358371
+ worker.on("error", () => {
358372
+ worker = null;
358373
+ });
358374
+ } catch {
358375
+ return NOOP_KEEPALIVE;
358376
+ }
358377
+ return {
358378
+ update(url, headers, body) {
358379
+ worker?.postMessage({ type: "update", url, headers, body, intervalMs });
358380
+ },
358381
+ stop() {
358382
+ try {
358383
+ worker?.postMessage({ type: "stop" });
358384
+ } catch {
358385
+ }
358386
+ worker = null;
358387
+ }
358388
+ };
358389
+ }
358390
+
358169
358391
  // src/mobile-device-queue.ts
358170
358392
  function isNativeMobileDiscoveryContext(ctx) {
358171
358393
  if (!ctx) return false;
@@ -363770,6 +363992,7 @@ async function startRunner(opts) {
363770
363992
  }
363771
363993
  }
363772
363994
  };
363995
+ const heartbeatKeepalive = startHeartbeatKeepalive();
363773
363996
  const sendHeartbeat = async () => {
363774
363997
  try {
363775
363998
  const devices = enableMobile ? getCachedDeviceSnapshot() : [];
@@ -363784,41 +364007,44 @@ async function startRunner(opts) {
363784
364007
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
363785
364008
  lastInventoryKinds.clear();
363786
364009
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
363787
- const response = await fetch(`${serverUrl}/api/runner/heartbeat`, {
364010
+ const heartbeatUrl = `${serverUrl}/api/runner/heartbeat`;
364011
+ const heartbeatBody = JSON.stringify({
364012
+ runnerName: RUNNER_NAME,
364013
+ runnerVersion: RUNNER_VERSION,
364014
+ runnerBuildId: RUNNER_BUILD_ID,
364015
+ runsCompleted,
364016
+ activeRuns,
364017
+ maxConcurrent: MAX_CONCURRENT,
364018
+ uptimeMs: Date.now() - startedAt,
364019
+ capabilities,
364020
+ devices,
364021
+ mobileBridge: bridge ? {
364022
+ online: bridge.running,
364023
+ port: bridge.port,
364024
+ tunnelUrl: bridge.tunnelUrl,
364025
+ tunnelToken: bridge.tunnelToken
364026
+ } : void 0,
364027
+ activeByType: Object.fromEntries(activeByType),
364028
+ activeByProject: Object.fromEntries(activeByProject),
364029
+ typeLimits: TYPE_LIMITS,
364030
+ maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
364031
+ resourceSnapshot: resourceStatus.snapshot,
364032
+ resourceGovernor: resourceStatus.governor,
364033
+ processInventory,
364034
+ aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
364035
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
364036
+ // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
364037
+ // Cached & cheap — see getCachedDoctor above.
364038
+ doctor: getCachedDoctor()
364039
+ });
364040
+ const response = await fetch(heartbeatUrl, {
363788
364041
  method: "POST",
363789
364042
  headers,
363790
- body: JSON.stringify({
363791
- runnerName: RUNNER_NAME,
363792
- runnerVersion: RUNNER_VERSION,
363793
- runnerBuildId: RUNNER_BUILD_ID,
363794
- runsCompleted,
363795
- activeRuns,
363796
- maxConcurrent: MAX_CONCURRENT,
363797
- uptimeMs: Date.now() - startedAt,
363798
- capabilities,
363799
- devices,
363800
- mobileBridge: bridge ? {
363801
- online: bridge.running,
363802
- port: bridge.port,
363803
- tunnelUrl: bridge.tunnelUrl,
363804
- tunnelToken: bridge.tunnelToken
363805
- } : void 0,
363806
- activeByType: Object.fromEntries(activeByType),
363807
- activeByProject: Object.fromEntries(activeByProject),
363808
- typeLimits: TYPE_LIMITS,
363809
- maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
363810
- resourceSnapshot: resourceStatus.snapshot,
363811
- resourceGovernor: resourceStatus.governor,
363812
- processInventory,
363813
- aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
363814
- liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
363815
- // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
363816
- // Cached & cheap — see getCachedDoctor above.
363817
- doctor: getCachedDoctor()
363818
- }),
364043
+ body: heartbeatBody,
363819
364044
  signal: AbortSignal.timeout(1e4)
363820
364045
  });
363821
364046
  if (response.ok) {
364047
+ heartbeatKeepalive.update(heartbeatUrl, headers, heartbeatBody);
363822
364048
  const body = await response.json().catch(() => null);
363823
364049
  if (body && Array.isArray(body.commands) && body.commands.length > 0) {
363824
364050
  void handleControlCommands(body.commands);
@@ -363889,6 +364115,7 @@ async function startRunner(opts) {
363889
364115
  process.exit(1);
363890
364116
  }, SHUTDOWN_TIMEOUT_MS).unref();
363891
364117
  clearInterval(heartbeat);
364118
+ heartbeatKeepalive.stop();
363892
364119
  if (liveBrowserHeartbeatTimer) {
363893
364120
  clearTimeout(liveBrowserHeartbeatTimer);
363894
364121
  liveBrowserHeartbeatTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.71",
3
+ "version": "1.0.73",
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"