@testdriverai/runner 7.11.96-test → 7.11.97-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/lib/ably-service.js +16 -4
- package/lib/automation.js +148 -25
- package/package.json +1 -1
package/lib/ably-service.js
CHANGED
|
@@ -205,6 +205,7 @@ class AblyService extends EventEmitter {
|
|
|
205
205
|
this._connected = false;
|
|
206
206
|
this._statsInterval = null;
|
|
207
207
|
this._closed = false; // set to true in close() to suppress post-close events
|
|
208
|
+
this._inFlight = new Map(); // requestId → { type, startedAt }; commands currently executing
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
/**
|
|
@@ -323,8 +324,17 @@ class AblyService extends EventEmitter {
|
|
|
323
324
|
|
|
324
325
|
const requestId = message.requestId;
|
|
325
326
|
const type = message.type;
|
|
326
|
-
|
|
327
|
-
|
|
327
|
+
const receivedAt = Date.now();
|
|
328
|
+
|
|
329
|
+
// Anything still executing when this arrives means the SDK (or a
|
|
330
|
+
// redelivery) sent commands concurrently — the first thing to rule out
|
|
331
|
+
// when input gets garbled.
|
|
332
|
+
const inFlight = [...this._inFlight.values()]
|
|
333
|
+
.map((c) => `${c.type}+${receivedAt - c.startedAt}ms`);
|
|
334
|
+
this.emit('log',
|
|
335
|
+
`Command received: ${type} (requestId=${requestId})` +
|
|
336
|
+
(inFlight.length ? ` while in-flight: [${inFlight.join(', ')}]` : ''));
|
|
337
|
+
this._inFlight.set(requestId, { type, startedAt: receivedAt });
|
|
328
338
|
|
|
329
339
|
// Stop re-publishing runner.ready once we get the first command
|
|
330
340
|
this._stopReadySignal();
|
|
@@ -358,11 +368,13 @@ class AblyService extends EventEmitter {
|
|
|
358
368
|
result,
|
|
359
369
|
success: true,
|
|
360
370
|
});
|
|
361
|
-
this.
|
|
371
|
+
this._inFlight.delete(requestId);
|
|
372
|
+
this.emit('log', `Command completed: ${type} (requestId=${requestId}) in ${Date.now() - receivedAt}ms`);
|
|
362
373
|
const span = Sentry.getActiveSpan();
|
|
363
374
|
if (span) span.setAttribute('command.success', true);
|
|
364
375
|
} catch (err) {
|
|
365
|
-
this.
|
|
376
|
+
this._inFlight.delete(requestId);
|
|
377
|
+
this.emit('log', `Command failed: ${type} after ${Date.now() - receivedAt}ms — ${err.message}`);
|
|
366
378
|
const span = Sentry.getActiveSpan();
|
|
367
379
|
if (span) {
|
|
368
380
|
span.setAttribute('command.success', false);
|
package/lib/automation.js
CHANGED
|
@@ -46,11 +46,21 @@ const API_KEY = process.env.TD_API_KEY;
|
|
|
46
46
|
// shell injection and escaping issues.
|
|
47
47
|
|
|
48
48
|
const PYTHON = IS_WINDOWS ? 'python' : 'python3';
|
|
49
|
+
// `_td(phase, **kv)` writes `__td <phase> t=<epoch ms> [k=v ...]` to stderr and
|
|
50
|
+
// flushes, so even a SIGTERM-killed process leaves a trail of how far it got.
|
|
51
|
+
// Scripts can call _td() themselves to mark sub-steps (see click/write).
|
|
52
|
+
const PY_MARK =
|
|
53
|
+
"import sys, time; " +
|
|
54
|
+
"_td = lambda p, **kw: (sys.stderr.write('__td ' + p + ' t=%d' % int(time.time() * 1000) + ''.join(' %s=%s' % kv for kv in kw.items()) + '\\n'), sys.stderr.flush()); " +
|
|
55
|
+
"_td('start'); ";
|
|
56
|
+
// Disable FAILSAFE (move to corner aborts) and PAUSE (100ms delay between calls).
|
|
57
|
+
const PY_SETUP =
|
|
58
|
+
"import pyautogui, atexit; pyautogui.FAILSAFE = False; pyautogui.PAUSE = 0; " +
|
|
59
|
+
"_td('imported'); atexit.register(lambda: _td('exit'))";
|
|
49
60
|
// On Linux, ensure DISPLAY is set (use env var or fallback to :0)
|
|
50
|
-
// The os.environ.get() preserves the parent's DISPLAY setting for E2B's :1 display
|
|
51
61
|
const PY_IMPORT = IS_LINUX
|
|
52
|
-
? "import os; os.environ.setdefault('DISPLAY', ':0');
|
|
53
|
-
:
|
|
62
|
+
? PY_MARK + "import os; os.environ.setdefault('DISPLAY', ':0'); " + PY_SETUP
|
|
63
|
+
: PY_MARK + PY_SETUP;
|
|
54
64
|
|
|
55
65
|
/**
|
|
56
66
|
* Read width/height from a PNG buffer's IHDR chunk without decoding the image.
|
|
@@ -200,6 +210,78 @@ async function fetchWithRetry(url, init, {
|
|
|
200
210
|
// it grows into a timeout.
|
|
201
211
|
const SLOW_PY_MS = 2000;
|
|
202
212
|
|
|
213
|
+
// In-flight pyautogui subprocesses and the most recent completed one, so a
|
|
214
|
+
// timeout can say whether another call overlapped it or just preceded it.
|
|
215
|
+
let pyCallSeq = 0;
|
|
216
|
+
const activePy = new Map(); // seq → { label, startedAt }
|
|
217
|
+
let lastPy = null; // { label, startedAt, endedAt }
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Split `__td` phase markers out of stderr.
|
|
221
|
+
* @param {string} stderr
|
|
222
|
+
* @returns {{ phases: Array<{phase: string, t: number, extra: string}>, stderr: string }}
|
|
223
|
+
*/
|
|
224
|
+
function parsePhaseMarkers(stderr) {
|
|
225
|
+
const phases = [];
|
|
226
|
+
const rest = [];
|
|
227
|
+
for (const line of (stderr || '').split(/\r?\n/)) {
|
|
228
|
+
const m = /^__td (\S+) t=(\d+)(.*)$/.exec(line);
|
|
229
|
+
if (m) phases.push({ phase: m[1], t: Number(m[2]), extra: m[3].trim() });
|
|
230
|
+
else if (line.trim()) rest.push(line);
|
|
231
|
+
}
|
|
232
|
+
return { phases, stderr: rest.join('\n') };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Render phases as offsets from spawn, e.g.
|
|
237
|
+
* `start@+95ms imported@+900ms clicked@+930ms pos=414,51 exit@+931ms`.
|
|
238
|
+
* If the process never reached 'exit', says where it stalled and for how long.
|
|
239
|
+
*/
|
|
240
|
+
function describePhases(phases, startedAt, endedAt) {
|
|
241
|
+
if (!phases.length) return 'no phases reached (interpreter never started executing)';
|
|
242
|
+
const parts = phases.map((p) => `${p.phase}@+${p.t - startedAt}ms${p.extra ? ' ' + p.extra : ''}`);
|
|
243
|
+
const last = phases[phases.length - 1];
|
|
244
|
+
if (last.phase !== 'exit') {
|
|
245
|
+
parts.push(`stalled after '${last.phase}' for ${endedAt - last.t}ms`);
|
|
246
|
+
}
|
|
247
|
+
return parts.join(' ');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Describe other pyautogui calls relative to one that started at `startedAt`.
|
|
252
|
+
* @param {Array<{label: string, startedAt: number}>} overlapping - calls in flight at start
|
|
253
|
+
* @param {{label: string, endedAt: number}|null} prev - last call that finished before start
|
|
254
|
+
*/
|
|
255
|
+
function describeNeighbours(overlapping, prev, startedAt) {
|
|
256
|
+
const parts = [];
|
|
257
|
+
parts.push(overlapping.length
|
|
258
|
+
? `overlapping: [${overlapping.map((c) => `${c.label} (started ${startedAt - c.startedAt}ms earlier)`).join(', ')}]`
|
|
259
|
+
: 'overlapping: none');
|
|
260
|
+
if (prev) parts.push(`previous: ${prev.label} ended ${startedAt - prev.endedAt}ms before start`);
|
|
261
|
+
return parts.join(' | ');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Best-effort snapshot of the desktop when a pyautogui call times out:
|
|
266
|
+
* which window is in the foreground, and which python processes exist (with
|
|
267
|
+
* age in seconds) — a stale one means an earlier command is still running.
|
|
268
|
+
* Bounded to a few seconds so it can't turn a timeout into a longer stall.
|
|
269
|
+
*/
|
|
270
|
+
async function collectTimeoutDiagnostics() {
|
|
271
|
+
const win = IS_WINDOWS
|
|
272
|
+
? runScript(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(__dirname, '..', 'getActiveWindow.ps1')], 5)
|
|
273
|
+
: runScript(['bash', '-c', "xdotool getactivewindow getwindowname 2>/dev/null || echo ''"], 5);
|
|
274
|
+
const procs = IS_WINDOWS
|
|
275
|
+
? runScript(['powershell', '-NoProfile', '-Command',
|
|
276
|
+
'Get-Process python* -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id):$([int]((Get-Date) - $_.StartTime).TotalSeconds)s" }'], 5)
|
|
277
|
+
: runScript(['bash', '-c', "ps -eo pid,etimes,comm | awk '$3 ~ /python/ {print $1\":\"$2\"s\"}'"], 5);
|
|
278
|
+
const [w, p] = await Promise.all([win, procs]);
|
|
279
|
+
const squash = (s) => s.trim().replace(/\s+/g, ' ');
|
|
280
|
+
const winStr = w.success ? (squash(w.stdout).slice(0, 200) || 'none') : `unavailable (${squash(w.stderr).slice(0, 100)})`;
|
|
281
|
+
const procStr = p.success ? (squash(p.stdout) || 'none') : 'unavailable';
|
|
282
|
+
return `activeWindow=${winStr} | pythonProcs(pid:age)=${procStr}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
203
285
|
/**
|
|
204
286
|
* Free space on the volume holding `dir`, in bytes.
|
|
205
287
|
*
|
|
@@ -274,42 +356,59 @@ function sweepStaleScreenshots() {
|
|
|
274
356
|
* @param {string} script — Python code (pyautogui + sys are already imported via PY_IMPORT prefix)
|
|
275
357
|
* @param {string[]} [extraArgs=[]] — additional args available as sys.argv[1], sys.argv[2], etc.
|
|
276
358
|
* @param {number} [timeout=15000] — subprocess timeout in ms
|
|
359
|
+
* @param {string} [label] — short name for logs; defaults to the script's first statement
|
|
277
360
|
* @returns {Promise<string>} stdout output (trimmed)
|
|
278
361
|
*/
|
|
279
|
-
function runPyAutoGUI(script, extraArgs = [], timeout = 15000) {
|
|
362
|
+
function runPyAutoGUI(script, extraArgs = [], timeout = 15000, label) {
|
|
280
363
|
return new Promise((resolve, reject) => {
|
|
281
364
|
// Always force DISPLAY=:0 on Linux so pyautogui can connect to X11
|
|
282
365
|
const env = { ...process.env };
|
|
283
366
|
if (IS_LINUX) {
|
|
284
367
|
env.DISPLAY = env.DISPLAY || ':0';
|
|
285
368
|
}
|
|
286
|
-
|
|
287
|
-
// pasting the whole program into every message.
|
|
288
|
-
const label = script.split('\n', 1)[0].slice(0, 60);
|
|
369
|
+
label = label || script.split(/[;\n]/, 1)[0].slice(0, 60);
|
|
289
370
|
const startedAt = Date.now();
|
|
290
|
-
|
|
371
|
+
const overlapping = [...activePy.values()];
|
|
372
|
+
const prev = lastPy;
|
|
373
|
+
const seq = ++pyCallSeq;
|
|
374
|
+
activePy.set(seq, { label, startedAt });
|
|
375
|
+
// Newline (not `;`) between prelude and script so compound statements
|
|
376
|
+
// (for/try) in the script are valid.
|
|
377
|
+
execFile(PYTHON, ['-c', PY_IMPORT + '\n' + script, ...extraArgs.map(String)], {
|
|
291
378
|
encoding: 'utf-8',
|
|
292
379
|
timeout,
|
|
293
380
|
env,
|
|
294
|
-
}, (err, stdout, stderr) => {
|
|
295
|
-
const
|
|
381
|
+
}, async (err, stdout, stderr) => {
|
|
382
|
+
const endedAt = Date.now();
|
|
383
|
+
const elapsed = endedAt - startedAt;
|
|
384
|
+
activePy.delete(seq);
|
|
385
|
+
lastPy = { label, startedAt, endedAt };
|
|
386
|
+
const { phases, stderr: cleanStderr } = parsePhaseMarkers(stderr);
|
|
387
|
+
const phaseSummary = describePhases(phases, startedAt, endedAt);
|
|
296
388
|
if (err) {
|
|
297
389
|
// execFile kills the child when `timeout` elapses, surfacing as
|
|
298
|
-
// killed/SIGTERM with
|
|
299
|
-
//
|
|
300
|
-
// name it, so say plainly that it was a timeout and how long it ran.
|
|
390
|
+
// killed/SIGTERM with a generic "Command failed" message. Name it as a
|
|
391
|
+
// timeout and attach everything we know about where it stalled.
|
|
301
392
|
if (err.killed || err.signal) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
`signal ${err.signal || 'none'}
|
|
305
|
-
|
|
393
|
+
const diag = await collectTimeoutDiagnostics().catch((e) => `diagnostics failed: ${e.message}`);
|
|
394
|
+
const msg =
|
|
395
|
+
`pyautogui timed out after ${elapsed}ms (limit ${timeout}ms, signal ${err.signal || 'none'}) — ${label}` +
|
|
396
|
+
` | phases: ${phaseSummary}` +
|
|
397
|
+
` | ${describeNeighbours(overlapping, prev, startedAt)}` +
|
|
398
|
+
` | ${diag}` +
|
|
399
|
+
(cleanStderr ? ` | stderr: ${cleanStderr.slice(0, 300)}` : '');
|
|
400
|
+
console.warn(`[automation] ${msg}`);
|
|
401
|
+
reject(new Error(msg));
|
|
306
402
|
return;
|
|
307
403
|
}
|
|
308
|
-
const msg =
|
|
309
|
-
reject(new Error(`pyautogui command failed after ${elapsed}ms: ${msg}`));
|
|
404
|
+
const msg = cleanStderr || err.message;
|
|
405
|
+
reject(new Error(`pyautogui command failed after ${elapsed}ms (${label} | phases: ${phaseSummary}): ${msg}`));
|
|
310
406
|
} else {
|
|
311
407
|
if (elapsed > SLOW_PY_MS) {
|
|
312
|
-
console.warn(
|
|
408
|
+
console.warn(
|
|
409
|
+
`[automation] slow pyautogui: ${elapsed}ms — ${label} | phases: ${phaseSummary}` +
|
|
410
|
+
` | ${describeNeighbours(overlapping, prev, startedAt)}`
|
|
411
|
+
);
|
|
313
412
|
}
|
|
314
413
|
resolve((stdout || '').trim());
|
|
315
414
|
}
|
|
@@ -549,9 +648,12 @@ class Automation extends EventEmitter {
|
|
|
549
648
|
"for k in ['shift','shiftleft','shiftright','ctrl','ctrlleft','ctrlright'," +
|
|
550
649
|
"'alt','altleft','altright','command','win','winleft','winright']:\n" +
|
|
551
650
|
" try: pyautogui.keyUp(k)\n" +
|
|
552
|
-
" except: pass"
|
|
651
|
+
" except: pass",
|
|
652
|
+
[], 15000, 'releaseModifiers',
|
|
553
653
|
);
|
|
554
|
-
} catch {
|
|
654
|
+
} catch (err) {
|
|
655
|
+
console.warn(`[automation] releaseModifiers failed: ${err.message}`);
|
|
656
|
+
}
|
|
555
657
|
}
|
|
556
658
|
|
|
557
659
|
/**
|
|
@@ -600,9 +702,19 @@ class Automation extends EventEmitter {
|
|
|
600
702
|
const button = data.button || 'left';
|
|
601
703
|
if (data.x != null && data.y != null) {
|
|
602
704
|
console.log(`[automation] click: (${data.x},${data.y})`);
|
|
603
|
-
|
|
705
|
+
// moveTo + click split with markers so a hang is attributable to
|
|
706
|
+
// the cursor move vs. the button event, and `pos` confirms the
|
|
707
|
+
// cursor actually landed where the click was requested.
|
|
708
|
+
await runPyAutoGUI(
|
|
709
|
+
`pyautogui.moveTo(${data.x}, ${data.y}); _td('moved', pos='%d,%d' % pyautogui.position()); ` +
|
|
710
|
+
`pyautogui.click(button=sys.argv[1]); _td('clicked', pos='%d,%d' % pyautogui.position())`,
|
|
711
|
+
[button], 15000, `click(${data.x},${data.y},${button})`,
|
|
712
|
+
);
|
|
604
713
|
} else {
|
|
605
|
-
await runPyAutoGUI(
|
|
714
|
+
await runPyAutoGUI(
|
|
715
|
+
`pyautogui.click(button=sys.argv[1]); _td('clicked', pos='%d,%d' % pyautogui.position())`,
|
|
716
|
+
[button], 15000, `click(${button})`,
|
|
717
|
+
);
|
|
606
718
|
}
|
|
607
719
|
return true;
|
|
608
720
|
}
|
|
@@ -676,7 +788,18 @@ class Automation extends EventEmitter {
|
|
|
676
788
|
case 'write': {
|
|
677
789
|
// Type each character — matches pyautogui.write() behavior
|
|
678
790
|
// Text passed via sys.argv[1] to avoid escaping issues
|
|
679
|
-
|
|
791
|
+
// Add interval between keystrokes to prevent garbled/dropped chars.
|
|
792
|
+
// Without an interval, keystrokes can collide with concurrent commands
|
|
793
|
+
// (like clicks) or overflow the keyboard buffer.
|
|
794
|
+
// Default: 50ms. Can be overridden via data.interval.
|
|
795
|
+
const interval = data.interval ?? 0.05;
|
|
796
|
+
const text = data.text || '';
|
|
797
|
+
// Typing time grows with length; give it 2x the expected duration on top of the base.
|
|
798
|
+
const writeTimeout = 15000 + Math.ceil(text.length * interval * 1000 * 2);
|
|
799
|
+
await runPyAutoGUI(
|
|
800
|
+
`pyautogui.write(sys.argv[1], interval=${interval}); _td('typed', chars=len(sys.argv[1]))`,
|
|
801
|
+
[text], writeTimeout, `write(${text.length} chars, interval=${interval})`,
|
|
802
|
+
);
|
|
680
803
|
await this.releaseModifiers();
|
|
681
804
|
return true;
|
|
682
805
|
}
|