@validate.qa/runner 1.0.71 → 1.0.72

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 +160 -44
  2. package/dist/cli.mjs +160 -44
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11491,7 +11491,7 @@ var require_thread_stream = __commonJS({
11491
11491
  "use strict";
11492
11492
  var { version } = require_package();
11493
11493
  var { EventEmitter: EventEmitter2 } = require("events");
11494
- var { Worker } = require("worker_threads");
11494
+ var { Worker: Worker2 } = require("worker_threads");
11495
11495
  var { join: join8 } = require("path");
11496
11496
  var { pathToFileURL } = require("url");
11497
11497
  var { wait } = require_wait();
@@ -11544,7 +11544,7 @@ var require_thread_stream = __commonJS({
11544
11544
  const { filename, workerData } = opts;
11545
11545
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
11546
11546
  const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11547
- const worker = new Worker(toExecute, {
11547
+ const worker = new Worker2(toExecute, {
11548
11548
  ...opts.workerOpts,
11549
11549
  name: opts.workerOpts?.name || "thread-stream",
11550
11550
  trackUnmanagedFds: false,
@@ -74644,6 +74644,7 @@ var require_mobile_explorer = __commonJS({
74644
74644
  Object.defineProperty(exports2, "__esModule", { value: true });
74645
74645
  exports2.computeMobileSurveyBreadthContinuation = computeMobileSurveyBreadthContinuation;
74646
74646
  exports2.computeMobileBreadthFinishRedirectDecision = computeMobileBreadthFinishRedirectDecision;
74647
+ exports2.hybridScreenFingerprint = hybridScreenFingerprint;
74647
74648
  exports2.runMobileExplorePhase = runMobileExplorePhase;
74648
74649
  var crypto_1 = require("crypto");
74649
74650
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
@@ -75097,11 +75098,12 @@ var require_mobile_explorer = __commonJS({
75097
75098
  const validSurfaceDigest = /^[0-9a-f]{16,64}$/.test(normalizedSurfaceDigest) ? normalizedSurfaceDigest : void 0;
75098
75099
  const legacyElementDigests = [...new Set(elements.map((element) => element.pageIdentityDigest?.trim().toLowerCase() ?? "").filter((digest) => /^[0-9a-f]{16,64}$/.test(digest)))];
75099
75100
  const pageIdentityDigest = validSurfaceDigest ?? (legacyElementDigests.length === 1 ? legacyElementDigests[0] : void 0);
75101
+ const hasStableControlIdentity = elements.some((element) => Boolean(safeHybridIdentity(element.id, credentials) || safeHybridIdentity(element.name, credentials)));
75100
75102
  const descriptors = elements.map((element) => {
75101
75103
  const editable = element.editable === true || /^(?:input|textarea|select)$/i.test(element.tag) || /(?:textbox|searchbox|combobox)/i.test(element.role);
75102
75104
  const id = safeHybridIdentity(element.id, credentials)?.toLowerCase() ?? "";
75103
75105
  const name = safeHybridIdentity(element.name, credentials)?.toLowerCase() ?? "";
75104
- const semanticText = editable || id || name ? "" : stableHybridSemanticText(element.text, credentials);
75106
+ const semanticText = editable || id || name || hasStableControlIdentity ? "" : stableHybridSemanticText(element.text, credentials);
75105
75107
  return [
75106
75108
  element.tag.trim().toLowerCase(),
75107
75109
  element.role.trim().toLowerCase(),
@@ -83740,6 +83742,14 @@ ${statePacket}`, redactionCredentials) },
83740
83742
  return actions.filter((_, index) => !toDrop.has(index));
83741
83743
  }
83742
83744
  var MAX_DETOUR_LENGTH = 6;
83745
+ function isDetourClosingAction(action) {
83746
+ if (action.type === "BACK" || action.type === "HOME")
83747
+ return true;
83748
+ return isNavigationChromeTap(action);
83749
+ }
83750
+ function isEvidenceAction(action) {
83751
+ return action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "WAIT_FOR_ELEMENT";
83752
+ }
83743
83753
  function elideNoOpDetours(actions) {
83744
83754
  if (actions.length < 2)
83745
83755
  return actions;
@@ -83747,12 +83757,16 @@ ${statePacket}`, redactionCredentials) },
83747
83757
  for (let j = 0; j < actions.length; j++) {
83748
83758
  if (toDrop.has(j))
83749
83759
  continue;
83750
- if (actions[j].type !== "BACK" && actions[j].type !== "HOME")
83760
+ if (!isDetourClosingAction(actions[j]))
83751
83761
  continue;
83752
83762
  const keyAfter = actions[j].metadata.structuralKey;
83753
83763
  if (!keyAfter)
83754
83764
  continue;
83765
+ const next = actions[j + 1];
83766
+ if (next && isEvidenceAction(next))
83767
+ continue;
83755
83768
  let bestStart = null;
83769
+ let leftScreen = false;
83756
83770
  const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83757
83771
  for (let i = j; i >= earliestStart; i--) {
83758
83772
  if (i < j) {
@@ -83760,11 +83774,14 @@ ${statePacket}`, redactionCredentials) },
83760
83774
  if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83761
83775
  break;
83762
83776
  }
83777
+ const insideKey = inside.metadata.structuralKey;
83778
+ if (insideKey && insideKey !== keyAfter)
83779
+ leftScreen = true;
83763
83780
  }
83764
83781
  if (i === 0)
83765
83782
  continue;
83766
83783
  const keyBefore = actions[i - 1].metadata.structuralKey;
83767
- if (i < j && keyBefore && keyBefore === keyAfter) {
83784
+ if (i < j && leftScreen && keyBefore && keyBefore === keyAfter) {
83768
83785
  bestStart = i;
83769
83786
  }
83770
83787
  }
@@ -352990,7 +353007,7 @@ var require_package4 = __commonJS({
352990
353007
  "package.json"(exports2, module2) {
352991
353008
  module2.exports = {
352992
353009
  name: "@validate.qa/runner",
352993
- version: "1.0.71",
353010
+ version: "1.0.72",
352994
353011
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
352995
353012
  bin: {
352996
353013
  "validate-runner": "dist/cli.js"
@@ -354802,7 +354819,11 @@ async function pressHome(sessionPath, platform3) {
354802
354819
  }
354803
354820
 
354804
354821
  // src/services/device-quarantine.ts
354805
- var quarantinedDevices = /* @__PURE__ */ new Set();
354822
+ var QUARANTINE_MAX_MS = (() => {
354823
+ const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
354824
+ return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
354825
+ })();
354826
+ var quarantinedAt = /* @__PURE__ */ new Map();
354806
354827
  var DeviceQuarantinedError = class extends Error {
354807
354828
  constructor(deviceId) {
354808
354829
  super(
@@ -354811,17 +354832,27 @@ var DeviceQuarantinedError = class extends Error {
354811
354832
  this.name = "DeviceQuarantinedError";
354812
354833
  }
354813
354834
  };
354814
- function quarantineDevice(deviceId) {
354815
- quarantinedDevices.add(deviceId);
354835
+ function quarantineDevice(deviceId, now = Date.now()) {
354836
+ quarantinedAt.set(deviceId, now);
354816
354837
  }
354817
354838
  function unquarantineDevice(deviceId) {
354818
- quarantinedDevices.delete(deviceId);
354839
+ quarantinedAt.delete(deviceId);
354819
354840
  }
354820
- function isDeviceQuarantined(deviceId) {
354821
- return typeof deviceId === "string" && quarantinedDevices.has(deviceId);
354841
+ function isDeviceQuarantined(deviceId, now = Date.now()) {
354842
+ if (typeof deviceId !== "string") return false;
354843
+ const since = quarantinedAt.get(deviceId);
354844
+ if (since === void 0) return false;
354845
+ if (now - since >= QUARANTINE_MAX_MS) {
354846
+ quarantinedAt.delete(deviceId);
354847
+ console.warn(
354848
+ `[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).`
354849
+ );
354850
+ return false;
354851
+ }
354852
+ return true;
354822
354853
  }
354823
- function assertDeviceNotQuarantined(deviceId) {
354824
- if (isDeviceQuarantined(deviceId)) {
354854
+ function assertDeviceNotQuarantined(deviceId, now = Date.now()) {
354855
+ if (isDeviceQuarantined(deviceId, now)) {
354825
354856
  throw new DeviceQuarantinedError(deviceId);
354826
354857
  }
354827
354858
  }
@@ -358158,6 +358189,86 @@ function validateRunnerMobileTarget(value) {
358158
358189
  };
358159
358190
  }
358160
358191
 
358192
+ // src/services/heartbeat-keepalive.ts
358193
+ var import_node_worker_threads = require("worker_threads");
358194
+ var KEEPALIVE_INTERVAL_MS = (() => {
358195
+ const raw = Number(process.env.VALIDATEQA_HEARTBEAT_KEEPALIVE_MS);
358196
+ return Number.isFinite(raw) && raw > 0 ? raw : 2e4;
358197
+ })();
358198
+ function isHeartbeatKeepaliveEnabled(env = process.env) {
358199
+ return (env.VALIDATEQA_HEARTBEAT_KEEPALIVE ?? "").trim().toLowerCase() !== "off";
358200
+ }
358201
+ var WORKER_SOURCE = `
358202
+ const { parentPort } = require('node:worker_threads');
358203
+ let url = null;
358204
+ let headers = null;
358205
+ let body = null;
358206
+ let intervalMs = 20000;
358207
+ let timer = null;
358208
+
358209
+ async function post() {
358210
+ if (!url || !headers || !body) return; // inert until the first real heartbeat
358211
+ try {
358212
+ await fetch(url, {
358213
+ method: 'POST',
358214
+ headers,
358215
+ body,
358216
+ signal: AbortSignal.timeout(10000),
358217
+ });
358218
+ } catch {
358219
+ // Best-effort by design: the next tick retries; the main loop's own
358220
+ // heartbeat error handling owns logging/upgrade signals.
358221
+ }
358222
+ }
358223
+
358224
+ parentPort.on('message', (msg) => {
358225
+ if (!msg || typeof msg !== 'object') return;
358226
+ if (msg.type === 'update') {
358227
+ url = msg.url;
358228
+ headers = msg.headers;
358229
+ body = msg.body;
358230
+ intervalMs = msg.intervalMs || intervalMs;
358231
+ if (!timer) {
358232
+ timer = setInterval(post, intervalMs);
358233
+ if (timer.unref) timer.unref();
358234
+ }
358235
+ } else if (msg.type === 'stop') {
358236
+ if (timer) clearInterval(timer);
358237
+ timer = null;
358238
+ parentPort.close();
358239
+ }
358240
+ });
358241
+ `;
358242
+ var NOOP_KEEPALIVE = { update: () => {
358243
+ }, stop: () => {
358244
+ } };
358245
+ function startHeartbeatKeepalive(options = {}) {
358246
+ if (!isHeartbeatKeepaliveEnabled(options.env)) return NOOP_KEEPALIVE;
358247
+ const intervalMs = options.intervalMs ?? KEEPALIVE_INTERVAL_MS;
358248
+ let worker;
358249
+ try {
358250
+ worker = new import_node_worker_threads.Worker(WORKER_SOURCE, { eval: true });
358251
+ worker.unref();
358252
+ worker.on("error", () => {
358253
+ worker = null;
358254
+ });
358255
+ } catch {
358256
+ return NOOP_KEEPALIVE;
358257
+ }
358258
+ return {
358259
+ update(url, headers, body) {
358260
+ worker?.postMessage({ type: "update", url, headers, body, intervalMs });
358261
+ },
358262
+ stop() {
358263
+ try {
358264
+ worker?.postMessage({ type: "stop" });
358265
+ } catch {
358266
+ }
358267
+ worker = null;
358268
+ }
358269
+ };
358270
+ }
358271
+
358161
358272
  // src/mobile-device-queue.ts
358162
358273
  function isNativeMobileDiscoveryContext(ctx) {
358163
358274
  if (!ctx) return false;
@@ -363762,6 +363873,7 @@ async function startRunner(opts) {
363762
363873
  }
363763
363874
  }
363764
363875
  };
363876
+ const heartbeatKeepalive = startHeartbeatKeepalive();
363765
363877
  const sendHeartbeat = async () => {
363766
363878
  try {
363767
363879
  const devices = enableMobile ? getCachedDeviceSnapshot() : [];
@@ -363776,41 +363888,44 @@ async function startRunner(opts) {
363776
363888
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
363777
363889
  lastInventoryKinds.clear();
363778
363890
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
363779
- const response = await fetch(`${serverUrl}/api/runner/heartbeat`, {
363891
+ const heartbeatUrl = `${serverUrl}/api/runner/heartbeat`;
363892
+ const heartbeatBody = JSON.stringify({
363893
+ runnerName: RUNNER_NAME,
363894
+ runnerVersion: RUNNER_VERSION,
363895
+ runnerBuildId: RUNNER_BUILD_ID,
363896
+ runsCompleted,
363897
+ activeRuns,
363898
+ maxConcurrent: MAX_CONCURRENT,
363899
+ uptimeMs: Date.now() - startedAt,
363900
+ capabilities,
363901
+ devices,
363902
+ mobileBridge: bridge ? {
363903
+ online: bridge.running,
363904
+ port: bridge.port,
363905
+ tunnelUrl: bridge.tunnelUrl,
363906
+ tunnelToken: bridge.tunnelToken
363907
+ } : void 0,
363908
+ activeByType: Object.fromEntries(activeByType),
363909
+ activeByProject: Object.fromEntries(activeByProject),
363910
+ typeLimits: TYPE_LIMITS,
363911
+ maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
363912
+ resourceSnapshot: resourceStatus.snapshot,
363913
+ resourceGovernor: resourceStatus.governor,
363914
+ processInventory,
363915
+ aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
363916
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
363917
+ // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
363918
+ // Cached & cheap — see getCachedDoctor above.
363919
+ doctor: getCachedDoctor()
363920
+ });
363921
+ const response = await fetch(heartbeatUrl, {
363780
363922
  method: "POST",
363781
363923
  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
- }),
363924
+ body: heartbeatBody,
363811
363925
  signal: AbortSignal.timeout(1e4)
363812
363926
  });
363813
363927
  if (response.ok) {
363928
+ heartbeatKeepalive.update(heartbeatUrl, headers, heartbeatBody);
363814
363929
  const body = await response.json().catch(() => null);
363815
363930
  if (body && Array.isArray(body.commands) && body.commands.length > 0) {
363816
363931
  void handleControlCommands(body.commands);
@@ -363881,6 +363996,7 @@ async function startRunner(opts) {
363881
363996
  process.exit(1);
363882
363997
  }, SHUTDOWN_TIMEOUT_MS).unref();
363883
363998
  clearInterval(heartbeat);
363999
+ heartbeatKeepalive.stop();
363884
364000
  if (liveBrowserHeartbeatTimer) {
363885
364001
  clearTimeout(liveBrowserHeartbeatTimer);
363886
364002
  liveBrowserHeartbeatTimer = null;
package/dist/cli.mjs CHANGED
@@ -11496,7 +11496,7 @@ var require_thread_stream = __commonJS({
11496
11496
  "use strict";
11497
11497
  var { version } = require_package();
11498
11498
  var { EventEmitter: EventEmitter2 } = __require("events");
11499
- var { Worker } = __require("worker_threads");
11499
+ var { Worker: Worker2 } = __require("worker_threads");
11500
11500
  var { join: join8 } = __require("path");
11501
11501
  var { pathToFileURL } = __require("url");
11502
11502
  var { wait } = require_wait();
@@ -11549,7 +11549,7 @@ var require_thread_stream = __commonJS({
11549
11549
  const { filename, workerData } = opts;
11550
11550
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
11551
11551
  const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
11552
- const worker = new Worker(toExecute, {
11552
+ const worker = new Worker2(toExecute, {
11553
11553
  ...opts.workerOpts,
11554
11554
  name: opts.workerOpts?.name || "thread-stream",
11555
11555
  trackUnmanagedFds: false,
@@ -74649,6 +74649,7 @@ var require_mobile_explorer = __commonJS({
74649
74649
  Object.defineProperty(exports, "__esModule", { value: true });
74650
74650
  exports.computeMobileSurveyBreadthContinuation = computeMobileSurveyBreadthContinuation;
74651
74651
  exports.computeMobileBreadthFinishRedirectDecision = computeMobileBreadthFinishRedirectDecision;
74652
+ exports.hybridScreenFingerprint = hybridScreenFingerprint;
74652
74653
  exports.runMobileExplorePhase = runMobileExplorePhase;
74653
74654
  var crypto_1 = __require("crypto");
74654
74655
  var shared_1 = (init_dist(), __toCommonJS(dist_exports));
@@ -75102,11 +75103,12 @@ var require_mobile_explorer = __commonJS({
75102
75103
  const validSurfaceDigest = /^[0-9a-f]{16,64}$/.test(normalizedSurfaceDigest) ? normalizedSurfaceDigest : void 0;
75103
75104
  const legacyElementDigests = [...new Set(elements.map((element) => element.pageIdentityDigest?.trim().toLowerCase() ?? "").filter((digest) => /^[0-9a-f]{16,64}$/.test(digest)))];
75104
75105
  const pageIdentityDigest = validSurfaceDigest ?? (legacyElementDigests.length === 1 ? legacyElementDigests[0] : void 0);
75106
+ const hasStableControlIdentity = elements.some((element) => Boolean(safeHybridIdentity(element.id, credentials) || safeHybridIdentity(element.name, credentials)));
75105
75107
  const descriptors = elements.map((element) => {
75106
75108
  const editable = element.editable === true || /^(?:input|textarea|select)$/i.test(element.tag) || /(?:textbox|searchbox|combobox)/i.test(element.role);
75107
75109
  const id = safeHybridIdentity(element.id, credentials)?.toLowerCase() ?? "";
75108
75110
  const name = safeHybridIdentity(element.name, credentials)?.toLowerCase() ?? "";
75109
- const semanticText = editable || id || name ? "" : stableHybridSemanticText(element.text, credentials);
75111
+ const semanticText = editable || id || name || hasStableControlIdentity ? "" : stableHybridSemanticText(element.text, credentials);
75110
75112
  return [
75111
75113
  element.tag.trim().toLowerCase(),
75112
75114
  element.role.trim().toLowerCase(),
@@ -83745,6 +83747,14 @@ ${statePacket}`, redactionCredentials) },
83745
83747
  return actions.filter((_, index) => !toDrop.has(index));
83746
83748
  }
83747
83749
  var MAX_DETOUR_LENGTH = 6;
83750
+ function isDetourClosingAction(action) {
83751
+ if (action.type === "BACK" || action.type === "HOME")
83752
+ return true;
83753
+ return isNavigationChromeTap(action);
83754
+ }
83755
+ function isEvidenceAction(action) {
83756
+ return action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT" || action.type === "WAIT_FOR_ELEMENT";
83757
+ }
83748
83758
  function elideNoOpDetours(actions) {
83749
83759
  if (actions.length < 2)
83750
83760
  return actions;
@@ -83752,12 +83762,16 @@ ${statePacket}`, redactionCredentials) },
83752
83762
  for (let j = 0; j < actions.length; j++) {
83753
83763
  if (toDrop.has(j))
83754
83764
  continue;
83755
- if (actions[j].type !== "BACK" && actions[j].type !== "HOME")
83765
+ if (!isDetourClosingAction(actions[j]))
83756
83766
  continue;
83757
83767
  const keyAfter = actions[j].metadata.structuralKey;
83758
83768
  if (!keyAfter)
83759
83769
  continue;
83770
+ const next = actions[j + 1];
83771
+ if (next && isEvidenceAction(next))
83772
+ continue;
83760
83773
  let bestStart = null;
83774
+ let leftScreen = false;
83761
83775
  const earliestStart = Math.max(0, j - MAX_DETOUR_LENGTH + 1);
83762
83776
  for (let i = j; i >= earliestStart; i--) {
83763
83777
  if (i < j) {
@@ -83765,11 +83779,14 @@ ${statePacket}`, redactionCredentials) },
83765
83779
  if (inside.type === "ASSERT_VISIBLE" || inside.type === "ASSERT_TEXT" || isDurableMutation(inside)) {
83766
83780
  break;
83767
83781
  }
83782
+ const insideKey = inside.metadata.structuralKey;
83783
+ if (insideKey && insideKey !== keyAfter)
83784
+ leftScreen = true;
83768
83785
  }
83769
83786
  if (i === 0)
83770
83787
  continue;
83771
83788
  const keyBefore = actions[i - 1].metadata.structuralKey;
83772
- if (i < j && keyBefore && keyBefore === keyAfter) {
83789
+ if (i < j && leftScreen && keyBefore && keyBefore === keyAfter) {
83773
83790
  bestStart = i;
83774
83791
  }
83775
83792
  }
@@ -352995,7 +353012,7 @@ var require_package4 = __commonJS({
352995
353012
  "package.json"(exports, module) {
352996
353013
  module.exports = {
352997
353014
  name: "@validate.qa/runner",
352998
- version: "1.0.71",
353015
+ version: "1.0.72",
352999
353016
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353000
353017
  bin: {
353001
353018
  "validate-runner": "dist/cli.js"
@@ -354807,7 +354824,11 @@ async function pressHome(sessionPath, platform3) {
354807
354824
  }
354808
354825
 
354809
354826
  // src/services/device-quarantine.ts
354810
- var quarantinedDevices = /* @__PURE__ */ new Set();
354827
+ var QUARANTINE_MAX_MS = (() => {
354828
+ const raw = Number(process.env.VALIDATEQA_DEVICE_QUARANTINE_MAX_MS);
354829
+ return Number.isFinite(raw) && raw > 0 ? raw : 10 * 60 * 1e3;
354830
+ })();
354831
+ var quarantinedAt = /* @__PURE__ */ new Map();
354811
354832
  var DeviceQuarantinedError = class extends Error {
354812
354833
  constructor(deviceId) {
354813
354834
  super(
@@ -354816,17 +354837,27 @@ var DeviceQuarantinedError = class extends Error {
354816
354837
  this.name = "DeviceQuarantinedError";
354817
354838
  }
354818
354839
  };
354819
- function quarantineDevice(deviceId) {
354820
- quarantinedDevices.add(deviceId);
354840
+ function quarantineDevice(deviceId, now = Date.now()) {
354841
+ quarantinedAt.set(deviceId, now);
354821
354842
  }
354822
354843
  function unquarantineDevice(deviceId) {
354823
- quarantinedDevices.delete(deviceId);
354844
+ quarantinedAt.delete(deviceId);
354824
354845
  }
354825
- function isDeviceQuarantined(deviceId) {
354826
- return typeof deviceId === "string" && quarantinedDevices.has(deviceId);
354846
+ function isDeviceQuarantined(deviceId, now = Date.now()) {
354847
+ if (typeof deviceId !== "string") return false;
354848
+ const since = quarantinedAt.get(deviceId);
354849
+ if (since === void 0) return false;
354850
+ if (now - since >= QUARANTINE_MAX_MS) {
354851
+ quarantinedAt.delete(deviceId);
354852
+ console.warn(
354853
+ `[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).`
354854
+ );
354855
+ return false;
354856
+ }
354857
+ return true;
354827
354858
  }
354828
- function assertDeviceNotQuarantined(deviceId) {
354829
- if (isDeviceQuarantined(deviceId)) {
354859
+ function assertDeviceNotQuarantined(deviceId, now = Date.now()) {
354860
+ if (isDeviceQuarantined(deviceId, now)) {
354830
354861
  throw new DeviceQuarantinedError(deviceId);
354831
354862
  }
354832
354863
  }
@@ -358166,6 +358197,86 @@ function validateRunnerMobileTarget(value) {
358166
358197
  };
358167
358198
  }
358168
358199
 
358200
+ // src/services/heartbeat-keepalive.ts
358201
+ import { Worker } from "worker_threads";
358202
+ var KEEPALIVE_INTERVAL_MS = (() => {
358203
+ const raw = Number(process.env.VALIDATEQA_HEARTBEAT_KEEPALIVE_MS);
358204
+ return Number.isFinite(raw) && raw > 0 ? raw : 2e4;
358205
+ })();
358206
+ function isHeartbeatKeepaliveEnabled(env = process.env) {
358207
+ return (env.VALIDATEQA_HEARTBEAT_KEEPALIVE ?? "").trim().toLowerCase() !== "off";
358208
+ }
358209
+ var WORKER_SOURCE = `
358210
+ const { parentPort } = require('node:worker_threads');
358211
+ let url = null;
358212
+ let headers = null;
358213
+ let body = null;
358214
+ let intervalMs = 20000;
358215
+ let timer = null;
358216
+
358217
+ async function post() {
358218
+ if (!url || !headers || !body) return; // inert until the first real heartbeat
358219
+ try {
358220
+ await fetch(url, {
358221
+ method: 'POST',
358222
+ headers,
358223
+ body,
358224
+ signal: AbortSignal.timeout(10000),
358225
+ });
358226
+ } catch {
358227
+ // Best-effort by design: the next tick retries; the main loop's own
358228
+ // heartbeat error handling owns logging/upgrade signals.
358229
+ }
358230
+ }
358231
+
358232
+ parentPort.on('message', (msg) => {
358233
+ if (!msg || typeof msg !== 'object') return;
358234
+ if (msg.type === 'update') {
358235
+ url = msg.url;
358236
+ headers = msg.headers;
358237
+ body = msg.body;
358238
+ intervalMs = msg.intervalMs || intervalMs;
358239
+ if (!timer) {
358240
+ timer = setInterval(post, intervalMs);
358241
+ if (timer.unref) timer.unref();
358242
+ }
358243
+ } else if (msg.type === 'stop') {
358244
+ if (timer) clearInterval(timer);
358245
+ timer = null;
358246
+ parentPort.close();
358247
+ }
358248
+ });
358249
+ `;
358250
+ var NOOP_KEEPALIVE = { update: () => {
358251
+ }, stop: () => {
358252
+ } };
358253
+ function startHeartbeatKeepalive(options = {}) {
358254
+ if (!isHeartbeatKeepaliveEnabled(options.env)) return NOOP_KEEPALIVE;
358255
+ const intervalMs = options.intervalMs ?? KEEPALIVE_INTERVAL_MS;
358256
+ let worker;
358257
+ try {
358258
+ worker = new Worker(WORKER_SOURCE, { eval: true });
358259
+ worker.unref();
358260
+ worker.on("error", () => {
358261
+ worker = null;
358262
+ });
358263
+ } catch {
358264
+ return NOOP_KEEPALIVE;
358265
+ }
358266
+ return {
358267
+ update(url, headers, body) {
358268
+ worker?.postMessage({ type: "update", url, headers, body, intervalMs });
358269
+ },
358270
+ stop() {
358271
+ try {
358272
+ worker?.postMessage({ type: "stop" });
358273
+ } catch {
358274
+ }
358275
+ worker = null;
358276
+ }
358277
+ };
358278
+ }
358279
+
358169
358280
  // src/mobile-device-queue.ts
358170
358281
  function isNativeMobileDiscoveryContext(ctx) {
358171
358282
  if (!ctx) return false;
@@ -363770,6 +363881,7 @@ async function startRunner(opts) {
363770
363881
  }
363771
363882
  }
363772
363883
  };
363884
+ const heartbeatKeepalive = startHeartbeatKeepalive();
363773
363885
  const sendHeartbeat = async () => {
363774
363886
  try {
363775
363887
  const devices = enableMobile ? getCachedDeviceSnapshot() : [];
@@ -363784,41 +363896,44 @@ async function startRunner(opts) {
363784
363896
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
363785
363897
  lastInventoryKinds.clear();
363786
363898
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
363787
- const response = await fetch(`${serverUrl}/api/runner/heartbeat`, {
363899
+ const heartbeatUrl = `${serverUrl}/api/runner/heartbeat`;
363900
+ const heartbeatBody = JSON.stringify({
363901
+ runnerName: RUNNER_NAME,
363902
+ runnerVersion: RUNNER_VERSION,
363903
+ runnerBuildId: RUNNER_BUILD_ID,
363904
+ runsCompleted,
363905
+ activeRuns,
363906
+ maxConcurrent: MAX_CONCURRENT,
363907
+ uptimeMs: Date.now() - startedAt,
363908
+ capabilities,
363909
+ devices,
363910
+ mobileBridge: bridge ? {
363911
+ online: bridge.running,
363912
+ port: bridge.port,
363913
+ tunnelUrl: bridge.tunnelUrl,
363914
+ tunnelToken: bridge.tunnelToken
363915
+ } : void 0,
363916
+ activeByType: Object.fromEntries(activeByType),
363917
+ activeByProject: Object.fromEntries(activeByProject),
363918
+ typeLimits: TYPE_LIMITS,
363919
+ maxProjectConcurrent: MAX_PROJECT_CONCURRENT,
363920
+ resourceSnapshot: resourceStatus.snapshot,
363921
+ resourceGovernor: resourceStatus.governor,
363922
+ processInventory,
363923
+ aiQueue: (0, import_runner_core13.getRunnerAIQueueStats)(),
363924
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
363925
+ // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
363926
+ // Cached & cheap — see getCachedDoctor above.
363927
+ doctor: getCachedDoctor()
363928
+ });
363929
+ const response = await fetch(heartbeatUrl, {
363788
363930
  method: "POST",
363789
363931
  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
- }),
363932
+ body: heartbeatBody,
363819
363933
  signal: AbortSignal.timeout(1e4)
363820
363934
  });
363821
363935
  if (response.ok) {
363936
+ heartbeatKeepalive.update(heartbeatUrl, headers, heartbeatBody);
363822
363937
  const body = await response.json().catch(() => null);
363823
363938
  if (body && Array.isArray(body.commands) && body.commands.length > 0) {
363824
363939
  void handleControlCommands(body.commands);
@@ -363889,6 +364004,7 @@ async function startRunner(opts) {
363889
364004
  process.exit(1);
363890
364005
  }, SHUTDOWN_TIMEOUT_MS).unref();
363891
364006
  clearInterval(heartbeat);
364007
+ heartbeatKeepalive.stop();
363892
364008
  if (liveBrowserHeartbeatTimer) {
363893
364009
  clearTimeout(liveBrowserHeartbeatTimer);
363894
364010
  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.72",
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"