@testdriverai/runner 7.8.0-canary.10
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/45-allow-colord.pkla +6 -0
- package/README.md +1 -0
- package/Xauthority +0 -0
- package/focusWindow.ps1 +123 -0
- package/getActiveWindow.ps1 +70 -0
- package/index.js +556 -0
- package/lib/ably-service.js +537 -0
- package/lib/automation-bridge.js +85 -0
- package/lib/automation.js +786 -0
- package/lib/automation.js.bak +882 -0
- package/lib/pyautogui-local.js +229 -0
- package/network.ps1 +18 -0
- package/package.json +43 -0
- package/sandbox-agent.js +266 -0
- package/scripts-desktop/control_window.sh +59 -0
- package/scripts-desktop/launch_chrome.sh +3 -0
- package/scripts-desktop/launch_chrome_for_testing.sh +9 -0
- package/scripts-desktop/start-desktop.sh +161 -0
- package/wallpaper.png +0 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pyautogui-local.js — PyAutoGUI WebSocket bridge (cross-platform)
|
|
3
|
+
*
|
|
4
|
+
* Spawns pyautogui-cli.py (a WebSocket server on port 8765) as a child
|
|
5
|
+
* process, then connects to it as a WebSocket client. All input automation
|
|
6
|
+
* is handled by PyAutoGUI — no nutjs dependency needed.
|
|
7
|
+
*
|
|
8
|
+
* Same interface as the old NutJSBridge:
|
|
9
|
+
* - extends EventEmitter (emits 'log', 'error')
|
|
10
|
+
* - start() — spawn pyautogui-cli.py + connect WS
|
|
11
|
+
* - stop() — close WS + kill process
|
|
12
|
+
* - sendCommand(command, data) → Promise<result>
|
|
13
|
+
*
|
|
14
|
+
* Protocol (over WebSocket JSON):
|
|
15
|
+
* Request: { "command": "click", "data": { "x": 100, "y": 200 } }
|
|
16
|
+
* Response: { "result": true, "originalData": { ... } }
|
|
17
|
+
* Error: { "error": "message" }
|
|
18
|
+
*/
|
|
19
|
+
const { EventEmitter } = require('events');
|
|
20
|
+
const { spawn } = require('child_process');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const WebSocket = require('ws');
|
|
24
|
+
|
|
25
|
+
const WS_PORT = 8765;
|
|
26
|
+
const WS_URL = `ws://127.0.0.1:${WS_PORT}`;
|
|
27
|
+
const MAX_CONNECT_ATTEMPTS = 30; // 30 × 500 ms = 15 s max wait
|
|
28
|
+
const CONNECT_RETRY_DELAY_MS = 500;
|
|
29
|
+
|
|
30
|
+
class PyAutoGUIBridge extends EventEmitter {
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
super();
|
|
33
|
+
this._process = null;
|
|
34
|
+
this._ws = null;
|
|
35
|
+
this._requestId = 0;
|
|
36
|
+
this._pending = new Map(); // id → { resolve, reject, timer }
|
|
37
|
+
this._timeout = options.commandTimeout || 30000; // 30 s default
|
|
38
|
+
this.started = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── start: spawn python server, wait for WS, connect ──────────────
|
|
42
|
+
|
|
43
|
+
async start() {
|
|
44
|
+
// Find pyautogui-cli.py
|
|
45
|
+
const scriptPaths = [
|
|
46
|
+
path.join(__dirname, '..', 'pyautogui-cli.py'),
|
|
47
|
+
path.join(__dirname, '..', 'scripts', 'pyautogui-cli.py'),
|
|
48
|
+
'pyautogui-cli.py',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
let scriptPath = null;
|
|
52
|
+
for (const p of scriptPaths) {
|
|
53
|
+
if (fs.existsSync(p)) {
|
|
54
|
+
scriptPath = p;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!scriptPath) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'pyautogui-cli.py not found. Place it in the runner directory.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.emit('log', `Starting PyAutoGUI server: ${scriptPath}`);
|
|
66
|
+
|
|
67
|
+
// Determine python command — "python3" on Linux/macOS, "python" on Windows
|
|
68
|
+
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
|
|
69
|
+
|
|
70
|
+
this._process = spawn(pythonCmd, [scriptPath], {
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
env: { ...process.env },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Forward stderr as log events
|
|
76
|
+
this._process.stderr.on('data', (data) => {
|
|
77
|
+
const msg = data.toString().trim();
|
|
78
|
+
if (msg) this.emit('log', `[pyautogui] ${msg}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this._process.stdout.on('data', (data) => {
|
|
82
|
+
const msg = data.toString().trim();
|
|
83
|
+
if (msg) this.emit('log', `[pyautogui] ${msg}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this._process.on('error', (err) => {
|
|
87
|
+
this.emit('error', err);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this._process.on('exit', (code) => {
|
|
91
|
+
this.emit('log', `PyAutoGUI server exited (code=${code})`);
|
|
92
|
+
this.started = false;
|
|
93
|
+
this._rejectAll(`PyAutoGUI process exited (code=${code})`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Wait for WS server to become reachable
|
|
97
|
+
await this._waitForServer();
|
|
98
|
+
|
|
99
|
+
// Connect WebSocket
|
|
100
|
+
await this._connect();
|
|
101
|
+
|
|
102
|
+
this.started = true;
|
|
103
|
+
this.emit('log', 'PyAutoGUI bridge ready');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── sendCommand ────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async sendCommand(command, data) {
|
|
109
|
+
if (!this.started || !this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
|
110
|
+
throw new Error('PyAutoGUI WebSocket not connected');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const id = ++this._requestId;
|
|
114
|
+
// Use per-command timeout if provided (e.g. exec with custom timeout), otherwise default
|
|
115
|
+
const timeoutMs = (data && data.timeout && data.timeout > 0)
|
|
116
|
+
? data.timeout + 5000 // Add 5s buffer over the command's own timeout
|
|
117
|
+
: this._timeout;
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
this._pending.delete(id);
|
|
122
|
+
reject(new Error(`PyAutoGUI command timed out: ${command} (${timeoutMs}ms)`));
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
|
|
125
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
126
|
+
|
|
127
|
+
const payload = JSON.stringify({ command, data, _id: id });
|
|
128
|
+
this._ws.send(payload, (err) => {
|
|
129
|
+
if (err) {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
this._pending.delete(id);
|
|
132
|
+
reject(new Error(`WS send failed: ${err.message}`));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── stop ───────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
stop() {
|
|
141
|
+
this.started = false;
|
|
142
|
+
|
|
143
|
+
if (this._ws) {
|
|
144
|
+
try { this._ws.close(); } catch {}
|
|
145
|
+
this._ws = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (this._process) {
|
|
149
|
+
try { this._process.kill(); } catch {}
|
|
150
|
+
this._process = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._rejectAll('PyAutoGUI stopped');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── internal helpers ───────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
_rejectAll(reason) {
|
|
159
|
+
for (const [, pending] of this._pending) {
|
|
160
|
+
clearTimeout(pending.timer);
|
|
161
|
+
pending.reject(new Error(reason));
|
|
162
|
+
}
|
|
163
|
+
this._pending.clear();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Poll until the WS server is accepting connections */
|
|
167
|
+
async _waitForServer() {
|
|
168
|
+
for (let i = 0; i < MAX_CONNECT_ATTEMPTS; i++) {
|
|
169
|
+
try {
|
|
170
|
+
await new Promise((resolve, reject) => {
|
|
171
|
+
const ws = new WebSocket(WS_URL);
|
|
172
|
+
ws.once('open', () => { ws.close(); resolve(); });
|
|
173
|
+
ws.once('error', reject);
|
|
174
|
+
});
|
|
175
|
+
return; // server is up
|
|
176
|
+
} catch {
|
|
177
|
+
await new Promise(r => setTimeout(r, CONNECT_RETRY_DELAY_MS));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`PyAutoGUI WS server did not start within ${MAX_CONNECT_ATTEMPTS * CONNECT_RETRY_DELAY_MS / 1000}s`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Open persistent WebSocket connection */
|
|
184
|
+
_connect() {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
this._ws = new WebSocket(WS_URL);
|
|
187
|
+
|
|
188
|
+
this._ws.on('open', () => {
|
|
189
|
+
this.emit('log', `Connected to PyAutoGUI WS at ${WS_URL}`);
|
|
190
|
+
resolve();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
this._ws.on('message', (raw) => {
|
|
194
|
+
try {
|
|
195
|
+
const msg = JSON.parse(raw.toString());
|
|
196
|
+
|
|
197
|
+
// The pyautogui-cli.py server doesn't echo _id back, so we
|
|
198
|
+
// resolve the oldest pending request (FIFO). Commands are serial
|
|
199
|
+
// from the runner's perspective.
|
|
200
|
+
if (this._pending.size > 0) {
|
|
201
|
+
const [id, pending] = this._pending.entries().next().value;
|
|
202
|
+
clearTimeout(pending.timer);
|
|
203
|
+
this._pending.delete(id);
|
|
204
|
+
|
|
205
|
+
if (msg.error) {
|
|
206
|
+
pending.reject(new Error(msg.error));
|
|
207
|
+
} else {
|
|
208
|
+
pending.resolve(msg.result);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
this.emit('log', `Non-JSON WS message: ${raw}`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this._ws.on('close', () => {
|
|
217
|
+
this.emit('log', 'PyAutoGUI WS disconnected');
|
|
218
|
+
this._rejectAll('WebSocket closed');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
this._ws.on('error', (err) => {
|
|
222
|
+
this.emit('error', err);
|
|
223
|
+
if (!this.started) reject(err);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { PyAutoGUIBridge };
|
package/network.ps1
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Get cumulative total bytes sent and received for each network interface
|
|
2
|
+
$networkInterfaces = Get-WmiObject -Query "SELECT Name, BytesReceivedPerSec, BytesSentPerSec FROM Win32_PerfRawData_Tcpip_NetworkInterface"
|
|
3
|
+
|
|
4
|
+
# Initialize counters
|
|
5
|
+
$totalBytesSent = 0
|
|
6
|
+
$totalBytesReceived = 0
|
|
7
|
+
|
|
8
|
+
# Sum up the cumulative bytes sent and received for all interfaces
|
|
9
|
+
foreach ($interface in $networkInterfaces) {
|
|
10
|
+
$totalBytesSent += $interface.BytesSentPerSec
|
|
11
|
+
$totalBytesReceived += $interface.BytesReceivedPerSec
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# Calculate the overall total bytes transferred
|
|
15
|
+
$totalNetworkBytes = $totalBytesSent + $totalBytesReceived
|
|
16
|
+
|
|
17
|
+
# Output results in JSON format for parsing in Node.js
|
|
18
|
+
Write-Output "{`"totalBytesSent`": $totalBytesSent, `"totalBytesReceived`": $totalBytesReceived, `"totalNetworkBytes`": $totalNetworkBytes}"
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@testdriverai/runner",
|
|
3
|
+
"version": "7.8.0-canary.10",
|
|
4
|
+
"description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"testdriver-runner": "./index.js",
|
|
8
|
+
"testdriver-sandbox-agent": "./sandbox-agent.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"sandbox-agent.js",
|
|
13
|
+
"lib/",
|
|
14
|
+
"scripts-desktop/",
|
|
15
|
+
"entrypoint.sh",
|
|
16
|
+
"45-allow-colord.pkla",
|
|
17
|
+
"Xauthority",
|
|
18
|
+
"wallpaper.png",
|
|
19
|
+
"focusWindow.ps1",
|
|
20
|
+
"getActiveWindow.ps1",
|
|
21
|
+
"network.ps1"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node index.js",
|
|
25
|
+
"test": "node test/local-test.mjs",
|
|
26
|
+
"test:packer": "cd ../sdk && npx vitest run --config vitest.runner.config.mjs runner/packer/test/packer-ami.test.mjs",
|
|
27
|
+
"test:packer:skip-build": "cd ../sdk && SKIP_PACKER_BUILD=true npx vitest run --config vitest.runner.config.mjs runner/packer/test/packer-ami.test.mjs",
|
|
28
|
+
"test:auto-upgrade": "cd ../sdk && npx vitest run --config vitest.runner.config.mjs runner/packer/test/auto-upgrade.test.mjs"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@sentry/node": "^10.37.0",
|
|
32
|
+
"ably": "^2.6.0",
|
|
33
|
+
"dashcam": "beta",
|
|
34
|
+
"dashcam-chrome": "*",
|
|
35
|
+
"dotenv": "^16.0.0",
|
|
36
|
+
"pngjs": "^7.0.0",
|
|
37
|
+
"sharp": "^0.33.0",
|
|
38
|
+
"uuid": "^9.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"e2b": "^2.12.1"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/sandbox-agent.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sandbox-agent.js — Ably-based sandbox automation agent
|
|
4
|
+
*
|
|
5
|
+
* Runs on the sandbox machine (Windows EC2 instance or any remote host).
|
|
6
|
+
* Replaces pyautogui-cli.py as the entry point for remote-controlled
|
|
7
|
+
* desktop automation.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Reads Ably credentials from /tmp/testdriver-agent.json (Linux)
|
|
11
|
+
* or C:\Windows\Temp\testdriver-agent.json (Windows)
|
|
12
|
+
* 2. Initializes the Automation module (@nut-tree/nut-js)
|
|
13
|
+
* 3. Connects to Ably and subscribes to command channel
|
|
14
|
+
* 4. Executes commands and publishes responses
|
|
15
|
+
*
|
|
16
|
+
* The API writes the credentials file during sandbox provisioning
|
|
17
|
+
* (via SSM SendCommand or userdata script).
|
|
18
|
+
*
|
|
19
|
+
* Config file format:
|
|
20
|
+
* {
|
|
21
|
+
* "sandboxId": "abc123",
|
|
22
|
+
* "ably": {
|
|
23
|
+
* "token": "...",
|
|
24
|
+
* "channel": "testdriver:env:team:sandbox"
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Environment variables (override config file):
|
|
29
|
+
* SANDBOX_ID — sandbox ID
|
|
30
|
+
* ABLY_TOKEN — Ably auth token (JSON string)
|
|
31
|
+
* ABLY_CHANNEL — Ably session channel name
|
|
32
|
+
* CONFIG_PATH — path to config file (overrides default)
|
|
33
|
+
*/
|
|
34
|
+
require('dotenv').config();
|
|
35
|
+
const Sentry = require('@sentry/node');
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const os = require('os');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
|
|
40
|
+
const { Automation } = require('./lib/automation');
|
|
41
|
+
const { AblyService } = require('./lib/ably-service');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the local runner version from package.json.
|
|
45
|
+
*/
|
|
46
|
+
function getLocalVersion() {
|
|
47
|
+
try {
|
|
48
|
+
if (process.env.RUNNER_VERSION) return process.env.RUNNER_VERSION;
|
|
49
|
+
const pkg = require('./package.json');
|
|
50
|
+
return pkg.version;
|
|
51
|
+
} catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
55
|
+
|
|
56
|
+
// ─── Load config ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function loadConfigFromFile() {
|
|
59
|
+
const configPath = process.env.CONFIG_PATH || (
|
|
60
|
+
IS_WINDOWS
|
|
61
|
+
? 'C:\\Windows\\Temp\\testdriver-agent.json'
|
|
62
|
+
: '/tmp/testdriver-agent.json'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(configPath)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let raw = fs.readFileSync(configPath, 'utf-8');
|
|
70
|
+
// Strip UTF-8 BOM if present (PowerShell 5.1 Set-Content -Encoding UTF8 adds it)
|
|
71
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
72
|
+
return JSON.parse(raw);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wait for the config file to appear on disk.
|
|
77
|
+
* The API writes it via SSM after the instance is running, so the agent
|
|
78
|
+
* (started at logon by the scheduled task) may boot before it exists.
|
|
79
|
+
*/
|
|
80
|
+
async function waitForConfig(timeoutMs) {
|
|
81
|
+
if (!timeoutMs) timeoutMs = 300000; // 5 minutes
|
|
82
|
+
const pollInterval = 2000; // 2s
|
|
83
|
+
const configPath = process.env.CONFIG_PATH || (
|
|
84
|
+
IS_WINDOWS
|
|
85
|
+
? 'C:\\Windows\\Temp\\testdriver-agent.json'
|
|
86
|
+
: '/tmp/testdriver-agent.json'
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
log('Waiting for config file: ' + configPath);
|
|
91
|
+
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
var cfg = loadConfigFromFile();
|
|
94
|
+
if (cfg && cfg.ably && cfg.ably.token) {
|
|
95
|
+
log('Config file loaded');
|
|
96
|
+
return cfg;
|
|
97
|
+
}
|
|
98
|
+
await new Promise(function (resolve) { setTimeout(resolve, pollInterval); });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Config file not found after ' + (timeoutMs / 1000) + 's: ' + configPath + '\n' +
|
|
103
|
+
'The API must provision this file during sandbox creation.\n' +
|
|
104
|
+
'Or set ABLY_TOKEN and ABLY_CHANNEL environment variables.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function loadConfig() {
|
|
109
|
+
// Try environment variables first
|
|
110
|
+
if (process.env.ABLY_TOKEN && process.env.ABLY_CHANNEL) {
|
|
111
|
+
return {
|
|
112
|
+
sandboxId: process.env.SANDBOX_ID || 'agent-' + Date.now(),
|
|
113
|
+
ably: {
|
|
114
|
+
token: JSON.parse(process.env.ABLY_TOKEN),
|
|
115
|
+
channel: process.env.ABLY_CHANNEL,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Synchronous load (for backward compat when called from tests / env-var mode)
|
|
121
|
+
var cfg = loadConfigFromFile();
|
|
122
|
+
if (cfg) return cfg;
|
|
123
|
+
|
|
124
|
+
throw new Error(
|
|
125
|
+
'Config file not found.\n' +
|
|
126
|
+
'The API must provision this file during sandbox creation.\n' +
|
|
127
|
+
'Or set ABLY_TOKEN and ABLY_CHANNEL environment variables.'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Write connection info (for compatibility with pyautogui-cli.py) ─────────
|
|
132
|
+
|
|
133
|
+
function writeConnectionInfo(sandboxId) {
|
|
134
|
+
const ip = getLocalIP();
|
|
135
|
+
const info = {
|
|
136
|
+
agent: 'testdriver-sandbox-agent',
|
|
137
|
+
sandboxId,
|
|
138
|
+
transport: 'ably',
|
|
139
|
+
ip,
|
|
140
|
+
pid: process.pid,
|
|
141
|
+
startedAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const outputPath = IS_WINDOWS
|
|
145
|
+
? 'C:\\Windows\\Temp\\testdriver-agent-status.json'
|
|
146
|
+
: '/tmp/testdriver-agent-status.json';
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
fs.writeFileSync(outputPath, JSON.stringify(info, null, 2));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.warn(`[agent] Failed to write status file: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getLocalIP() {
|
|
156
|
+
const interfaces = os.networkInterfaces();
|
|
157
|
+
for (const name of Object.keys(interfaces)) {
|
|
158
|
+
for (const iface of interfaces[name]) {
|
|
159
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
160
|
+
return iface.address;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return '127.0.0.1';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
const logFile = IS_WINDOWS
|
|
170
|
+
? 'C:\\Windows\\Temp\\testdriver-agent.log'
|
|
171
|
+
: '/tmp/testdriver-agent.log';
|
|
172
|
+
|
|
173
|
+
function log(msg) {
|
|
174
|
+
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
175
|
+
console.log(line);
|
|
176
|
+
try {
|
|
177
|
+
fs.appendFileSync(logFile, line + '\n');
|
|
178
|
+
} catch { }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function main() {
|
|
184
|
+
log('TestDriver Sandbox Agent starting...');
|
|
185
|
+
log(`Runner version: ${getLocalVersion() || 'unknown'}`);
|
|
186
|
+
|
|
187
|
+
// Wait for config file — the API writes it via SSM after the instance boots.
|
|
188
|
+
// The SSM command also updates the runner (via S3 for non-stable envs, or npm
|
|
189
|
+
// for stable) BEFORE writing the config, so by this point we're up to date.
|
|
190
|
+
const config = await waitForConfig(300000);
|
|
191
|
+
|
|
192
|
+
// Initialize Sentry for distributed tracing
|
|
193
|
+
const sentryDsn = process.env.SENTRY_DSN || config.sentryDsn || 'https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760';
|
|
194
|
+
if (sentryDsn) {
|
|
195
|
+
const { version } = require('./package.json');
|
|
196
|
+
Sentry.init({
|
|
197
|
+
dsn: sentryDsn,
|
|
198
|
+
// Use tracesSampler to inherit parent sampling decisions
|
|
199
|
+
// This ensures SDK → API → Runner traces stay connected
|
|
200
|
+
tracesSampler: (samplingContext) => {
|
|
201
|
+
// Inherit parent sampling decision for distributed tracing continuity
|
|
202
|
+
if (samplingContext.parentSampled !== undefined) {
|
|
203
|
+
return samplingContext.parentSampled;
|
|
204
|
+
}
|
|
205
|
+
// Sample all runner-initiated traces (shouldn't happen often)
|
|
206
|
+
return 1.0;
|
|
207
|
+
},
|
|
208
|
+
environment: process.env.NODE_ENV || 'production',
|
|
209
|
+
release: version,
|
|
210
|
+
serverName: os.hostname(),
|
|
211
|
+
});
|
|
212
|
+
log('Sentry initialized');
|
|
213
|
+
}
|
|
214
|
+
log(`Sandbox ID: ${config.sandboxId}`);
|
|
215
|
+
log(`Transport: Ably`);
|
|
216
|
+
|
|
217
|
+
// Initialize automation
|
|
218
|
+
const automation = new Automation({
|
|
219
|
+
sandboxId: config.sandboxId,
|
|
220
|
+
apiRoot: config.apiRoot,
|
|
221
|
+
apiKey: config.apiKey,
|
|
222
|
+
});
|
|
223
|
+
log('Automation module initialized');
|
|
224
|
+
|
|
225
|
+
// Write connection info
|
|
226
|
+
writeConnectionInfo(config.sandboxId);
|
|
227
|
+
|
|
228
|
+
// Connect to Ably
|
|
229
|
+
const ablyService = new AblyService({
|
|
230
|
+
automation,
|
|
231
|
+
ablyToken: config.ably.token,
|
|
232
|
+
channel: config.ably.channel || (config.ably.channels && config.ably.channels.commands),
|
|
233
|
+
sandboxId: config.sandboxId,
|
|
234
|
+
apiRoot: config.apiRoot,
|
|
235
|
+
apiKey: config.apiKey,
|
|
236
|
+
updateInfo: null, // sandbox-agent doesn't do self-update checks
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
ablyService.on('log', (msg) => log(`[ably] ${msg}`));
|
|
240
|
+
ablyService.on('error', (err) => log(`[ably] ERROR: ${err.message}`));
|
|
241
|
+
|
|
242
|
+
await ablyService.connect();
|
|
243
|
+
log('Agent ready — listening for commands via Ably');
|
|
244
|
+
|
|
245
|
+
// ─── Graceful shutdown ────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const shutdown = async () => {
|
|
248
|
+
log('Shutting down...');
|
|
249
|
+
await ablyService.close();
|
|
250
|
+
automation.cleanup();
|
|
251
|
+
await Sentry.close(2000);
|
|
252
|
+
process.exit(0);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
process.on('SIGINT', shutdown);
|
|
256
|
+
process.on('SIGTERM', shutdown);
|
|
257
|
+
|
|
258
|
+
// Keep process alive
|
|
259
|
+
setInterval(() => { }, 60000);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
main().catch((err) => {
|
|
263
|
+
log(`Fatal error: ${err.message}`);
|
|
264
|
+
console.error(err);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Check and print each argument
|
|
4
|
+
echo "Number of arguments: $#"
|
|
5
|
+
for i in "${!@}"; do
|
|
6
|
+
echo "Arg[$i]: ${!i}"
|
|
7
|
+
done
|
|
8
|
+
|
|
9
|
+
# Existing logic
|
|
10
|
+
if [ "$#" -lt 2 ]; then
|
|
11
|
+
echo "Error: Not enough arguments supplied."
|
|
12
|
+
echo "Usage: ./control_window.sh <WindowTitle> <Action>"
|
|
13
|
+
echo "Actions: Minimize, Restore, Focus"
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Assign arguments
|
|
18
|
+
WINDOW_TITLE="$1"
|
|
19
|
+
ACTION="$2"
|
|
20
|
+
|
|
21
|
+
# Debug output
|
|
22
|
+
echo "=== Debugging Arguments ==="
|
|
23
|
+
echo "WindowTitle: '$WINDOW_TITLE'"
|
|
24
|
+
echo "Action: '$ACTION'"
|
|
25
|
+
echo "==========================="
|
|
26
|
+
|
|
27
|
+
# Validate action
|
|
28
|
+
if [[ "$ACTION" != "Minimize" && "$ACTION" != "Restore" && "$ACTION" != "Focus" ]]; then
|
|
29
|
+
echo "Invalid action. Valid actions are: Minimize, Restore, Focus."
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Search for windows matching the title
|
|
34
|
+
WINDOW_IDS=$(wmctrl -lx | grep -i "$WINDOW_TITLE" | awk '{print $1}')
|
|
35
|
+
|
|
36
|
+
if [ -z "$WINDOW_IDS" ]; then
|
|
37
|
+
echo "No window found with title containing '$WINDOW_TITLE'"
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Perform action on each matching window
|
|
42
|
+
for WIN_ID in $WINDOW_IDS; do
|
|
43
|
+
WINDOW_NAME=$(xprop -id "$WIN_ID" WM_NAME | cut -d'"' -f2)
|
|
44
|
+
echo "Found window: '$WINDOW_NAME'"
|
|
45
|
+
|
|
46
|
+
case "$ACTION" in
|
|
47
|
+
Minimize)
|
|
48
|
+
xdotool windowminimize "$WIN_ID"
|
|
49
|
+
echo "Minimized '$WINDOW_NAME'"
|
|
50
|
+
;;
|
|
51
|
+
Restore|Focus)
|
|
52
|
+
# Raise and focus window (xdotool will automatically un-minimize if minimized)
|
|
53
|
+
xdotool windowmap "$WIN_ID"
|
|
54
|
+
xdotool windowactivate "$WIN_ID"
|
|
55
|
+
echo "Brought '$WINDOW_NAME' to the foreground."
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Resolve dashcam-chrome extension path from the runner package or global install
|
|
4
|
+
DASHCAM_CHROME_PATH=$(node -e "try{console.log(require.resolve('dashcam-chrome').replace(/\/[^/]*$/,''))}catch{}" 2>/dev/null)
|
|
5
|
+
if [ -z "$DASHCAM_CHROME_PATH" ]; then
|
|
6
|
+
DASHCAM_CHROME_PATH="/usr/local/lib/node_modules/dashcam-chrome"
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
jumpapp chrome-for-testing --remote-debugging-port=9222 --disable-fre --no-default-browser-check --no-first-run --load-extension="$DASHCAM_CHROME_PATH" $@
|