@testdriverai/runner 7.11.27-test → 7.11.28-test

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.
package/index.js CHANGED
@@ -35,7 +35,7 @@ const fs = require('fs');
35
35
  const Ably = require('ably');
36
36
  const environments = require('../shared/environments.json');
37
37
 
38
- const { Automation } = require('./lib/automation');
38
+ const { Automation, getDiskSpace } = require('./lib/automation');
39
39
  const { AblyService } = require('./lib/ably-service');
40
40
 
41
41
  /**
@@ -338,11 +338,19 @@ class PresenceRunner {
338
338
  this.heartbeatInterval = setInterval(async () => {
339
339
  if (this.shuttingDown) return;
340
340
  try {
341
+ // Free space rides along on the heartbeat so every runner reports its
342
+ // own disk continuously — each machine has its own volume size and
343
+ // whatever the customer has installed or downloaded onto it, so a
344
+ // one-off check tells you nothing about the next session. statfs is a
345
+ // sub-millisecond syscall; omitted entirely when unavailable so
346
+ // "unknown" is never mistaken for "0 bytes free".
347
+ const disk = getDiskSpace();
341
348
  await this.runnerChannel.presence.update({
342
349
  ...this.presenceData,
343
350
  status: this.currentSession ? 'busy' : 'available',
344
351
  sandboxId: this.currentSession?.sandboxId || null,
345
352
  lastSeen: Date.now(),
353
+ ...(disk ? { freeBytes: disk.freeBytes, freePct: disk.freePct } : {}),
346
354
  });
347
355
  } catch (err) {
348
356
  log(`Warning: Heartbeat update failed: ${err.message}`);
package/lib/automation.js CHANGED
@@ -72,6 +72,81 @@ function readPngDimensions(buffer) {
72
72
  };
73
73
  }
74
74
 
75
+ // Log any pyautogui call that takes longer than this. Every call pays a fresh
76
+ // interpreter start plus the `import pyautogui` graph, so the normal range is
77
+ // tens to low-hundreds of ms — anything past this is drift worth seeing before
78
+ // it grows into a timeout.
79
+ const SLOW_PY_MS = 2000;
80
+
81
+ /**
82
+ * Free space on the volume holding `dir`, in bytes.
83
+ *
84
+ * `fs.statfsSync` is built in and works on Windows as well as Linux/macOS, so
85
+ * this stays a sub-millisecond syscall — cheap enough to run on a heartbeat
86
+ * and in the screenshot preflight. Returns null if the platform or the path
87
+ * won't answer, so callers treat "unknown" differently from "full".
88
+ * @param {string} [dir=os.tmpdir()]
89
+ * @returns {{ freeBytes: number, totalBytes: number, freePct: number } | null}
90
+ */
91
+ function getDiskSpace(dir = os.tmpdir()) {
92
+ try {
93
+ const { bsize, blocks, bavail } = fs.statfsSync(dir);
94
+ const totalBytes = blocks * bsize;
95
+ const freeBytes = bavail * bsize;
96
+ return {
97
+ freeBytes,
98
+ totalBytes,
99
+ freePct: totalBytes > 0 ? Math.round((freeBytes / totalBytes) * 100) : 0,
100
+ };
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Screenshot capture budget. 20s was too tight for a cold interpreter on a
107
+ // loaded Windows sandbox: the subprocess got SIGTERM'd and the caller saw a
108
+ // bare "Command failed" with no stderr. Generous here, since a real hang is
109
+ // now retried locally rather than costing the SDK four full-length attempts.
110
+ const SCREENSHOT_TIMEOUT_MS = 45000;
111
+
112
+ // Screenshots write a full-size PNG to the temp volume on every capture. Below
113
+ // this much free space that write is at risk, and a stalled or failed write
114
+ // looks exactly like a capture timeout from the caller's side.
115
+ const LOW_DISK_BYTES = 500 * 1024 * 1024;
116
+
117
+ // Only sweep screenshot temp files older than this, so a concurrent capture's
118
+ // in-flight file is never deleted out from under it.
119
+ const STALE_TMP_MS = 10 * 60 * 1000;
120
+
121
+ /**
122
+ * Delete leftover td_screenshot_* files older than STALE_TMP_MS.
123
+ *
124
+ * The capture path unlinks its own temp file in a `finally`, but that doesn't
125
+ * run if the runner process itself is killed mid-capture — which is exactly
126
+ * what happens on the failure paths we're chasing, so they leak precisely when
127
+ * disk is already tight.
128
+ * @returns {{ removed: number, bytes: number }}
129
+ */
130
+ function sweepStaleScreenshots() {
131
+ let removed = 0;
132
+ let bytes = 0;
133
+ const dir = os.tmpdir();
134
+ try {
135
+ for (const name of fs.readdirSync(dir)) {
136
+ if (!name.startsWith('td_screenshot_') || !name.endsWith('.png')) continue;
137
+ const file = path.join(dir, name);
138
+ try {
139
+ const stat = fs.statSync(file);
140
+ if (Date.now() - stat.mtimeMs < STALE_TMP_MS) continue;
141
+ fs.unlinkSync(file);
142
+ removed++;
143
+ bytes += stat.size;
144
+ } catch {}
145
+ }
146
+ } catch {}
147
+ return { removed, bytes };
148
+ }
149
+
75
150
  /**
76
151
  * Run a pyautogui Python script via subprocess.
77
152
  * @param {string} script — Python code (pyautogui + sys are already imported via PY_IMPORT prefix)
@@ -86,15 +161,34 @@ function runPyAutoGUI(script, extraArgs = [], timeout = 15000) {
86
161
  if (IS_LINUX) {
87
162
  env.DISPLAY = env.DISPLAY || ':0';
88
163
  }
164
+ // First line only — enough to identify the op in timing logs without
165
+ // pasting the whole program into every message.
166
+ const label = script.split('\n', 1)[0].slice(0, 60);
167
+ const startedAt = Date.now();
89
168
  execFile(PYTHON, ['-c', PY_IMPORT + script, ...extraArgs.map(String)], {
90
169
  encoding: 'utf-8',
91
170
  timeout,
92
171
  env,
93
172
  }, (err, stdout, stderr) => {
173
+ const elapsed = Date.now() - startedAt;
94
174
  if (err) {
175
+ // execFile kills the child when `timeout` elapses, surfacing as
176
+ // killed/SIGTERM with an empty stderr and a generic "Command failed"
177
+ // message. That is indistinguishable from a Python crash unless we
178
+ // name it, so say plainly that it was a timeout and how long it ran.
179
+ if (err.killed || err.signal) {
180
+ reject(new Error(
181
+ `pyautogui timed out after ${elapsed}ms (limit ${timeout}ms, ` +
182
+ `signal ${err.signal || 'none'}, no stderr) — ${label}`
183
+ ));
184
+ return;
185
+ }
95
186
  const msg = (stderr || '').trim() || err.message;
96
- reject(new Error(`pyautogui command failed: ${msg}`));
187
+ reject(new Error(`pyautogui command failed after ${elapsed}ms: ${msg}`));
97
188
  } else {
189
+ if (elapsed > SLOW_PY_MS) {
190
+ console.warn(`[automation] slow pyautogui: ${elapsed}ms — ${label}`);
191
+ }
98
192
  resolve((stdout || '').trim());
99
193
  }
100
194
  });
@@ -705,29 +799,66 @@ class Automation extends EventEmitter {
705
799
  async _captureScreenshotBuffer() {
706
800
  const sharp = require('sharp');
707
801
  const maxAttempts = 3;
802
+ let lastError;
803
+
804
+ // Preflight: a temp volume with no room turns the PNG write below into a
805
+ // stall or a failure that reads like a capture timeout. Check first so it
806
+ // fails loudly, and reclaim any leaked captures while we're here.
807
+ const disk = getDiskSpace();
808
+ if (disk && disk.freeBytes < LOW_DISK_BYTES) {
809
+ const freeMb = Math.round(disk.freeBytes / 1024 / 1024);
810
+ console.warn(
811
+ `[automation] LOW DISK: ${freeMb}MB free (${disk.freePct}%) on ${os.tmpdir()} — ` +
812
+ `screenshot writes may fail`
813
+ );
814
+ const swept = sweepStaleScreenshots();
815
+ if (swept.removed > 0) {
816
+ console.warn(
817
+ `[automation] swept ${swept.removed} stale screenshot temp files ` +
818
+ `(${Math.round(swept.bytes / 1024 / 1024)}MB reclaimed)`
819
+ );
820
+ }
821
+ }
708
822
 
709
823
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
710
824
  const tmpFile = path.join(os.tmpdir(), `td_screenshot_${Date.now()}.png`);
711
825
 
712
826
  try {
713
- // Capture screenshot via pyautogui → saves to temp file
714
- // Python handles Retina downscale: if physical size differs from logical,
715
- // the image is resized to logical dimensions before saving.
716
- await runPyAutoGUI(
827
+ // Capture screenshot via pyautogui → saves to temp file.
828
+ // Python only captures, saves, and reports sizes: physical (what the
829
+ // grab returned) then logical (what pyautogui reports). Any Retina /
830
+ // DPI-scaling downscale is done by sharp below — libvips is
831
+ // multithreaded, where PIL's LANCZOS resample is a single-threaded
832
+ // pass over a full-size frame inside the subprocess timeout budget.
833
+ const sizeLine = await runPyAutoGUI(
717
834
  'img = pyautogui.screenshot()\n' +
835
+ 'img.save(sys.argv[1], format="PNG")\n' +
718
836
  'logical = pyautogui.size()\n' +
719
- 'if img.size[0] != logical[0] or img.size[1] != logical[1]:\n' +
720
- ' from PIL import Image\n' +
721
- ' img = img.resize((logical[0], logical[1]), Image.LANCZOS)\n' +
722
- 'img.save(sys.argv[1], format="PNG")',
837
+ 'print(img.size[0], img.size[1], logical[0], logical[1])',
723
838
  [tmpFile],
724
- 20000
839
+ SCREENSHOT_TIMEOUT_MS
725
840
  );
726
841
 
727
842
  // Read the PNG and re-encode with sharp (lossless, no compression)
728
843
  const pngBuffer = fs.readFileSync(tmpFile);
729
844
  const image = sharp(pngBuffer);
730
845
 
846
+ // Downscale to logical dimensions when the grab came back at physical
847
+ // resolution. `fit: 'fill'` forces the exact target size — the default
848
+ // ('cover') would crop, which would silently shift every coordinate
849
+ // the agent derives from this image. `stats()` below reads the input
850
+ // image, so queuing the resize here doesn't affect black detection.
851
+ const [physW, physH, logW, logH] = sizeLine.split(/\s+/).map(Number);
852
+ const haveSizes = [physW, physH, logW, logH].every(
853
+ (n) => Number.isFinite(n) && n > 0
854
+ );
855
+ if (haveSizes && (physW !== logW || physH !== logH)) {
856
+ console.log(
857
+ `[automation] screenshot: downscaling ${physW}x${physH} → ${logW}x${logH}`
858
+ );
859
+ image.resize(logW, logH, { kernel: 'lanczos3', fit: 'fill' });
860
+ }
861
+
731
862
  // Detect all-black screenshots (Xvfb/compositor issue)
732
863
  if (IS_LINUX) {
733
864
  const { channels } = await image.stats();
@@ -765,11 +896,28 @@ class Automation extends EventEmitter {
765
896
  }
766
897
 
767
898
  return await image.png({ compressionLevel: 0 }).toBuffer();
899
+ } catch (err) {
900
+ // Retry capture failures here, not just the all-black case above.
901
+ // Without this a single timeout escapes on the first attempt and the
902
+ // SDK retries it at full cost — four capture timeouts back to back
903
+ // instead of one cheap local retry.
904
+ lastError = err;
905
+ console.warn(
906
+ `[automation] Screenshot attempt ${attempt}/${maxAttempts} failed: ${err.message}`
907
+ );
908
+ if (attempt < maxAttempts) {
909
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
910
+ continue;
911
+ }
912
+ throw err;
768
913
  } finally {
769
914
  // Clean up temp file
770
915
  try { fs.unlinkSync(tmpFile); } catch {}
771
916
  }
772
917
  }
918
+
919
+ // Only reachable if the loop exits without returning or throwing.
920
+ throw lastError || new Error('Screenshot failed: no attempts produced an image');
773
921
  }
774
922
 
775
923
  async _captureScreenshot() {
@@ -876,4 +1024,4 @@ class Automation extends EventEmitter {
876
1024
  }
877
1025
  }
878
1026
 
879
- module.exports = { Automation };
1027
+ module.exports = { Automation, getDiskSpace, sweepStaleScreenshots };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/runner",
3
- "version": "7.11.27-test",
3
+ "version": "7.11.28-test",
4
4
  "description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
5
5
  "main": "index.js",
6
6
  "bin": {