@testdriverai/runner 7.8.0-canary.14 → 7.8.0-test.39

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/README.md CHANGED
@@ -1,187 +1 @@
1
- # @testdriverai/runner
2
-
3
- The TestDriver Runner is a desktop automation agent that connects to the TestDriver API via [Ably](https://ably.com/) realtime messaging. It receives commands from the SDK (click, type, find, screenshot, etc.) and executes them on a desktop environment using PyAutoGUI and Sharp.
4
-
5
- ## Architecture
6
-
7
- The runner operates in two modes:
8
-
9
- | Mode | Binary | Use Case |
10
- |------|--------|----------|
11
- | **Presence Runner** | `testdriver-runner` | Self-registers with the API, enters Ably presence, and waits for SDK sessions to claim it. Used for persistent/pooled runners. |
12
- | **Sandbox Agent** | `testdriver-sandbox-agent` | Reads pre-provisioned credentials from a config file or environment variables. Used for ephemeral cloud sandboxes (E2B, AWS EC2). |
13
-
14
- ## Prerequisites
15
-
16
- ### System Requirements
17
-
18
- - **Node.js** >= 18
19
- - **Python 3** with `pyautogui` and `Pillow`
20
- - A desktop environment (physical display, VNC, or virtual framebuffer)
21
-
22
- ### Desktop Environment (Linux)
23
-
24
- ```bash
25
- # Virtual display + desktop
26
- apt-get install -y xvfb xfce4 xfce4-terminal dbus-x11 wmctrl
27
-
28
- # VNC access (optional, for debugging)
29
- apt-get install -y tigervnc-standalone-server novnc websockify
30
-
31
- # Python automation
32
- pip3 install pyautogui python-xlib Pillow
33
- ```
34
-
35
- ### Desktop Environment (Windows)
36
-
37
- - Standard Windows desktop (RDP or console session)
38
- - Python 3 with `pyautogui` and `Pillow`:
39
- ```powershell
40
- pip install pyautogui Pillow
41
- ```
42
-
43
- ### Chrome
44
-
45
- Google Chrome or Chrome for Testing must be installed and accessible on `PATH`.
46
-
47
- ## Installation
48
-
49
- ### From the TestDriver API (recommended)
50
-
51
- ```bash
52
- curl -fSL -H "x-api-key: $TD_API_KEY" \
53
- https://api.testdriver.ai/api/v7/runner/download \
54
- -o /tmp/testdriverai-runner.tgz && \
55
- npm install -g /tmp/testdriverai-runner.tgz && \
56
- rm /tmp/testdriverai-runner.tgz
57
- ```
58
-
59
- ### From source (development)
60
-
61
- ```bash
62
- cd runner
63
- npm install
64
- npm start
65
- ```
66
-
67
- ## Quick Start
68
-
69
- ### Presence Runner
70
-
71
- ```bash
72
- export TD_API_KEY="your-team-api-key"
73
- testdriver-runner
74
- ```
75
-
76
- The runner will:
77
- 1. Register with the API at `/api/v7/runner/register`
78
- 2. Receive an Ably token and channel
79
- 3. Enter presence on the runner channel
80
- 4. Wait for SDK sessions to claim it
81
-
82
- ### Sandbox Agent
83
-
84
- The sandbox agent reads credentials from a JSON config file that the API provisions (via SSM, cloud-init, etc.):
85
-
86
- **Linux:** `/tmp/testdriver-agent.json`
87
- **Windows:** `C:\Windows\Temp\testdriver-agent.json`
88
-
89
- ```json
90
- {
91
- "sandboxId": "sb-abc123",
92
- "ably": {
93
- "token": "ably-token-string",
94
- "channel": "testdriver:env:team:sandbox"
95
- },
96
- "apiRoot": "https://api.testdriver.ai",
97
- "apiKey": "team-api-key"
98
- }
99
- ```
100
-
101
- Start the agent (it will wait up to 5 minutes for the config file to appear):
102
-
103
- ```bash
104
- testdriver-sandbox-agent
105
- ```
106
-
107
- Or pass credentials via environment variables instead:
108
-
109
- ```bash
110
- export SANDBOX_ID="my-sandbox"
111
- export ABLY_TOKEN='{"token":"..."}'
112
- export ABLY_CHANNEL="testdriver:env:team:sandbox"
113
- testdriver-sandbox-agent
114
- ```
115
-
116
- ## Environment Variables
117
-
118
- ### Required
119
-
120
- | Variable | Description |
121
- |----------|-------------|
122
- | `TD_API_KEY` | Team API key (presence runner mode) |
123
-
124
- ### Optional
125
-
126
- | Variable | Default | Description |
127
- |----------|---------|-------------|
128
- | `TD_API_ROOT` | Per `TD_ENV` | API server URL |
129
- | `TD_ENV` | `stable` | Environment (`dev` / `test` / `canary` / `stable`) |
130
- | `TD_RUNNER_ID` | Auto-generated UUID | Fixed runner identifier |
131
- | `TD_RUNNER_SINGLE` | `false` | Exit after one session |
132
- | `TD_RUNNER_OS` | Auto-detected | OS capability advertised to API |
133
- | `TD_VNC_URL` | Auto-detected | Public VNC URL override |
134
- | `TD_NOVNC_PORT` | Auto-detected | noVNC WebSocket proxy port |
135
- | `SANDBOX_ID` | Auto-generated | Sandbox identifier (agent mode) |
136
- | `ABLY_TOKEN` | From config file | Ably auth token JSON (agent mode) |
137
- | `ABLY_CHANNEL` | From config file | Ably channel name (agent mode) |
138
- | `CONFIG_PATH` | `/tmp/testdriver-agent.json` | Config file path override (agent mode) |
139
- | `SCREEN_WIDTH` | `1366` | Virtual display width (Linux) |
140
- | `SCREEN_HEIGHT` | `768` | Virtual display height (Linux) |
141
- | `DISPLAY` | `:0` | X11 display (Linux) |
142
-
143
- ## Logs
144
-
145
- | Platform | Runner Log | Agent Log |
146
- |----------|-----------|-----------|
147
- | Linux/macOS | `/tmp/testdriver-runner.log` | `/tmp/testdriver-agent.log` |
148
- | Windows | `C:\Windows\Temp\testdriver-runner.log` | `C:\Windows\Temp\testdriver-agent.log` |
149
-
150
- ## Desktop Scripts
151
-
152
- Helper scripts in `scripts-desktop/` for managing the Linux desktop environment:
153
-
154
- | Script | Purpose |
155
- |--------|---------|
156
- | `start-desktop.sh` | Starts Xvfb, XFCE, D-Bus, disables screen blanking |
157
- | `launch_chrome.sh` | Launches Chrome with standard flags |
158
- | `launch_chrome_for_testing.sh` | Launches Chrome for Testing with remote debugging (port 9222) |
159
- | `control_window.sh` | Window management (minimize, restore, focus) via wmctrl |
160
-
161
- ## Deployment
162
-
163
- ### AWS AMI (Packer)
164
-
165
- See `packer/` for Packer templates that build AMIs with the runner pre-installed. The AMI includes the full desktop stack, Chrome, Python, and the runner.
166
-
167
- ### E2B Sandboxes
168
-
169
- The E2B template installs the runner in a Dockerfile. See `sdk/setup/e2b/` for the recommended setup.
170
-
171
- ### Docker
172
-
173
- ```bash
174
- TD_API_KEY=your-key docker compose up --build
175
- ```
176
-
177
- ## Updating
178
-
179
- Re-download and reinstall:
180
-
181
- ```bash
182
- curl -fSL -H "x-api-key: $TD_API_KEY" \
183
- https://api.testdriver.ai/api/v7/runner/download \
184
- -o /tmp/testdriverai-runner.tgz && \
185
- npm install -g /tmp/testdriverai-runner.tgz && \
186
- rm /tmp/testdriverai-runner.tgz
187
- ```
1
+ # runner
@@ -170,7 +170,6 @@ class AblyService extends EventEmitter {
170
170
  callback(null, this._ablyToken);
171
171
  },
172
172
  clientId: this._clientId,
173
- echoMessages: false, // don't receive our own published messages
174
173
  disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
175
174
  suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
176
175
  logHandler: (msg) => {
@@ -231,7 +230,7 @@ class AblyService extends EventEmitter {
231
230
  level: 'info',
232
231
  message,
233
232
  timestamp: Date.now(),
234
- }).catch(() => { }); // best-effort
233
+ }).catch(() => {}); // best-effort
235
234
  });
