testdriverai 7.3.12 → 7.3.13
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/.github/skills/testdriver:ai/SKILL.md +204 -0
- package/.github/skills/testdriver:assert/SKILL.md +284 -0
- package/.github/skills/testdriver:aws-setup/SKILL.md +515 -0
- package/.github/skills/testdriver:caching/SKILL.md +124 -0
- package/.github/skills/testdriver:captcha/SKILL.md +159 -0
- package/.github/skills/testdriver:ci-cd/SKILL.md +602 -0
- package/.github/skills/testdriver:click/SKILL.md +286 -0
- package/.github/skills/testdriver:client/SKILL.md +339 -0
- package/.github/skills/testdriver:cloud/SKILL.md +119 -0
- package/.github/skills/testdriver:customizing-devices/SKILL.md +153 -0
- package/.github/skills/testdriver:dashcam/SKILL.md +418 -0
- package/.github/skills/testdriver:debugging-with-screenshots/SKILL.md +271 -0
- package/.github/skills/testdriver:device-config/SKILL.md +317 -0
- package/.github/skills/testdriver:double-click/SKILL.md +102 -0
- package/.github/skills/testdriver:elements/SKILL.md +605 -0
- package/.github/skills/testdriver:enterprise/SKILL.md +114 -0
- package/.github/skills/testdriver:examples/SKILL.md +7 -0
- package/.github/skills/testdriver:exec/SKILL.md +345 -0
- package/.github/skills/testdriver:find/SKILL.md +721 -0
- package/.github/skills/testdriver:focus-application/SKILL.md +293 -0
- package/.github/skills/testdriver:generating-tests/SKILL.md +36 -0
- package/.github/skills/testdriver:hover/SKILL.md +278 -0
- package/.github/skills/testdriver:locating-elements/SKILL.md +71 -0
- package/.github/skills/testdriver:making-assertions/SKILL.md +32 -0
- package/.github/skills/testdriver:mcp-workflow/SKILL.md +410 -0
- package/.github/skills/testdriver:mouse-down/SKILL.md +161 -0
- package/.github/skills/testdriver:mouse-up/SKILL.md +164 -0
- package/.github/skills/testdriver:performing-actions/SKILL.md +51 -0
- package/.github/skills/testdriver:press-keys/SKILL.md +348 -0
- package/.github/skills/testdriver:quickstart/SKILL.md +161 -0
- package/.github/skills/testdriver:reusable-code/SKILL.md +240 -0
- package/.github/skills/testdriver:right-click/SKILL.md +123 -0
- package/.github/skills/testdriver:running-tests/SKILL.md +181 -0
- package/.github/skills/testdriver:screenshot/SKILL.md +167 -0
- package/.github/skills/testdriver:scroll/SKILL.md +299 -0
- package/.github/skills/testdriver:secrets/SKILL.md +115 -0
- package/.github/skills/testdriver:self-hosted/SKILL.md +65 -0
- package/.github/skills/testdriver:test-writer/SKILL.md +451 -0
- package/.github/skills/testdriver:testdriver/SKILL.md +523 -0
- package/.github/skills/testdriver:testdriver-mechanic/SKILL.md +165 -0
- package/.github/skills/testdriver:type/SKILL.md +357 -0
- package/.github/skills/testdriver:variables/SKILL.md +111 -0
- package/.github/skills/testdriver:waiting-for-elements/SKILL.md +66 -0
- package/.github/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
- package/.github/workflows/acceptance-windows-scheduled.yaml +6 -1
- package/.github/workflows/acceptance.yaml +0 -36
- package/.github/workflows/update-examples.yaml +53 -0
- package/CHANGELOG.md +4 -0
- package/agent/events.js +1 -0
- package/agent/index.js +8 -0
- package/agent/lib/commands.js +48 -29
- package/agent/lib/redraw.js +3 -1
- package/agent/lib/sandbox.js +166 -14
- package/agent/lib/sdk.js +142 -3
- package/agent/lib/system.js +4 -6
- package/ai/skills/testdriver:ai/SKILL.md +204 -0
- package/ai/skills/testdriver:assert/SKILL.md +315 -0
- package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
- package/ai/skills/testdriver:caching/SKILL.md +124 -0
- package/ai/skills/testdriver:captcha/SKILL.md +159 -0
- package/ai/skills/testdriver:ci-cd/SKILL.md +602 -0
- package/ai/skills/testdriver:click/SKILL.md +286 -0
- package/ai/skills/testdriver:client/SKILL.md +372 -0
- package/ai/skills/testdriver:cloud/SKILL.md +119 -0
- package/ai/skills/testdriver:customizing-devices/SKILL.md +153 -0
- package/ai/skills/testdriver:dashcam/SKILL.md +418 -0
- package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +401 -0
- package/ai/skills/testdriver:device-config/SKILL.md +317 -0
- package/ai/skills/testdriver:double-click/SKILL.md +102 -0
- package/ai/skills/testdriver:elements/SKILL.md +605 -0
- package/ai/skills/testdriver:enterprise/SKILL.md +114 -0
- package/ai/skills/testdriver:examples/SKILL.md +7 -0
- package/ai/skills/testdriver:exec/SKILL.md +345 -0
- package/ai/skills/testdriver:find/SKILL.md +745 -0
- package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
- package/ai/skills/testdriver:generating-tests/SKILL.md +36 -0
- package/ai/skills/testdriver:hover/SKILL.md +278 -0
- package/ai/skills/testdriver:locating-elements/SKILL.md +71 -0
- package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
- package/ai/skills/testdriver:mcp-workflow/SKILL.md +410 -0
- package/ai/skills/testdriver:mouse-down/SKILL.md +161 -0
- package/ai/skills/testdriver:mouse-up/SKILL.md +164 -0
- package/ai/skills/testdriver:ocr/SKILL.md +235 -0
- package/ai/skills/testdriver:performing-actions/SKILL.md +51 -0
- package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
- package/ai/skills/testdriver:quickstart/SKILL.md +146 -0
- package/ai/skills/testdriver:reusable-code/SKILL.md +240 -0
- package/ai/skills/testdriver:right-click/SKILL.md +123 -0
- package/ai/skills/testdriver:running-tests/SKILL.md +185 -0
- package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
- package/ai/skills/testdriver:scroll/SKILL.md +335 -0
- package/ai/skills/testdriver:secrets/SKILL.md +115 -0
- package/ai/skills/testdriver:self-hosted/SKILL.md +65 -0
- package/ai/skills/testdriver:test-writer/SKILL.md +451 -0
- package/ai/skills/testdriver:testdriver/SKILL.md +631 -0
- package/ai/skills/testdriver:testdriver-mechanic/SKILL.md +165 -0
- package/ai/skills/testdriver:type/SKILL.md +357 -0
- package/ai/skills/testdriver:variables/SKILL.md +111 -0
- package/ai/skills/testdriver:waiting-for-elements/SKILL.md +66 -0
- package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
- package/debugger/index.html +12 -2
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/find.mdx +1 -0
- package/examples/config.mjs +1 -1
- package/examples/findall-coffee-icons.test.mjs +42 -0
- package/examples/flake-diffthreshold-001.test.mjs +9 -0
- package/examples/flake-diffthreshold-01.test.mjs +9 -0
- package/examples/flake-diffthreshold-05.test.mjs +9 -0
- package/examples/{z_flake-noredraw-cache.test.mjs → flake-noredraw-cache.test.mjs} +2 -2
- package/examples/{z_flake-noredraw-nocache.test.mjs → flake-noredraw-nocache.test.mjs} +2 -2
- package/examples/{z_flake-redraw-cache.test.mjs → flake-redraw-cache.test.mjs} +2 -2
- package/examples/{z_flake-redraw-nocache.test.mjs → flake-redraw-nocache.test.mjs} +2 -2
- package/examples/flake-rocket-match.test.mjs +30 -0
- package/examples/{z_flake-shared.mjs → flake-shared.mjs} +2 -2
- package/examples/parse.test.mjs +19 -0
- package/examples/scroll-keyboard.test.mjs +1 -1
- package/interfaces/cli/lib/base.js +6 -0
- package/interfaces/logger.js +51 -13
- package/interfaces/vitest-plugin.mjs +137 -0
- package/lib/core/index.d.ts +22 -0
- package/lib/init-project.js +105 -6
- package/lib/vitest/hooks.mjs +2 -5
- package/lib/vitest/setup-disable-defender.mjs +52 -0
- package/package.json +2 -1
- package/sdk-log-formatter.js +90 -0
- package/sdk.d.ts +88 -51
- package/sdk.js +126 -18
- package/setup/aws/disable-defender.sh +42 -0
- package/vitest.config.mjs +1 -3
- package/examples/z_flake-diffthreshold-001.test.mjs +0 -9
- package/examples/z_flake-diffthreshold-01.test.mjs +0 -9
- package/examples/z_flake-diffthreshold-05.test.mjs +0 -9
- /package/{examples → manual}/captcha-api.test.mjs +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Update Example Docs
|
|
2
|
+
permissions:
|
|
3
|
+
contents: write
|
|
4
|
+
on:
|
|
5
|
+
schedule:
|
|
6
|
+
- cron: "0 0 * * *" # Daily at midnight UTC
|
|
7
|
+
workflow_dispatch: # Allow manual trigger
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
update-examples:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
18
|
+
|
|
19
|
+
- name: Setup Node.js
|
|
20
|
+
uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: "20"
|
|
23
|
+
|
|
24
|
+
- name: Configure Git
|
|
25
|
+
run: |
|
|
26
|
+
git config user.name "github-actions[bot]"
|
|
27
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
28
|
+
|
|
29
|
+
- name: Install dependencies
|
|
30
|
+
run: npm ci
|
|
31
|
+
|
|
32
|
+
- name: Run example tests
|
|
33
|
+
run: set -o pipefail && npx vitest run examples/*.test.mjs 2>&1 | tee test-output.log
|
|
34
|
+
env:
|
|
35
|
+
TD_API_KEY: ${{ secrets.TD_API_KEY }}
|
|
36
|
+
TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }}
|
|
37
|
+
TD_OS: linux
|
|
38
|
+
|
|
39
|
+
- name: Extract example URLs
|
|
40
|
+
if: success()
|
|
41
|
+
run: node docs/_scripts/extract-example-urls.js --file=test-output.log
|
|
42
|
+
|
|
43
|
+
- name: Generate example docs
|
|
44
|
+
if: success()
|
|
45
|
+
run: node docs/_scripts/generate-examples.js --skip-ai
|
|
46
|
+
env:
|
|
47
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
48
|
+
|
|
49
|
+
- name: Commit and push updated docs
|
|
50
|
+
if: success()
|
|
51
|
+
run: |
|
|
52
|
+
git add docs/
|
|
53
|
+
git diff --staged --quiet || (git commit -m "docs: update example documentation [skip ci]" && git push origin main)
|
package/CHANGELOG.md
CHANGED
package/agent/events.js
CHANGED
package/agent/index.js
CHANGED
|
@@ -1791,6 +1791,14 @@ ${regression}
|
|
|
1791
1791
|
ip: this.ip,
|
|
1792
1792
|
});
|
|
1793
1793
|
|
|
1794
|
+
// Store sandboxId (for self-hosted, use the IP as identifier) so messages include it
|
|
1795
|
+
// This enables the API to reconnect if the websocket connection is rerouted
|
|
1796
|
+
this.sandbox._lastConnectParams = {
|
|
1797
|
+
sandboxId: instance?.instance?.instanceId || instance?.instance?.sandboxId || this.ip,
|
|
1798
|
+
persist: true,
|
|
1799
|
+
keepAlive: this.keepAlive,
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1794
1802
|
// Mark instance socket as connected so console logs are forwarded
|
|
1795
1803
|
this.sandbox.instanceSocketConnected = true;
|
|
1796
1804
|
this.emitter.emit(events.sandbox.connected);
|
package/agent/lib/commands.js
CHANGED
|
@@ -51,24 +51,43 @@ class CommandError extends Error {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Normalize redraw options from new thresholds format or legacy format.
|
|
56
|
+
* New: { enabled: true, thresholds: { screen: 0.05, network: true } }
|
|
57
|
+
* Legacy: { enabled: true, diffThreshold: 0.1, screenRedraw: true, networkMonitor: true }
|
|
58
|
+
* @param {Object} opts - Raw redraw options object
|
|
59
|
+
* @returns {Object} Normalised { enabled, screenRedraw, networkMonitor }
|
|
60
|
+
*/
|
|
61
|
+
const normalizeRedrawOpts = (opts) => {
|
|
62
|
+
if (!opts || typeof opts !== 'object') return { enabled: !!opts };
|
|
63
|
+
const result = { enabled: opts.enabled !== false };
|
|
64
|
+
if (opts.thresholds && typeof opts.thresholds === 'object') {
|
|
65
|
+
result.screenRedraw = opts.thresholds.screen !== false;
|
|
66
|
+
result.networkMonitor = !!opts.thresholds.network;
|
|
67
|
+
} else {
|
|
68
|
+
result.screenRedraw = opts.screenRedraw !== undefined ? opts.screenRedraw : true;
|
|
69
|
+
result.networkMonitor = opts.networkMonitor !== undefined ? opts.networkMonitor : false;
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* Extract redraw options from command options
|
|
56
76
|
* @param {Object} options - Command options that may contain redraw settings
|
|
57
77
|
* @returns {Object} Redraw options object
|
|
58
78
|
*/
|
|
59
79
|
const extractRedrawOptions = (options = {}) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// Support nested redraw object: { redraw: { enabled: false, diffThreshold: 0.5 } }
|
|
80
|
+
// Support nested redraw object (new or legacy format)
|
|
63
81
|
if (options.redraw && typeof options.redraw === 'object') {
|
|
64
|
-
return options.redraw;
|
|
82
|
+
return normalizeRedrawOpts(options.redraw);
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
// Support flat options for convenience
|
|
85
|
+
// Support flat options for convenience (legacy)
|
|
86
|
+
const redrawOpts = {};
|
|
68
87
|
if ('redrawEnabled' in options) redrawOpts.enabled = options.redrawEnabled;
|
|
69
88
|
if ('redrawScreenRedraw' in options) redrawOpts.screenRedraw = options.redrawScreenRedraw;
|
|
70
89
|
if ('redrawNetworkMonitor' in options) redrawOpts.networkMonitor = options.redrawNetworkMonitor;
|
|
71
|
-
if ('redrawDiffThreshold' in options) redrawOpts.
|
|
90
|
+
if ('redrawDiffThreshold' in options) redrawOpts.screenRedraw = true;
|
|
72
91
|
|
|
73
92
|
return redrawOpts;
|
|
74
93
|
};
|
|
@@ -226,8 +245,8 @@ const createCommands = (
|
|
|
226
245
|
const assertTimestamp = Date.now();
|
|
227
246
|
const assertStartTime = assertTimestamp;
|
|
228
247
|
|
|
229
|
-
// Extract cache
|
|
230
|
-
const { threshold =
|
|
248
|
+
// Extract cache options
|
|
249
|
+
const { threshold = 0.05, cacheKey, os, resolution, ai } = options;
|
|
231
250
|
|
|
232
251
|
// Debug log cache settings
|
|
233
252
|
emitter.emit(
|
|
@@ -318,9 +337,9 @@ const createCommands = (
|
|
|
318
337
|
* @param {number} [options.amount=300] - Amount to scroll in pixels
|
|
319
338
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
320
339
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
321
|
-
* @param {
|
|
322
|
-
* @param {boolean} [options.redraw.
|
|
323
|
-
* @param {
|
|
340
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
341
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
342
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
324
343
|
*/
|
|
325
344
|
const scroll = async (direction = 'down', options = {}) => {
|
|
326
345
|
// Capture absolute timestamp at the very start of the command
|
|
@@ -461,9 +480,9 @@ const createCommands = (
|
|
|
461
480
|
* @param {boolean} [options.selectorUsed] - Whether selector was used
|
|
462
481
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
463
482
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
464
|
-
* @param {
|
|
465
|
-
* @param {boolean} [options.redraw.
|
|
466
|
-
* @param {
|
|
483
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
484
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
485
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
467
486
|
*/
|
|
468
487
|
const click = async (...args) => {
|
|
469
488
|
// Capture absolute timestamp at the very start of the command
|
|
@@ -904,9 +923,9 @@ const createCommands = (
|
|
|
904
923
|
* @param {boolean} [options.secret=false] - If true, text is treated as sensitive (not logged or stored)
|
|
905
924
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
906
925
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
907
|
-
* @param {
|
|
908
|
-
* @param {boolean} [options.redraw.
|
|
909
|
-
* @param {
|
|
926
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
927
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
928
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
910
929
|
*/
|
|
911
930
|
"type": async (text, options = {}) => {
|
|
912
931
|
const { formatter } = require("../../sdk-log-formatter.js");
|
|
@@ -976,9 +995,9 @@ const createCommands = (
|
|
|
976
995
|
* @param {Object} [options] - Additional options
|
|
977
996
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
978
997
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
979
|
-
* @param {
|
|
980
|
-
* @param {boolean} [options.redraw.
|
|
981
|
-
* @param {
|
|
998
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
999
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
1000
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
982
1001
|
*/
|
|
983
1002
|
"press-keys": async (keys, options = {}) => {
|
|
984
1003
|
const { formatter } = require("../../sdk-log-formatter.js");
|
|
@@ -1183,9 +1202,9 @@ const createCommands = (
|
|
|
1183
1202
|
* @param {number} [options.timeout=5000] - Timeout in milliseconds
|
|
1184
1203
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
1185
1204
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
1186
|
-
* @param {
|
|
1187
|
-
* @param {boolean} [options.redraw.
|
|
1188
|
-
* @param {
|
|
1205
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
1206
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
1207
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
1189
1208
|
*/
|
|
1190
1209
|
"wait-for-text": async (...args) => {
|
|
1191
1210
|
// Capture absolute timestamp at the very start of the command
|
|
@@ -1294,9 +1313,9 @@ const createCommands = (
|
|
|
1294
1313
|
* @param {boolean} [options.invert=false] - Invert the match
|
|
1295
1314
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
1296
1315
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
1297
|
-
* @param {
|
|
1298
|
-
* @param {boolean} [options.redraw.
|
|
1299
|
-
* @param {
|
|
1316
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
1317
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
1318
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
1300
1319
|
*/
|
|
1301
1320
|
"scroll-until-text": async (...args) => {
|
|
1302
1321
|
let text, direction, maxDistance, invert, redrawOptions;
|
|
@@ -1448,9 +1467,9 @@ const createCommands = (
|
|
|
1448
1467
|
* @param {Object} [options] - Additional options
|
|
1449
1468
|
* @param {Object} [options.redraw] - Redraw detection options
|
|
1450
1469
|
* @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
|
|
1451
|
-
* @param {
|
|
1452
|
-
* @param {boolean} [options.redraw.
|
|
1453
|
-
* @param {
|
|
1470
|
+
* @param {Object} [options.redraw.thresholds] - Threshold configuration
|
|
1471
|
+
* @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
|
|
1472
|
+
* @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
|
|
1454
1473
|
*/
|
|
1455
1474
|
"focus-application": async (name, options = {}) => {
|
|
1456
1475
|
const redrawOptions = extractRedrawOptions(options);
|
package/agent/lib/redraw.js
CHANGED
|
@@ -348,9 +348,11 @@ const createRedraw = (
|
|
|
348
348
|
});
|
|
349
349
|
resolve("true");
|
|
350
350
|
} else {
|
|
351
|
+
// Poll at 500ms intervals to reduce websocket traffic
|
|
352
|
+
// (previously 250ms = up to 20 polls per 5s timeout, now max 10)
|
|
351
353
|
setTimeout(() => {
|
|
352
354
|
checkCondition(resolve, startTime, timeoutMs, options);
|
|
353
|
-
},
|
|
355
|
+
}, 500);
|
|
354
356
|
}
|
|
355
357
|
}
|
|
356
358
|
|
package/agent/lib/sandbox.js
CHANGED
|
@@ -39,10 +39,14 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
39
39
|
this.sessionInstance = sessionInstance; // Store session instance to include in messages
|
|
40
40
|
this.traceId = null; // Sentry trace ID for debugging
|
|
41
41
|
this.reconnectAttempts = 0;
|
|
42
|
-
this.maxReconnectAttempts =
|
|
42
|
+
this.maxReconnectAttempts = 10;
|
|
43
43
|
this.intentionalDisconnect = false;
|
|
44
44
|
this.apiRoot = null;
|
|
45
45
|
this.apiKey = null;
|
|
46
|
+
this.reconnectTimer = null; // Track reconnect setTimeout
|
|
47
|
+
this.reconnecting = false; // Prevent duplicate reconnection attempts
|
|
48
|
+
this.pendingTimeouts = new Map(); // Track per-message timeouts
|
|
49
|
+
this.pendingRetryQueue = []; // Queue of requests to retry after reconnection
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -89,6 +93,12 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
// Add sandboxId to every message if we have a connected sandbox
|
|
97
|
+
// This allows the API to reconnect if the connection was rerouted
|
|
98
|
+
if (this._lastConnectParams?.sandboxId && !message.sandboxId) {
|
|
99
|
+
message.sandboxId = this._lastConnectParams.sandboxId;
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
let p = new Promise((resolve, reject) => {
|
|
93
103
|
this.socket.send(JSON.stringify(message));
|
|
94
104
|
emitter.emit(events.sandbox.sent, message);
|
|
@@ -100,6 +110,7 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
100
110
|
|
|
101
111
|
// Set up timeout to prevent hanging requests
|
|
102
112
|
const timeoutId = setTimeout(() => {
|
|
113
|
+
this.pendingTimeouts.delete(requestId);
|
|
103
114
|
if (this.ps[requestId]) {
|
|
104
115
|
delete this.ps[requestId];
|
|
105
116
|
rejectPromise(
|
|
@@ -110,20 +121,32 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
110
121
|
}
|
|
111
122
|
}, timeout);
|
|
112
123
|
|
|
124
|
+
// Track timeout so close() can clear it
|
|
125
|
+
this.pendingTimeouts.set(requestId, timeoutId);
|
|
126
|
+
|
|
113
127
|
this.ps[requestId] = {
|
|
114
128
|
promise: p,
|
|
115
129
|
resolve: (result) => {
|
|
116
130
|
clearTimeout(timeoutId);
|
|
131
|
+
this.pendingTimeouts.delete(requestId);
|
|
117
132
|
resolvePromise(result);
|
|
118
133
|
},
|
|
119
134
|
reject: (error) => {
|
|
120
135
|
clearTimeout(timeoutId);
|
|
136
|
+
this.pendingTimeouts.delete(requestId);
|
|
121
137
|
rejectPromise(error);
|
|
122
138
|
},
|
|
123
139
|
message,
|
|
124
140
|
startTime: Date.now(),
|
|
125
141
|
};
|
|
126
142
|
|
|
143
|
+
// Fire-and-forget message types: attach .catch() to prevent
|
|
144
|
+
// unhandled promise rejections if nobody awaits the result
|
|
145
|
+
const fireAndForgetTypes = ["output", "trackInteraction"];
|
|
146
|
+
if (fireAndForgetTypes.includes(message.type)) {
|
|
147
|
+
p.catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
|
|
127
150
|
return p;
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -156,6 +179,9 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
async connect(sandboxId, persist = false, keepAlive = null) {
|
|
182
|
+
// Store connection params so we can re-establish after reconnection
|
|
183
|
+
this._lastConnectParams = { sandboxId, persist, keepAlive };
|
|
184
|
+
|
|
159
185
|
let reply = await this.send({
|
|
160
186
|
type: "connect",
|
|
161
187
|
persist,
|
|
@@ -177,34 +203,129 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
177
203
|
async handleConnectionLoss() {
|
|
178
204
|
if (this.intentionalDisconnect) return;
|
|
179
205
|
|
|
206
|
+
// Prevent duplicate reconnection attempts (both 'error' and 'close' fire)
|
|
207
|
+
if (this.reconnecting) return;
|
|
208
|
+
this.reconnecting = true;
|
|
209
|
+
|
|
210
|
+
// Remove listeners from the old socket to prevent "No pending promise found" warnings
|
|
211
|
+
// when late responses arrive on the dying connection
|
|
212
|
+
if (this.socket) {
|
|
213
|
+
try {
|
|
214
|
+
this.socket.removeAllListeners("message");
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Ignore errors removing listeners from closed socket
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Queue pending requests for retry after reconnection
|
|
221
|
+
// (they were sent on the old socket and will never receive responses)
|
|
222
|
+
const pendingRequestIds = Object.keys(this.ps);
|
|
223
|
+
if (pendingRequestIds.length > 0) {
|
|
224
|
+
console.log(`[Sandbox] Queuing ${pendingRequestIds.length} pending request(s) for retry after reconnection`);
|
|
225
|
+
for (const requestId of pendingRequestIds) {
|
|
226
|
+
const pending = this.ps[requestId];
|
|
227
|
+
if (pending) {
|
|
228
|
+
// Clear the timeout - we'll set a new one when we retry
|
|
229
|
+
const timeoutId = this.pendingTimeouts.get(requestId);
|
|
230
|
+
if (timeoutId) {
|
|
231
|
+
clearTimeout(timeoutId);
|
|
232
|
+
this.pendingTimeouts.delete(requestId);
|
|
233
|
+
}
|
|
234
|
+
// Queue for retry (store message and promise handlers)
|
|
235
|
+
this.pendingRetryQueue.push({
|
|
236
|
+
message: pending.message,
|
|
237
|
+
resolve: pending.resolve,
|
|
238
|
+
reject: pending.reject,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
this.ps = {};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Cancel any existing reconnect timer
|
|
246
|
+
if (this.reconnectTimer) {
|
|
247
|
+
clearTimeout(this.reconnectTimer);
|
|
248
|
+
this.reconnectTimer = null;
|
|
249
|
+
}
|
|
250
|
+
|
|
180
251
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
181
252
|
const errorMsg =
|
|
182
253
|
"Unable to reconnect to TestDriver sandbox after multiple attempts. Please check your internet connection.";
|
|
183
254
|
emitter.emit(events.error.sandbox, errorMsg);
|
|
184
255
|
console.error(errorMsg);
|
|
256
|
+
|
|
257
|
+
// Reject all queued requests since reconnection failed
|
|
258
|
+
if (this.pendingRetryQueue.length > 0) {
|
|
259
|
+
console.log(`[Sandbox] Rejecting ${this.pendingRetryQueue.length} queued request(s) - reconnection failed`);
|
|
260
|
+
for (const queued of this.pendingRetryQueue) {
|
|
261
|
+
queued.reject(new Error("Sandbox reconnection failed after multiple attempts"));
|
|
262
|
+
}
|
|
263
|
+
this.pendingRetryQueue = [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.reconnecting = false;
|
|
185
267
|
return;
|
|
186
268
|
}
|
|
187
269
|
|
|
188
270
|
this.reconnectAttempts++;
|
|
189
|
-
const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1),
|
|
271
|
+
const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 60000);
|
|
190
272
|
|
|
191
273
|
console.log(
|
|
192
274
|
`[Sandbox] Connection lost. Reconnecting in ${delay}ms... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
|
193
275
|
);
|
|
194
276
|
|
|
195
|
-
setTimeout(async () => {
|
|
277
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
278
|
+
this.reconnectTimer = null;
|
|
196
279
|
try {
|
|
197
280
|
await this.boot(this.apiRoot);
|
|
198
281
|
if (this.apiKey) {
|
|
199
282
|
await this.auth(this.apiKey);
|
|
200
283
|
}
|
|
284
|
+
// Re-establish sandbox connection on the new API instance
|
|
285
|
+
// Without this, the new API instance has no connection.desktop
|
|
286
|
+
// and all Linux operations will fail with "sandbox not initialized"
|
|
287
|
+
if (this._lastConnectParams) {
|
|
288
|
+
const { sandboxId, persist, keepAlive } = this._lastConnectParams;
|
|
289
|
+
console.log(`[Sandbox] Re-establishing sandbox connection (${sandboxId})...`);
|
|
290
|
+
await this.connect(sandboxId, persist, keepAlive);
|
|
291
|
+
}
|
|
201
292
|
console.log("[Sandbox] Reconnected successfully.");
|
|
293
|
+
|
|
294
|
+
// Retry queued requests
|
|
295
|
+
await this._retryQueuedRequests();
|
|
202
296
|
} catch (e) {
|
|
203
|
-
//
|
|
297
|
+
// boot's close handler will trigger handleConnectionLoss again
|
|
298
|
+
} finally {
|
|
299
|
+
this.reconnecting = false;
|
|
204
300
|
}
|
|
205
301
|
}, delay);
|
|
206
302
|
}
|
|
207
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Retry queued requests after successful reconnection
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
async _retryQueuedRequests() {
|
|
309
|
+
if (this.pendingRetryQueue.length === 0) return;
|
|
310
|
+
|
|
311
|
+
console.log(`[Sandbox] Retrying ${this.pendingRetryQueue.length} queued request(s)...`);
|
|
312
|
+
|
|
313
|
+
// Take all queued requests and clear the queue
|
|
314
|
+
const toRetry = this.pendingRetryQueue.splice(0);
|
|
315
|
+
|
|
316
|
+
for (const queued of toRetry) {
|
|
317
|
+
try {
|
|
318
|
+
// Re-send the message and resolve/reject the original promise
|
|
319
|
+
const result = await this.send(queued.message);
|
|
320
|
+
queued.resolve(result);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
queued.reject(err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
console.log(`[Sandbox] Finished retrying queued requests.`);
|
|
327
|
+
}
|
|
328
|
+
|
|
208
329
|
async boot(apiRoot) {
|
|
209
330
|
if (apiRoot) this.apiRoot = apiRoot;
|
|
210
331
|
return new Promise((resolve, reject) => {
|
|
@@ -233,10 +354,11 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
233
354
|
// handle errors
|
|
234
355
|
this.socket.on("close", () => {
|
|
235
356
|
clearInterval(this.heartbeat);
|
|
236
|
-
// Emit a clear error event for API key issues
|
|
237
|
-
reject();
|
|
238
357
|
this.apiSocketConnected = false;
|
|
358
|
+
// Reset reconnecting flag so handleConnectionLoss can run for this new disconnection
|
|
359
|
+
this.reconnecting = false;
|
|
239
360
|
this.handleConnectionLoss();
|
|
361
|
+
reject();
|
|
240
362
|
});
|
|
241
363
|
|
|
242
364
|
this.socket.on("error", (err) => {
|
|
@@ -245,13 +367,14 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
245
367
|
clearInterval(this.heartbeat);
|
|
246
368
|
emitter.emit(events.error.sandbox, err);
|
|
247
369
|
this.apiSocketConnected = false;
|
|
248
|
-
|
|
249
|
-
//
|
|
370
|
+
// Don't call handleConnectionLoss here - the 'close' event always fires
|
|
371
|
+
// after 'error', so let 'close' handle reconnection to avoid duplicate attempts
|
|
250
372
|
reject(err);
|
|
251
373
|
});
|
|
252
374
|
|
|
253
375
|
this.socket.on("open", async () => {
|
|
254
376
|
this.reconnectAttempts = 0;
|
|
377
|
+
this.reconnecting = false;
|
|
255
378
|
this.apiSocketConnected = true;
|
|
256
379
|
|
|
257
380
|
this.heartbeat = setInterval(() => {
|
|
@@ -259,6 +382,10 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
259
382
|
this.socket.ping();
|
|
260
383
|
}
|
|
261
384
|
}, 5000);
|
|
385
|
+
// Don't let the heartbeat interval prevent Node process from exiting
|
|
386
|
+
if (this.heartbeat.unref) {
|
|
387
|
+
this.heartbeat.unref();
|
|
388
|
+
}
|
|
262
389
|
|
|
263
390
|
resolve(this);
|
|
264
391
|
});
|
|
@@ -276,15 +403,24 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
276
403
|
}
|
|
277
404
|
|
|
278
405
|
if (!this.ps[message.requestId]) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
406
|
+
// This can happen during reconnection (ps was cleared) or after timeout
|
|
407
|
+
// (promise was deleted). Only log at debug level since it's expected.
|
|
408
|
+
if (!this.reconnecting) {
|
|
409
|
+
console.warn(
|
|
410
|
+
"No pending promise found for requestId:",
|
|
411
|
+
message.requestId,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
283
414
|
return;
|
|
284
415
|
}
|
|
285
416
|
|
|
286
417
|
if (message.error) {
|
|
287
|
-
|
|
418
|
+
// Don't emit error:sandbox for output (log forwarding) messages
|
|
419
|
+
// to prevent infinite loops: error → log → sendToSandbox → error → ...
|
|
420
|
+
const pendingMessage = this.ps[message.requestId]?.message;
|
|
421
|
+
if (pendingMessage?.type !== "output") {
|
|
422
|
+
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
423
|
+
}
|
|
288
424
|
const error = new Error(message.errorMessage || "Sandbox error");
|
|
289
425
|
error.responseData = message;
|
|
290
426
|
this.ps[message.requestId].reject(error);
|
|
@@ -302,12 +438,27 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
302
438
|
*/
|
|
303
439
|
close() {
|
|
304
440
|
this.intentionalDisconnect = true;
|
|
441
|
+
this.reconnecting = false;
|
|
442
|
+
// Cancel any pending reconnect timer
|
|
443
|
+
if (this.reconnectTimer) {
|
|
444
|
+
clearTimeout(this.reconnectTimer);
|
|
445
|
+
this.reconnectTimer = null;
|
|
446
|
+
}
|
|
447
|
+
|
|
305
448
|
if (this.heartbeat) {
|
|
306
449
|
clearInterval(this.heartbeat);
|
|
307
450
|
this.heartbeat = null;
|
|
308
451
|
}
|
|
309
452
|
|
|
453
|
+
// Clear all pending message timeouts to prevent timers keeping the process alive
|
|
454
|
+
for (const timeoutId of this.pendingTimeouts.values()) {
|
|
455
|
+
clearTimeout(timeoutId);
|
|
456
|
+
}
|
|
457
|
+
this.pendingTimeouts.clear();
|
|
458
|
+
|
|
310
459
|
if (this.socket) {
|
|
460
|
+
// Remove all listeners before closing to prevent reconnect attempts
|
|
461
|
+
this.socket.removeAllListeners();
|
|
311
462
|
try {
|
|
312
463
|
this.socket.close();
|
|
313
464
|
} catch (err) {
|
|
@@ -321,9 +472,10 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
321
472
|
this.authenticated = false;
|
|
322
473
|
this.instance = null;
|
|
323
474
|
|
|
324
|
-
// Silently clear pending promises without rejecting
|
|
475
|
+
// Silently clear pending promises and retry queue without rejecting
|
|
325
476
|
// (rejecting causes unhandled promise rejections during cleanup)
|
|
326
477
|
this.ps = {};
|
|
478
|
+
this.pendingRetryQueue = [];
|
|
327
479
|
}
|
|
328
480
|
}
|
|
329
481
|
|