@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 +1 -187
- package/lib/ably-service.js +19 -125
- package/lib/automation.js +4 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,187 +1 @@
|
|
|
1
|
-
#
|
|
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
|
package/lib/ably-service.js
CHANGED
|
@@ -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(() => {
|
|
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(() => {
|
|
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(() => {
|
|
251
|
+
}).catch(() => {}); // best-effort
|
|
253
252
|
});
|
|
254
253
|
|
|
255
|
-
// Forward exec streaming chunks to SDK
|
|
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.
|
|
265
|
-
|
|
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
|
|
269
|
-
this.
|
|
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
|
|
435
|
-
this.
|
|
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 ~
|
|
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.
|
|
531
|
-
//
|
|
532
|
-
|
|
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) {
|