236
235
  this._automation.on('warn', (message) => {
237
236
  if (!this._debugMode) return;
@@ -240,7 +239,7 @@ class AblyService extends EventEmitter {
240
239
  level: 'warn',
241
240
  message,
242
241
  timestamp: Date.now(),
243
- }).catch(() => { }); // best-effort
242
+ }).catch(() => {}); // best-effort
244
243
  });
245
244
  this._automation.on('error', (message) => {
246
245
  if (!this._debugMode) return;
@@ -249,24 +248,21 @@ class AblyService extends EventEmitter {
249
248
  level: 'error',
250
249
  message: typeof message === 'string' ? message : message.message || String(message),
251
250
  timestamp: Date.now(),
252
- }).catch(() => { }); // best-effort
251
+ }).catch(() => {}); // best-effort
253
252
  });
254
253
 
255
- // Forward exec streaming chunks to SDK with rate limiting.
256
- // Exec output can produce many chunks rapidly (e.g. verbose commands);
257
- // throttle to avoid hitting Ably's 50 msg/sec per-connection limit.
258
- this._execOutputLastTime = 0;
259
- this._execOutputMinIntervalMs = 50; // 20 msg/sec max for exec.output
260
- this._execOutputQueue = []; // queued chunks waiting to send
261
- this._execOutputDraining = false;
262
-
254
+ // Forward exec streaming chunks to SDK
263
255
  this._automation.on('exec.output', ({ requestId, chunk }) => {
264
- this._execOutputQueue.push({ requestId, chunk });
265
- this._drainExecOutputQueue();
256
+ this._sendResponse({
257
+ type: 'exec.output',
258
+ requestId,
259
+ chunk,
260
+ timestamp: Date.now(),
261
+ }).catch(() => {}); // best-effort, don't block exec
266
262
  });
