@testdriverai/runner 7.10.0-canary.0 → 7.10.1-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.
@@ -40,16 +40,16 @@ function getLocalVersion() {
40
40
 
41
41
  /**
42
42
  * Upload a screenshot to S3 via presigned URL from the API
43
- *
43
+ *
44
44
  * @param {string} apiRoot - API base URL
45
45
  * @param {string} apiKey - Team API key
46
46
  * @param {string} sandboxId - Sandbox ID
47
- * @param {string} base64 - Base64-encoded image data
47
+ * @param {string|Buffer} payload - PNG bytes as a Buffer (preferred) or base64 string
48
48
  * @param {string} label - Label for the screenshot (e.g., 'screenshot', 'before', 'after')
49
49
  * @returns {Promise<{ s3Key: string, fileName: string } | null>}
50
50
  */
51
- async function uploadToS3(apiRoot, apiKey, sandboxId, base64, label = 'screenshot') {
52
- if (!base64 || !apiRoot || !apiKey) return null;
51
+ async function uploadToS3(apiRoot, apiKey, sandboxId, payload, label = 'screenshot') {
52
+ if (!payload || !apiRoot || !apiKey) return null;
53
53
 
54
54
  try {
55
55
  const fileName = `${label}-${Date.now()}.png`;
@@ -68,8 +68,10 @@ async function uploadToS3(apiRoot, apiKey, sandboxId, base64, label = 'screensho
68
68
  return null;
69
69
  }
70
70
 
71
- // Upload to S3 via presigned URL
72
- const pngBuffer = Buffer.from(base64, 'base64');
71
+ // Upload to S3 via presigned URL. Accept either a Buffer (no decode) or
72
+ // a base64 string (legacy callers) — Buffer is preferred to avoid the
73
+ // wasteful PNG → base64 → PNG round-trip.
74
+ const pngBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'base64');
73
75
  const url = new URL(uploadUrlResponse.uploadUrl);
74
76
  const transport = url.protocol === 'https:' ? https : http;
75
77
 
@@ -136,6 +138,41 @@ async function httpPost(apiRoot, path, body, extraHeaders = {}) {
136
138
  });
137
139
  }
138
140
 
141
+ // ─── EC2 Metadata Helpers ────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Fetch the public IPv4 address from the EC2 Instance Metadata Service (IMDSv1).
145
+ * Returns null if not running on EC2, instance has no public IP, or request times out.
146
+ * Non-blocking: uses a 2-second timeout so it never delays runner startup.
147
+ *
148
+ * @returns {Promise<string|null>}
149
+ */
150
+ function getEc2PublicIp() {
151
+ return new Promise((resolve) => {
152
+ const req = http.request(
153
+ {
154
+ hostname: '169.254.169.254',
155
+ port: 80,
156
+ path: '/latest/meta-data/public-ipv4',
157
+ method: 'GET',
158
+ timeout: 2000,
159
+ },
160
+ (res) => {
161
+ if (res.statusCode !== 200) {
162
+ resolve(null);
163
+ return;
164
+ }
165
+ let data = '';
166
+ res.on('data', (chunk) => { data += chunk; });
167
+ res.on('end', () => resolve(data.trim() || null));
168
+ }
169
+ );
170
+ req.on('error', () => resolve(null));
171
+ req.on('timeout', () => { req.destroy(); resolve(null); });
172
+ req.end();
173
+ });
174
+ }
175
+
139
176
  // ─── Ably Service Class ──────────────────────────────────────────────────────
140
177
 
141
178
  class AblyService extends EventEmitter {
@@ -164,6 +201,7 @@ class AblyService extends EventEmitter {
164
201
  this._sessionChannel = null;
165
202
  this._connected = false;
166
203
  this._statsInterval = null;
204
+ this._closed = false; // set to true in close() to suppress post-close events
167
205
  }
168
206
 
169
207
  /**
@@ -185,12 +223,12 @@ class AblyService extends EventEmitter {
185
223
  const text = typeof msg === 'string' ? msg : String(msg);
186
224
  const isError = /error/i.test(text) || /\[Level 1\]/i.test(text);
187
225
  if (isError) {
188
- console.error(`[Ably runner] ${text}`);
226
+ console.error('[Ably runner] Ably SDK error', { sandboxId: self._sandboxId, raw: text });
189
227
  Sentry.withScope((scope) => {
190
228
  scope.setTag('ably.client', 'runner');
191
229
  scope.setTag('sandbox.id', self._sandboxId);
192
230
  scope.setContext('ably_log', { raw: text });
193
- Sentry.captureMessage(`[Ably runner] ${text}`, 'error');
231
+ Sentry.captureMessage('Ably SDK error (runner)', 'error');
194
232
  });
195
233
  }
196
234
  Sentry.addBreadcrumb({
@@ -367,7 +405,10 @@ class AblyService extends EventEmitter {
367
405
  if (current === 'disconnected') {
368
406
  this._connected = false;
369
407
  this.emit('log', `Realtime connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}${retryIn ? ' (retryIn=' + retryIn + 'ms)' : ''}`);
370
- this.emit('log', 'Ably disconnected will auto-reconnect');
408
+ // Suppress auto-reconnect message when we've already initiated a clean close
409
+ if (!this._closed) {
410
+ this.emit('log', 'Ably disconnected — will auto-reconnect');
411
+ }
371
412
  } else if (current === 'connected' && previous !== 'initialized') {
372
413
  if (!this._connected) {
373
414
  this._connected = true;
@@ -390,8 +431,8 @@ class AblyService extends EventEmitter {
390
431
  this.emit('log', `Realtime connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`);
391
432
  }
392
433
 
393
- // Capture exceptions for bad states
394
- if (current === 'failed' || current === 'suspended' || (current === 'disconnected' && reason)) {
434
+ // Capture exceptions for bad states — skip if we're in a clean close sequence
435
+ if (!this._closed && (current === 'failed' || current === 'suspended' || (current === 'disconnected' && reason))) {
395
436
  Sentry.withScope((scope) => {
396
437
  scope.setTag('ably.client', 'runner');
397
438
  scope.setTag('ably.state', current);
@@ -477,12 +518,22 @@ class AblyService extends EventEmitter {
477
518
  this.emit('log', 'Listening for commands on Ably');
478
519
 
479
520
  // Signal readiness to SDK — commands sent before this would be lost
521
+
522
+ // Best-effort: fetch the public IP from EC2 instance metadata so the SDK can
523
+ // construct the VNC preview URL without relying solely on API-side IP lookup.
524
+ // Times out after 2s and resolves null if not on EC2 or instance has no public IP.
525
+ const ec2PublicIp = await getEc2PublicIp();
526
+ if (ec2PublicIp) {
527
+ this.emit('log', `EC2 public IP: ${ec2PublicIp}`);
528
+ }
529
+
480
530
  const readyPayload = {
481
531
  type: 'runner.ready',
482
532
  os: process.platform === 'win32' ? 'windows' : 'linux',
483
533
  sandboxId: this._sandboxId,
484
534
  runnerVersion: getLocalVersion() || 'unknown',
485
535
  timestamp: Date.now(),
536
+ ip: ec2PublicIp || undefined,
486
537
  };
487
538
  if (this._updateInfo) {
488
539
  readyPayload.update = {
@@ -579,6 +630,10 @@ class AblyService extends EventEmitter {
579
630
  * so that missed commands are actually executed.
580
631
  */
581
632
  async _recoverFromDiscontinuity() {
633
+ // Don't attempt recovery after an intentional close — subscriptions are
634
+ // nulled out in close() and the channel is being torn down
635
+ if (this._closed) return;
636
+
582
637
  const subs = [
583
638
  { name: 'command', sub: this._commandSubscription, handler: this._onCommandMsg },
584
639
  { name: 'control', sub: this._controlSubscription, handler: this._onControlMsg },
@@ -668,6 +723,9 @@ class AblyService extends EventEmitter {
668
723
  * Disconnect from Ably and clean up.
669
724
  */
670
725
  async close() {
726
+ if (this._closed) return; // Prevent double-close
727
+ this._closed = true;
728
+
671
729
  this.emit('log', 'Closing realtime service...');
672
730
 
673
731
  this._stopReadySignal();
@@ -677,6 +735,11 @@ class AblyService extends EventEmitter {
677
735
  this._statsInterval = null;
678
736
  }
679
737
 
738
+ // Null out subscription refs so _recoverFromDiscontinuity won't replay
739
+ // messages on a channel that is about to be torn down
740
+ this._commandSubscription = null;
741
+ this._controlSubscription = null;
742
+
680
743
  try {
681
744
  if (this._sessionChannel) this._sessionChannel.detach();
682
745
  } catch { }
package/lib/automation.js CHANGED
@@ -52,6 +52,26 @@ const PY_IMPORT = IS_LINUX
52
52
  ? "import os; os.environ.setdefault('DISPLAY', ':0'); import pyautogui, sys; pyautogui.FAILSAFE = False; "
53
53
  : 'import pyautogui, sys; pyautogui.FAILSAFE = False; ';
54
54
 
55
+ /**
56
+ * Read width/height from a PNG buffer's IHDR chunk without decoding the image.
57
+ * PNG layout: 8-byte signature, then the IHDR chunk whose 4-byte width and
58
+ * height live at byte offsets 16 and 20 (big-endian). Returns {} if the buffer
59
+ * doesn't look like a PNG so callers degrade to the resize fallback.
60
+ * @param {Buffer} buffer
61
+ * @returns {{ width?: number, height?: number }}
62
+ */
63
+ function readPngDimensions(buffer) {
64
+ const PNG_SIGNATURE = '89504e470d0a1a0a';
65
+ if (!buffer || buffer.length < 24 ||
66
+ buffer.slice(0, 8).toString('hex') !== PNG_SIGNATURE) {
67
+ return {};
68
+ }
69
+ return {
70
+ width: buffer.readUInt32BE(16),
71
+ height: buffer.readUInt32BE(20),
72
+ };
73
+ }
74
+
55
75
  /**
56
76
  * Run a pyautogui Python script via subprocess.
57
77
  * @param {string} script — Python code (pyautogui + sys are already imported via PY_IMPORT prefix)
@@ -150,6 +170,10 @@ class ShellSession {
150
170
  // Clear stderr buffer
151
171
  this._stderrLines = [];
152
172
 
173
+ // Capture the process reference now — the exit handler may set
174
+ // this._process to null before the Promise resolves/times out.
175
+ const proc = this._process;
176
+
153
177
  return await new Promise((resolve, reject) => {
154
178
  const timeoutMs = timeout * 1000;
155
179
  const timer = setTimeout(() => {
@@ -208,11 +232,11 @@ class ShellSession {
208
232
 
209
233
  const cleanup = () => {
210
234
  clearTimeout(timer);
211
- this._process.stdout.removeListener('data', onData);
235
+ proc.stdout.removeListener('data', onData);
212
236
  };
213
237
 
214
- this._process.stdout.on('data', onData);
215
- this._process.stdin.write(fullCommand);
238
+ proc.stdout.on('data', onData);
239
+ proc.stdin.write(fullCommand);
216
240
  });
217
241
  } catch (err) {
218
242
  return {
@@ -587,20 +611,26 @@ class Automation extends EventEmitter {
587
611
  // For extract/remember: capture screenshot, upload to S3, return s3Key
588
612
  // SDK will then call API with the s3Key
589
613
  // For system.screenshot (normalized to 'screenshot'): same flow (return s3Key instead of base64)
590
-
591
- // Capture screenshot locally
614
+
615
+ // Capture screenshot locally — buffer variant avoids an unnecessary
616
+ // round-trip through base64 (PNG buffer → base64 → PNG buffer) that
617
+ // doubles peak memory and adds ~10–30 ms of pure encoding cost on
618
+ // large screenshots.
592
619
  console.log('[automation] Step 1: Capturing screenshot...');
593
- const screenshot = await this._captureScreenshot();
594
- console.log(`[automation] Step 2: Screenshot captured, size: ${screenshot.length} bytes (base64)`);
595
- const buffer = Buffer.from(screenshot, 'base64');
596
- console.log(`[automation] Step 3: Buffer created, size: ${buffer.length} bytes`);
620
+ const buffer = await this._captureScreenshotBuffer();
621
+ console.log(`[automation] Step 2: Screenshot captured, size: ${buffer.length} bytes`);
622
+
623
+ // Read the PNG dimensions from the IHDR header (bytes 16–24) without
624
+ // decoding. The SDK uses these to decide whether it can pass the s3Key
625
+ // straight to the API (no resize needed) or must download + resize.
626
+ const dimensions = readPngDimensions(buffer);
597
627
 
598
628
  // Upload screenshot to S3 and return key
599
- console.log('[automation] Step 4: Uploading to S3...');
629
+ console.log('[automation] Step 3: Uploading to S3...');
600
630
  const s3Key = await this._uploadToS3(buffer, this._sandboxId, 'image/png');
601
- console.log(`[automation] Step 5: Upload complete, s3Key: ${s3Key}`);
602
-
603
- return { s3Key };
631
+ console.log(`[automation] Step 4: Upload complete, s3Key: ${s3Key}`);
632
+
633
+ return { s3Key, ...dimensions };
604
634
  }
605
635
 
606
636
  case 'ping':
@@ -664,7 +694,15 @@ class Automation extends EventEmitter {
664
694
 
665
695
  // ── Screenshot (highest quality PNG, via pyautogui → temp file → sharp) ──
666
696
 
667
- async _captureScreenshot() {
697
+ /**
698
+ * Capture a screenshot and return the raw PNG buffer.
699
+ *
700
+ * Prefer this when the next consumer of the bytes is binary (e.g. an HTTP
701
+ * PUT to S3): it avoids the wasteful PNG → base64 → PNG round-trip that
702
+ * `_captureScreenshot()` does for backwards compatibility with callers
703
+ * that still expect a base64 string.
704
+ */
705
+ async _captureScreenshotBuffer() {
668
706
  const sharp = require('sharp');
669
707
  const maxAttempts = 3;
670
708
 
@@ -702,11 +740,15 @@ class Automation extends EventEmitter {
702
740
  if (maxPixel <= 1) {
703
741
  console.warn(`[automation] Screenshot attempt ${attempt}/${maxAttempts}: image is all black (max pixel=${maxPixel})`);
704
742
  if (attempt < maxAttempts) {
705
- // Try to heal: poke the display to trigger a redraw
743
+ // Try to heal: poke the display to trigger a redraw.
744
+ // NOTE: do NOT send the `super` key here — on Linux desktops it's
745
+ // bound to the Activities overview / app launcher, which steals
746
+ // focus from (and visually hides) the active window. That made
747
+ // Chrome appear to "go missing" right after a flaky black capture.
748
+ // The xset display-wake calls are what actually recover the screen.
706
749
  try {
707
750
  await runPyAutoGUI(
708
751
  "import subprocess; " +
709
- "subprocess.run(['xdotool', 'key', '--clearmodifiers', 'super'], timeout=5); " +
710
752
  "subprocess.run(['xset', 's', 'off'], timeout=5); " +
711
753
  "subprocess.run(['xset', 's', 'noblank'], timeout=5); " +
712
754
  "subprocess.run(['xset', '-dpms'], timeout=5)",
@@ -722,8 +764,7 @@ class Automation extends EventEmitter {
722
764
  }
723
765
  }
724
766
 
725
- const buffer = await image.png({ compressionLevel: 0 }).toBuffer();
726
- return buffer.toString('base64');
767
+ return await image.png({ compressionLevel: 0 }).toBuffer();
727
768
  } finally {
728
769
  // Clean up temp file
729
770
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -731,6 +772,14 @@ class Automation extends EventEmitter {
731
772
  }
732
773
  }
733
774
 
775
+ async _captureScreenshot() {
776
+ // Kept for backwards compatibility with callers (e.g. Ably channel
777
+ // messages) that still want a base64 string. New code paths should
778
+ // prefer `_captureScreenshotBuffer()` to avoid the round-trip.
779
+ const buffer = await this._captureScreenshotBuffer();
780
+ return buffer.toString('base64');
781
+ }
782
+
734
783
  // ── Focus window (platform-specific) ───────────────────────────────
735
784
 
736
785
  async _focusWindow(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/runner",
3
- "version": "7.10.0-canary.0",
3
+ "version": "7.10.1-canary",
4
4
  "description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
5
5
  "main": "index.js",
6
6
  "bin": {
package/sandbox-agent.js CHANGED
@@ -33,6 +33,7 @@
33
33
  */
34
34
  require('dotenv').config();
35
35
  const Sentry = require('@sentry/node');
36
+ const crypto = require('crypto');
36
37
  const fs = require('fs');
37
38
  const os = require('os');
38
39
  const path = require('path');
@@ -181,6 +182,7 @@ function log(msg) {
181
182
  // ─── Main ────────────────────────────────────────────────────────────────────
182
183
 
183
184
  async function main() {
185
+ let rootSpan = null;
184
186
  log('TestDriver Sandbox Agent starting...');
185
187
  log(`Runner version: ${getLocalVersion() || 'unknown'}`);
186
188
 
@@ -207,13 +209,45 @@ async function main() {
207
209
  // Sample all runner-initiated traces (shouldn't happen often)
208
210
  return 1.0;
209
211
  },
210
- environment: config.sentryEnvironment || process.env.TD_ENV || 'production',
212
+ environment: config.sentryChannel || process.env.TD_CHANNEL || 'stable',
211
213
  release: version,
212
214
  serverName: os.hostname(),
213
215
  });
214
216
  if (config.sentryChannel || process.env.TD_CHANNEL) {
215
217
  Sentry.setTag('channel', config.sentryChannel || process.env.TD_CHANNEL);
216
218
  }
219
+
220
+ // Create a session-level root span for distributed tracing.
221
+ // Uses deterministic traceId = MD5(sessionId) so all components
222
+ // (SDK, API, Runner, Worker) share the same trace.
223
+ const sessionId = config.sessionId;
224
+ if (sessionId) {
225
+ const traceId = crypto.createHash('md5').update(sessionId).digest('hex');
226
+ const spanId = crypto.randomBytes(8).toString('hex');
227
+ const sentryTraceHeader = `${traceId}-${spanId}-1`;
228
+ const baggageHeader = `sentry-trace_id=${traceId},sentry-sampled=true`;
229
+
230
+ Sentry.continueTrace(
231
+ { sentryTrace: sentryTraceHeader, baggage: baggageHeader },
232
+ () => {
233
+ rootSpan = Sentry.startInactiveSpan({
234
+ name: 'runner.session',
235
+ op: 'session',
236
+ forceTransaction: true,
237
+ attributes: {
238
+ 'sandbox.id': config.sandboxId,
239
+ 'session.id': sessionId,
240
+ },
241
+ });
242
+ },
243
+ );
244
+
245
+ Sentry.setTag('session', sessionId);
246
+ Sentry.setTag('trace_id', traceId);
247
+ log(`Distributed tracing: session=${sessionId} traceId=${traceId}`);
248
+ }
249
+
250
+ Sentry.setTag('sandbox', config.sandboxId);
217
251
  log('Sentry initialized');
218
252
  }
219
253
  log(`Sandbox ID: ${config.sandboxId}`);
@@ -251,6 +285,9 @@ async function main() {
251
285
 
252
286
  const shutdown = async () => {
253
287
  log('Shutting down...');
288
+ if (rootSpan) {
289
+ try { rootSpan.end(); } catch (e) { /* ignore */ }
290
+ }
254
291
  await ablyService.close();
255
292
  automation.cleanup();
256
293
  await Sentry.close(2000);
@@ -168,9 +168,12 @@ else
168
168
  fi
169
169
 
170
170
  # ─── Force a screen redraw ────────────────────────────────────────────────────
171
- # Send a synthetic keypress so the display is definitely "active" and any
172
- # screen blanking timers are reset.
173
- xdotool key --clearmodifiers super 2>/dev/null || true
171
+ # Wake the display and reset any blanking timers WITHOUT synthetic input.
172
+ # Do NOT send the `super` key here — on Linux desktops it's bound to the
173
+ # Activities overview / app launcher, which steals focus from (and visually
174
+ # hides) the active window. Same reasoning as the black-screenshot recovery
175
+ # in lib/automation.js.
176
+ xset s reset 2>/dev/null || true
174
177
  sleep 1
175
178
 
176
179
  echo "[start-desktop] Desktop environment ready"