@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.
@@ -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
+ }
@@ -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,3 @@
1
+ #!/bin/bash
2
+
3
+ jumpapp google-chrome --disable-fre --no-default-browser-check --no-first-run $@
@@ -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" $@