267
263
 
268
- // Subscribe to commands — save subscription ref for historyBeforeSubscribe()
269
- this._onCommandMsg = async (msg) => {
264
+ // Subscribe to commands
265
+ this._sessionChannel.subscribe('command', async (msg) => {
270
266
  const message = msg.data;
271
267
  if (!message) return;
272
268
 
@@ -297,7 +293,7 @@ class AblyService extends EventEmitter {
297
293
 
298
294
  // Screenshots are now handled by automation.js (returns { s3Key })
299
295
  // No need to check type here - just pass through the result
300
-
296
+
301
297
  await this._sendResponse({
302
298
  requestId,
303
299
  type: `${type}.reply`,
@@ -328,8 +324,7 @@ class AblyService extends EventEmitter {
328
324
  } else {
329
325
  await executeCommand();
330
326
  }
331
- };
332
- this._commandSubscription = await this._sessionChannel.subscribe('command', this._onCommandMsg);
327
+ });
333
328
 
334
329
  // ─── Ably connection state monitoring → Sentry ─────────────────────────
335
330
  this._ably.connection.on((stateChange) => {
@@ -412,27 +407,11 @@ class AblyService extends EventEmitter {
412
407
  Sentry.captureException(err);
413
408
  });
414
409
  }
415
-
416
- // Detect discontinuity: channel re-attached but message continuity was lost.
417
- // Use historyBeforeSubscribe() on each subscription to recover missed messages.
418
- if (current === 'attached' && stateChange.resumed === false && previous) {
419
- this.emit('log', `Ably channel [session]: DISCONTINUITY (resumed=false)${reasonMsg ? ' — ' + reasonMsg : ''}`);
420
-
421
- Sentry.withScope((scope) => {
422
- scope.setTag('ably.client', 'runner');
423
- scope.setTag('ably.channel', sessionCh.name);
424
- scope.setTag('ably.issue', 'discontinuity');
425
- scope.setFingerprint(['ably-channel-discontinuity', 'runner']);
426
- Sentry.captureMessage('Ably channel discontinuity (runner)', 'warning');
427
- });
428
-
429
- this._recoverFromDiscontinuity();
430
- }
431
410
  });
432
411
  }
433
412
 
434
- // Subscribe to control messages — save subscription ref for historyBeforeSubscribe()
435
- this._onControlMsg = async (msg) => {
413
+ // Subscribe to control messages
414
+ this._sessionChannel.subscribe('control', async (msg) => {
436
415
  const message = msg.data;
437
416
  if (!message) return;
438
417
 
@@ -451,8 +430,7 @@ class AblyService extends EventEmitter {
451
430
  this._debugMode = !!message.enabled;
452
431
  this.emit('log', `Debug mode ${this._debugMode ? 'enabled' : 'disabled'}`);
453
432
  }
454
- };
455
- this._controlSubscription = await this._sessionChannel.subscribe('control', this._onControlMsg);
433
+ });
456
434
 
457
435
  this.emit('log', 'Listening for commands on Ably');
458
436
 
@@ -475,90 +453,6 @@ class AblyService extends EventEmitter {
475
453
  this.emit('log', 'Published runner.ready signal');
476
454
  }
477
455
 
