@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.
@@ -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
- this.emit('log', `Command received: ${type} (requestId=${requestId})`);
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.emit('log', `Command completed: ${type} (requestId=${requestId})`);
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.emit('log', `Command failed: ${type} — ${err.message}`);
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'); import pyautogui, sys; pyautogui.FAILSAFE = False; "
53
- : 'import pyautogui, sys; pyautogui.FAILSAFE = False; ';
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
- // First line only — enough to identify the op in timing logs without
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
- execFile(PYTHON, ['-c', PY_IMPORT + script, ...extraArgs.map(String)], {
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 elapsed = Date.now() - startedAt;
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 an empty stderr and a generic "Command failed"
299
- // message. That is indistinguishable from a Python crash unless we
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
- reject(new Error(
303
- `pyautogui timed out after ${elapsed}ms (limit ${timeout}ms, ` +
304
- `signal ${err.signal || 'none'}, no stderr) — ${label}`
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 = (stderr || '').trim() || err.message;
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(`[automation] slow pyautogui: ${elapsed}ms — ${label}`);
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
- await runPyAutoGUI(`pyautogui.click(${data.x}, ${data.y}, button=sys.argv[1])`, [button]);
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(`pyautogui.click(button=sys.argv[1])`, [button]);
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
- await runPyAutoGUI('pyautogui.write(sys.argv[1])', [data.text || '']);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/runner",
3
- "version": "7.11.96-test",
3
+ "version": "7.11.97-canary",
4
4
  "description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
5
5
  "main": "index.js",
6
6
  "bin": {