@testdriverai/runner 7.11.27-test → 7.11.28-canary
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 +9 -1
- package/lib/automation.js +159 -11
- package/package.json +1 -1
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
|
|
715
|
-
//
|
|
716
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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 };
|