478
- /**
479
- * Drain the exec.output queue, respecting the rate limit interval.
480
- * Coalesces queued chunks per-requestId into single messages to reduce
481
- * message count when output arrives faster than we can send.
482
- */
483
- async _drainExecOutputQueue() {
484
- if (this._execOutputDraining) return; // already draining
485
- this._execOutputDraining = true;
486
-
487
- try {
488
- while (this._execOutputQueue.length > 0) {
489
- // Rate limit: wait if needed
490
- const now = Date.now();
491
- const elapsed = now - this._execOutputLastTime;
492
- if (elapsed < this._execOutputMinIntervalMs) {
493
- await new Promise((resolve) => setTimeout(resolve, this._execOutputMinIntervalMs - elapsed));
494
- }
495
-
496
- // Coalesce all queued chunks for the same requestId
497
- const batch = {};
498
- while (this._execOutputQueue.length > 0) {
499
- const { requestId, chunk } = this._execOutputQueue.shift();
500
- if (!batch[requestId]) batch[requestId] = '';
501
- batch[requestId] += chunk;
502
- }
503
-
504
- this._execOutputLastTime = Date.now();
505
-
506
- // Send one message per requestId
507
- for (const [requestId, chunk] of Object.entries(batch)) {
508
- this._sendResponse({
509
- type: 'exec.output',
510
- requestId,
511
- chunk,
512
- timestamp: Date.now(),
513
- }).catch(() => { }); // best-effort
514
- }
515
- }
516
- } finally {
517
- this._execOutputDraining = false;
518
- }
519
- }
520
-
521
- /**
522
- * Recover missed messages after a channel discontinuity.
523
- * Uses historyBeforeSubscribe() on each subscription, which guarantees
524
- * no gap between historical and live messages. Each recovered message
525
- * is dispatched through the same handler that processes live messages
526
- * so that missed commands are actually executed.
527
- */
528
- async _recoverFromDiscontinuity() {
529
- const subs = [
530
- { name: 'command', sub: this._commandSubscription, handler: this._onCommandMsg },
531
- { name: 'control', sub: this._controlSubscription, handler: this._onControlMsg },
532
- ];
533
- for (const { name, sub, handler } of subs) {
534
- if (!sub) continue;
535
- try {
536
- this.emit('log', `Discontinuity recovery: fetching historyBeforeSubscribe for ${name}...`);
537
- let page = await sub.historyBeforeSubscribe({ limit: 100 });
538
- let recovered = 0;
539
- while (page) {
540
- for (const item of page.items) {
541
- recovered++;
542
- try {
543
- if (handler) {
544
- this.emit('log', `Replaying recovered ${name} message (requestId=${item.data && item.data.requestId || 'none'})`);
545
- await handler(item);
546
- }
547
- } catch (replayErr) {
548
- this.emit('log', `Error replaying recovered ${name} message: ${replayErr.message}`);
549
- Sentry.captureException(replayErr);
550
- }
551
- }
552
- page = page.hasNext() ? await page.next() : null;
553
- }
554
- this.emit('log', `Discontinuity recovery: replayed ${recovered} ${name} message(s) from gap`);
555
- } catch (err) {
556
- this.emit('log', `Discontinuity recovery failed for ${name}: ${err.message}`);
557
- Sentry.captureException(err);
558
- }
559
- }
560
- }
561
-
562
456
  /**
563
457
  * Send a response on the session channel.
564
458
  */
@@ -624,12 +518,12 @@ class AblyService extends EventEmitter {
624
518
 
625
519
  try {
626
520
  if (this._sessionChannel) this._sessionChannel.detach();
627
- } catch { }
521
+ } catch {}
628
522
 
629
523
  if (this._ably) {
630
524
  try {
631
525
  this._ably.close();
632
- } catch { }
526
+ } catch {}
633
527
  this._ably = null;
634
528
  }
635
529
 
package/lib/automation.js CHANGED
@@ -525,14 +525,11 @@ class Automation extends EventEmitter {
525
525
  const timeout = Math.ceil((data.timeout || 300000) / 1000); // ms to seconds
526
526
  const requestId = data.requestId;
527
527
 
528
- // Buffer stdout chunks to ~32KB before emitting over Ably.
528
+ // Buffer stdout chunks to ~16KB before emitting over Ably.
529
529
  // This reduces message count while keeping each message well under
530
- // Ably's 64KB limit. 32KB leaves headroom for the JSON envelope +
531
- // string escaping while halving the message count vs the previous
532
- // 16KB size, helping avoid Ably's per-channel rate limit on verbose
533
- // commands. The SDK accumulates these chunks and reconstructs the
534
- // full stdout — the final response only carries returncode + stderr.
535
- const CHUNK_FLUSH_SIZE = 32 * 1024; // 32KB
530
+ // Ably's 64KB limit. The SDK accumulates these chunks and reconstructs
531
+ // the full stdout the final response only carries returncode + stderr.
532
+ const CHUNK_FLUSH_SIZE = 16 * 1024; // 16KB
536
533
  let chunkBuffer = '';
537
534
  const flushChunkBuffer = () => {
538
535
  if (chunkBuffer.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/runner",
3
- "version": "7.8.0-canary.14",
3
+ "version": "7.8.0-test.39",
4
4
  "description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
5
5
  "main": "index.js",
6
6
  "bin": {