@testdriverai/mcp 7.10.0-canary.0 → 7.10.1-canary
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/.claude-plugin/marketplace.json +17 -0
- package/agent/index.js +116 -71
- package/agent/interface.js +13 -1
- package/agent/lib/commands.js +35 -15
- package/agent/lib/provision-commands.js +92 -11
- package/agent/lib/sandbox.js +263 -30
- package/agent/lib/sdk.js +16 -12
- package/agent/lib/system.js +107 -31
- package/ai/.claude-plugin/plugin.json +89 -4
- package/ai/agents/testdriver.md +4 -8
- package/ai/skills/testdriver-assert/SKILL.md +0 -1
- package/ai/skills/testdriver-aws-setup/SKILL.md +1 -1
- package/ai/skills/testdriver-caching/SKILL.md +13 -6
- package/ai/skills/testdriver-ci-cd/SKILL.md +57 -14
- package/ai/skills/testdriver-client/SKILL.md +10 -6
- package/ai/skills/testdriver-customizing-devices/SKILL.md +7 -5
- package/ai/skills/testdriver-dashcam/SKILL.md +36 -3
- package/ai/skills/testdriver-debugging-with-screenshots/SKILL.md +28 -4
- package/ai/skills/testdriver-device-config/SKILL.md +5 -5
- package/ai/skills/testdriver-find/SKILL.md +50 -11
- package/ai/skills/testdriver-generating-tests/SKILL.md +163 -8
- package/ai/skills/testdriver-hosted/SKILL.md +1 -1
- package/ai/skills/testdriver-machine-setup/SKILL.md +331 -0
- package/ai/skills/testdriver-making-assertions/SKILL.md +78 -2
- package/ai/skills/testdriver-mcp-workflow/SKILL.md +2 -2
- package/ai/skills/testdriver-parse/SKILL.md +1 -1
- package/ai/skills/testdriver-performing-actions/SKILL.md +97 -3
- package/ai/skills/testdriver-provision/SKILL.md +0 -20
- package/ai/skills/testdriver-quickstart/SKILL.md +299 -21
- package/ai/skills/testdriver-reusable-code/SKILL.md +3 -3
- package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
- package/ai/skills/testdriver-screenshot/SKILL.md +5 -5
- package/ai/skills/testdriver-scroll/SKILL.md +30 -160
- package/ai/skills/testdriver-test-writer/SKILL.md +3 -5
- package/ai/skills/testdriver-testdriver/SKILL.md +3 -7
- package/ai/skills/testdriver-wait/SKILL.md +1 -1
- package/{docs/v7/ai.mdx → ai/skills/testdriver:ai/SKILL.md} +3 -4
- package/ai/skills/testdriver:assert/SKILL.md +315 -0
- package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
- package/ai/skills/testdriver:cache/SKILL.md +221 -0
- package/ai/skills/testdriver:caching/SKILL.md +124 -0
- package/ai/skills/testdriver:captcha/SKILL.md +158 -0
- package/ai/skills/testdriver:ci-cd/SKILL.md +659 -0
- package/ai/skills/testdriver:click/SKILL.md +286 -0
- package/ai/skills/testdriver:client/SKILL.md +477 -0
- package/ai/skills/testdriver:customizing-devices/SKILL.md +319 -0
- package/ai/skills/testdriver:dashcam/SKILL.md +451 -0
- package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +415 -0
- package/{docs/v7/device-config.mdx → ai/skills/testdriver:device-config/SKILL.md} +8 -8
- 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 +7 -0
- package/ai/skills/testdriver:errors/SKILL.md +246 -0
- package/ai/skills/testdriver:events/SKILL.md +356 -0
- package/ai/skills/testdriver:exec/SKILL.md +317 -0
- package/ai/skills/testdriver:find/SKILL.md +860 -0
- package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
- package/ai/skills/testdriver:generating-tests/SKILL.md +25 -0
- package/ai/skills/testdriver:hosted/SKILL.md +156 -0
- package/ai/skills/testdriver:hover/SKILL.md +278 -0
- package/{docs/v7/locating-elements.mdx → ai/skills/testdriver:locating-elements/SKILL.md} +3 -3
- package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
- package/ai/skills/testdriver:mcp/SKILL.md +7 -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:parse/SKILL.md +236 -0
- package/ai/skills/testdriver:performing-actions/SKILL.md +53 -0
- package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
- package/ai/skills/testdriver:provision/SKILL.md +331 -0
- package/ai/skills/testdriver:quickstart/SKILL.md +172 -0
- package/ai/skills/testdriver:redraw/SKILL.md +214 -0
- package/ai/skills/testdriver:reusable-code/SKILL.md +249 -0
- package/ai/skills/testdriver:right-click/SKILL.md +123 -0
- package/{docs/v7/running-tests.mdx → ai/skills/testdriver:running-tests/SKILL.md} +4 -4
- package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
- package/ai/skills/testdriver:screenshots/SKILL.md +184 -0
- package/ai/skills/testdriver:scroll/SKILL.md +205 -0
- package/ai/skills/testdriver:secrets/SKILL.md +115 -0
- package/ai/skills/testdriver:self-hosted/SKILL.md +147 -0
- package/ai/skills/testdriver:test-results-json/SKILL.md +257 -0
- package/ai/skills/testdriver:testdriver/SKILL.md +624 -0
- package/ai/skills/testdriver:type/SKILL.md +357 -0
- package/ai/skills/testdriver:variables/SKILL.md +111 -0
- package/ai/skills/testdriver:wait/SKILL.md +50 -0
- package/{docs/v7/waiting-for-elements.mdx → ai/skills/testdriver:waiting-for-elements/SKILL.md} +3 -3
- package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
- package/debugger/index.html +12 -0
- package/docs/_data/examples-manifest.json +54 -46
- package/docs/_data/examples-manifest.schema.json +12 -2
- package/docs/_scripts/extract-example-urls.js +21 -3
- package/docs/_scripts/generate-examples.js +64 -16
- package/docs/changelog.mdx +29 -2
- package/docs/claude-mcp-plugin.mdx +65 -18
- package/docs/docs.json +16 -33
- package/docs/snippets/tests/scroll-yaml.mdx +2 -2
- package/docs/v6/commands/scroll-until-image.mdx +1 -1
- package/docs/v6/commands/scroll-until-text.mdx +2 -2
- package/docs/v6/commands/scroll.mdx +2 -2
- package/docs/v7/_drafts/agents.mdx +0 -4
- package/docs/v7/_drafts/best-practices.mdx +0 -12
- package/docs/v7/_drafts/commands/scroll-until-image.mdx +1 -1
- package/docs/v7/_drafts/commands/scroll-until-text.mdx +2 -2
- package/docs/v7/_drafts/commands/scroll.mdx +1 -1
- package/docs/v7/_drafts/configuration.mdx +0 -12
- package/docs/v7/_drafts/faq.mdx +2 -10
- package/docs/v7/_drafts/readme.mdx +0 -1
- package/docs/v7/_drafts/troubleshooting.mdx +1 -4
- package/docs/v7/assert.mdx +0 -1
- package/docs/v7/aws-setup.mdx +1 -1
- package/docs/v7/caching.mdx +14 -10
- package/docs/v7/ci-cd.mdx +98 -14
- package/docs/v7/client.mdx +10 -6
- package/docs/v7/copilot/auto-healing.mdx +167 -18
- package/docs/v7/copilot/running-tests.mdx +915 -54
- package/docs/v7/customizing-devices.mdx +7 -5
- package/docs/v7/dashcam.mdx +36 -3
- package/docs/v7/debugging-with-screenshots.mdx +31 -7
- package/docs/v7/examples/ai.mdx +13 -8
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +30 -13
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/findall-coffee-icons.mdx +3 -2
- package/docs/v7/examples/formatted-logging.mdx +74 -0
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text-with-description.mdx +2 -2
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +27 -27
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/parse.mdx +3 -2
- package/docs/v7/examples/press-keys.mdx +2 -2
- package/docs/v7/examples/scroll-keyboard.mdx +2 -2
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/find.mdx +50 -11
- package/docs/v7/generating-tests.mdx +166 -10
- package/docs/v7/hosted.mdx +1 -1
- package/docs/v7/making-assertions.mdx +81 -4
- package/docs/v7/parse.mdx +1 -1
- package/docs/v7/performing-actions.mdx +100 -5
- package/docs/v7/provision.mdx +0 -20
- package/docs/v7/quickstart.mdx +299 -21
- package/docs/v7/reusable-code.mdx +3 -3
- package/docs/v7/screenshot.mdx +5 -5
- package/docs/v7/scroll.mdx +30 -160
- package/docs/v7/wait.mdx +1 -1
- package/examples/chrome-extension.test.mjs +29 -12
- package/examples/config.mjs +1 -1
- package/examples/launch-vscode-linux.test.mjs +25 -25
- package/interfaces/cli/commands/init.js +84 -2
- package/interfaces/vitest-plugin.mjs +51 -61
- package/lib/core/Dashcam.js +43 -2
- package/lib/core/index.d.ts +13 -1
- package/lib/github-comment.mjs +11 -5
- package/lib/init-project.js +98 -44
- package/lib/install-clients.js +470 -0
- package/lib/provision.js +749 -0
- package/lib/resolve-channel.js +2 -2
- package/lib/sentry.js +15 -2
- package/lib/vitest/hooks.mjs +44 -34
- package/mcp-server/README.md +32 -0
- package/mcp-server/dist/core/actions.d.ts +176 -0
- package/mcp-server/dist/core/actions.js +753 -0
- package/mcp-server/dist/env-utils.d.ts +45 -0
- package/mcp-server/dist/env-utils.js +63 -0
- package/mcp-server/dist/provision-types.d.ts +6 -2
- package/mcp-server/dist/provision-types.js +3 -1
- package/mcp-server/dist/server.mjs +449 -751
- package/mcp-server/package-lock.json +384 -1
- package/mcp-server/package.json +5 -2
- package/mcp-server/src/core/actions.ts +912 -0
- package/mcp-server/src/env-utils.test.ts +82 -0
- package/mcp-server/src/env-utils.ts +77 -0
- package/mcp-server/src/provision-types.ts +4 -1
- package/mcp-server/src/server.ts +533 -954
- package/mcp-server/tsconfig.json +1 -1
- package/mcp-server/vitest.config.ts +7 -0
- package/package.json +15 -3
- package/sdk.d.ts +11 -9
- package/sdk.js +116 -746
- package/setup/aws/spawn-runner.sh +24 -3
- package/docs/v7/_drafts/caching-ai.mdx +0 -215
- package/docs/v7/_drafts/caching.mdx +0 -366
- package/docs/v7/_drafts/prompt-cache.mdx +0 -200
- package/docs/v7/copilot/creating-tests.mdx +0 -156
- package/docs/v7/copilot/github.mdx +0 -143
- package/docs/v7/copilot/setup.mdx +0 -143
- package/docs/v7/examples/exec-output.mdx +0 -84
- package/docs/v7/examples/exec-pwsh.mdx +0 -82
- package/docs/v7/examples/focus-window.mdx +0 -61
- package/docs/v7/examples/scroll-until-image.mdx +0 -82
- package/docs/v7/examples/windows-installer.mdx +0 -95
- package/examples/formatted-logging.test.mjs +0 -27
- package/examples/match-image.test.mjs +0 -55
- package/manual/exec-stream-logs.test.mjs +0 -25
package/agent/lib/sandbox.js
CHANGED
|
@@ -6,6 +6,17 @@ const { version } = require("../../package.json");
|
|
|
6
6
|
const { withRetry, getSentryTraceHeaders } = require("./sdk");
|
|
7
7
|
const sentry = require("../../lib/sentry");
|
|
8
8
|
|
|
9
|
+
// How long (ms) to keep polling for a free concurrency slot before giving up.
|
|
10
|
+
// Configurable via TD_CONCURRENCY_MAX_WAIT (in seconds). Defaults to 60s.
|
|
11
|
+
// A value of 0 (or negative) disables polling entirely — fail on first denial.
|
|
12
|
+
function resolveConcurrencyMaxWait() {
|
|
13
|
+
var raw = process.env.TD_CONCURRENCY_MAX_WAIT;
|
|
14
|
+
if (raw === undefined || raw === "") return 60000;
|
|
15
|
+
var seconds = Number(raw);
|
|
16
|
+
if (!Number.isFinite(seconds) || seconds < 0) return 60000;
|
|
17
|
+
return Math.round(seconds * 1000);
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
10
21
|
class Sandbox {
|
|
11
22
|
constructor() {
|
|
@@ -372,7 +383,37 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
372
383
|
step: 'discontinuity',
|
|
373
384
|
message: 'Recovering missed messages after connection interruption...',
|
|
374
385
|
});
|
|
375
|
-
self._recoverFromDiscontinuity()
|
|
386
|
+
self._recoverFromDiscontinuity().catch(function (err) {
|
|
387
|
+
logger.debug('[realtime] Discontinuity recovery error (suppressed): ' + (err.message || err));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// When the channel is detached or failed (e.g. sandbox terminated — 404/90001),
|
|
392
|
+
// reject all pending promises immediately so callers don't hang for the full
|
|
393
|
+
// command timeout (up to 5 minutes for dashcam stop).
|
|
394
|
+
if (current === 'detached' || current === 'failed') {
|
|
395
|
+
var pendingIds = Object.keys(self.ps);
|
|
396
|
+
if (pendingIds.length > 0) {
|
|
397
|
+
logger.warn(
|
|
398
|
+
'[realtime] Channel ' + current + ' with ' + pendingIds.length +
|
|
399
|
+
' pending request(s) — rejecting immediately' +
|
|
400
|
+
(reasonMsg ? ' (reason: ' + reasonMsg + ')' : '')
|
|
401
|
+
);
|
|
402
|
+
var channelErr = new Error(
|
|
403
|
+
'Channel ' + current + (reasonMsg ? ': ' + reasonMsg : '') +
|
|
404
|
+
' — sandbox session may have terminated'
|
|
405
|
+
);
|
|
406
|
+
channelErr.name = 'NotFoundError';
|
|
407
|
+
if (reason && reason.code) channelErr.code = reason.code;
|
|
408
|
+
for (var pi = 0; pi < pendingIds.length; pi++) {
|
|
409
|
+
var entry = self.ps[pendingIds[pi]];
|
|
410
|
+
if (entry) {
|
|
411
|
+
delete self.ps[pendingIds[pi]];
|
|
412
|
+
delete self._execBuffers[pendingIds[pi]];
|
|
413
|
+
entry.reject(channelErr);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
376
417
|
}
|
|
377
418
|
});
|
|
378
419
|
}
|
|
@@ -433,6 +474,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
433
474
|
*/
|
|
434
475
|
async _httpPostWithConcurrencyRetry(path, body, timeout) {
|
|
435
476
|
var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
|
|
477
|
+
var concurrencyMaxWait = resolveConcurrencyMaxWait(); // give up polling for a slot after this many ms
|
|
436
478
|
var startTime = Date.now();
|
|
437
479
|
var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
|
|
438
480
|
|
|
@@ -476,6 +518,15 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
476
518
|
var responseData = err.response && err.response.data;
|
|
477
519
|
if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
|
|
478
520
|
var elapsed = Date.now() - startTime;
|
|
521
|
+
if (elapsed >= concurrencyMaxWait) {
|
|
522
|
+
var giveUpErr = new Error(
|
|
523
|
+
(responseData.errorMessage || responseData.message || "Concurrency limit reached") +
|
|
524
|
+
" — gave up after " + Math.round(elapsed / 1000) + "s " +
|
|
525
|
+
"(max wait " + (concurrencyMaxWait / 1000) + "s).",
|
|
526
|
+
);
|
|
527
|
+
giveUpErr.responseData = responseData;
|
|
528
|
+
throw giveUpErr;
|
|
529
|
+
}
|
|
479
530
|
logger.log(
|
|
480
531
|
"Concurrency limit reached — waiting " +
|
|
481
532
|
concurrencyRetryInterval / 1000 +
|
|
@@ -584,6 +635,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
584
635
|
// On slot-denied, we poll forever (re-calling authenticate every 10s)
|
|
585
636
|
// until a slot opens, matching _httpPostWithConcurrencyRetry behavior.
|
|
586
637
|
var concurrencyRetryInterval = 10000;
|
|
638
|
+
// Overall cap on how long we'll keep polling for a free slot before
|
|
639
|
+
// giving up. Keeps a queued agent from hanging forever when the account
|
|
640
|
+
// is at its concurrency limit. Configurable via TD_CONCURRENCY_MAX_WAIT.
|
|
641
|
+
var concurrencyMaxWait = resolveConcurrencyMaxWait();
|
|
587
642
|
var slotPollStart = Date.now();
|
|
588
643
|
while (reply.status === 'pending') {
|
|
589
644
|
logger.log('Slot claim pending — waiting for approval via Ably...');
|
|
@@ -659,12 +714,27 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
659
714
|
// Slot denied — disconnect Ably and re-try the full authenticate
|
|
660
715
|
// flow after a delay, polling forever until a slot opens.
|
|
661
716
|
var elapsed = Date.now() - slotPollStart;
|
|
717
|
+
if (elapsed >= concurrencyMaxWait) {
|
|
718
|
+
try {
|
|
719
|
+
if (this._ably) this._ably.close();
|
|
720
|
+
this._ably = null;
|
|
721
|
+
this._sessionChannel = null;
|
|
722
|
+
} catch (_) {}
|
|
723
|
+
var giveUpErr = new Error(
|
|
724
|
+
'Slot denied: ' + (slotDecision.data.message || 'concurrency limit reached') +
|
|
725
|
+
' — gave up after ' + Math.round(elapsed / 1000) + 's ' +
|
|
726
|
+
'(max wait ' + (concurrencyMaxWait / 1000) + 's). ' +
|
|
727
|
+
'Upgrade for more slots → https://console.testdriver.ai/checkout/pro'
|
|
728
|
+
);
|
|
729
|
+
giveUpErr.responseData = slotDecision.data;
|
|
730
|
+
throw giveUpErr;
|
|
731
|
+
}
|
|
662
732
|
logger.log(
|
|
663
733
|
'Slot denied: ' + (slotDecision.data.message || 'concurrency limit reached') +
|
|
664
734
|
' — waiting ' + (concurrencyRetryInterval / 1000) + 's before retrying' +
|
|
665
735
|
' (' + Math.round(elapsed / 1000) + 's elapsed)...'
|
|
666
736
|
);
|
|
667
|
-
logger.log('Upgrade for more slots → https://console.testdriver.ai/checkout/
|
|
737
|
+
logger.log('Upgrade for more slots → https://console.testdriver.ai/checkout/pro');
|
|
668
738
|
try {
|
|
669
739
|
if (this._ably) this._ably.close();
|
|
670
740
|
this._ably = null;
|
|
@@ -741,14 +811,160 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
741
811
|
break; // slot approved and provisioned — exit the while loop
|
|
742
812
|
}
|
|
743
813
|
|
|
814
|
+
// ─── Handle async provisioning status ─────────────────────────────
|
|
815
|
+
// API may return early with status: 'provisioning' while a background
|
|
816
|
+
// job is still creating/configuring the sandbox. Prefer Ably control
|
|
817
|
+
// events for completion and only fall back to authenticate polling when
|
|
818
|
+
// an event is not observed in time.
|
|
819
|
+
var provisioningPollStart = Date.now();
|
|
820
|
+
var provisioningTimeoutMs = 10 * 60 * 1000;
|
|
821
|
+
while (reply.status === 'provisioning') {
|
|
822
|
+
var provisionElapsed = Date.now() - provisioningPollStart;
|
|
823
|
+
if (provisionElapsed >= provisioningTimeoutMs) {
|
|
824
|
+
var provisioningTimeoutErr = new Error(
|
|
825
|
+
"Sandbox provisioning timed out after " +
|
|
826
|
+
Math.round(provisionElapsed / 1000) +
|
|
827
|
+
"s" +
|
|
828
|
+
(this._sandboxId || (reply && reply.sandboxId)
|
|
829
|
+
? " for sandbox " + (this._sandboxId || (reply && reply.sandboxId))
|
|
830
|
+
: "") +
|
|
831
|
+
". Last known status: provisioning"
|
|
832
|
+
);
|
|
833
|
+
provisioningTimeoutErr.responseData = reply;
|
|
834
|
+
throw provisioningTimeoutErr;
|
|
835
|
+
}
|
|
836
|
+
logger.log(
|
|
837
|
+
'Waiting for sandbox to be ready...'
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
var self = this;
|
|
841
|
+
var provisioningEvent = null;
|
|
842
|
+
|
|
843
|
+
if (this._sessionChannel) {
|
|
844
|
+
provisioningEvent = await new Promise(function (resolve) {
|
|
845
|
+
var resolved = false;
|
|
846
|
+
var eventTimeout = 30000;
|
|
847
|
+
|
|
848
|
+
function finish(data) {
|
|
849
|
+
if (resolved) return;
|
|
850
|
+
resolved = true;
|
|
851
|
+
clearTimeout(timer);
|
|
852
|
+
try { self._sessionChannel.unsubscribe('control', onProvisionCtrl); } catch (_) {}
|
|
853
|
+
resolve(data || null);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function onProvisionCtrl(msg) {
|
|
857
|
+
var data = msg && msg.data;
|
|
858
|
+
if (!data) return;
|
|
859
|
+
if (data.type === 'provisioning.started') {
|
|
860
|
+
logger.log((data.message || 'Provisioning started') + (data.os ? ' (' + data.os + ')' : ''));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (data.type === 'provisioning.progress') {
|
|
864
|
+
var progress = data.message || ('Provisioning step: ' + (data.phase || 'in-progress'));
|
|
865
|
+
logger.log(progress);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (data.type === 'provisioning.completed' || data.type === 'provisioning.failed') {
|
|
869
|
+
finish(data);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
var timer = setTimeout(function () {
|
|
874
|
+
finish(null);
|
|
875
|
+
}, eventTimeout);
|
|
876
|
+
if (timer.unref) timer.unref();
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
self._sessionChannel.subscribe('control', onProvisionCtrl);
|
|
880
|
+
|
|
881
|
+
// Check recent history to close race window where event was
|
|
882
|
+
// published before this subscription was attached.
|
|
883
|
+
self._sessionChannel.history({ limit: 20 }).then(function (page) {
|
|
884
|
+
if (!page || !page.items || resolved) return;
|
|
885
|
+
for (var i = 0; i < page.items.length; i++) {
|
|
886
|
+
var item = page.items[i];
|
|
887
|
+
var data = item && item.data;
|
|
888
|
+
if (item && item.name === 'control' && data && (data.type === 'provisioning.completed' || data.type === 'provisioning.failed')) {
|
|
889
|
+
finish(data);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}).catch(function (err) {
|
|
894
|
+
logger.warn('Provisioning history lookup failed (non-fatal): ' + (err.message || err));
|
|
895
|
+
});
|
|
896
|
+
} catch (subscribeErr) {
|
|
897
|
+
logger.warn('Provisioning event subscribe failed (non-fatal): ' + (subscribeErr.message || subscribeErr));
|
|
898
|
+
finish(null);
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (provisioningEvent && provisioningEvent.type === 'provisioning.failed') {
|
|
904
|
+
var eventErr = new Error(
|
|
905
|
+
provisioningEvent.errorMessage || 'Failed while waiting for sandbox provisioning',
|
|
906
|
+
);
|
|
907
|
+
eventErr.responseData = provisioningEvent;
|
|
908
|
+
throw eventErr;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (provisioningEvent && provisioningEvent.type === 'provisioning.completed') {
|
|
912
|
+
// Event carries the final payload shape from the API, so we can stop
|
|
913
|
+
// polling authenticate in the common case.
|
|
914
|
+
reply = Object.assign({}, reply, provisioningEvent);
|
|
915
|
+
if (reply.status === 'provisioning') {
|
|
916
|
+
reply.status = 'ready';
|
|
917
|
+
}
|
|
918
|
+
if (reply.success !== true) {
|
|
919
|
+
reply.success = true;
|
|
920
|
+
}
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
await new Promise(function (resolve) {
|
|
925
|
+
var t = setTimeout(resolve, 10000);
|
|
926
|
+
if (t.unref) t.unref();
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
var pollBody = {
|
|
930
|
+
apiKey: this.apiKey,
|
|
931
|
+
version: version,
|
|
932
|
+
os: message.os || this.os || 'linux',
|
|
933
|
+
session: sessionId,
|
|
934
|
+
apiRoot: this.apiRoot,
|
|
935
|
+
sandboxId: this._sandboxId || (reply && reply.sandboxId),
|
|
936
|
+
slotApproved: true,
|
|
937
|
+
};
|
|
938
|
+
if (message.resolution) pollBody.resolution = message.resolution;
|
|
939
|
+
if (message.ci) pollBody.ci = message.ci;
|
|
940
|
+
if (message.ami) pollBody.ami = message.ami;
|
|
941
|
+
if (message.instanceType) pollBody.instanceType = message.instanceType;
|
|
942
|
+
if (message.e2bTemplateId) pollBody.e2bTemplateId = message.e2bTemplateId;
|
|
943
|
+
if (message.keepAlive !== undefined) pollBody.keepAlive = message.keepAlive;
|
|
944
|
+
|
|
945
|
+
reply = await this._httpPostWithConcurrencyRetry(
|
|
946
|
+
"/api/v7/sandbox/authenticate",
|
|
947
|
+
pollBody,
|
|
948
|
+
timeout,
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
if (!reply.success && reply.status !== 'provisioning') {
|
|
952
|
+
var provisioningErr = new Error(
|
|
953
|
+
reply.errorMessage || "Failed while waiting for sandbox provisioning",
|
|
954
|
+
);
|
|
955
|
+
provisioningErr.responseData = reply;
|
|
956
|
+
throw provisioningErr;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
744
960
|
if (message.type === "create") {
|
|
745
961
|
// E2B (Linux) sandboxes return a url directly.
|
|
746
962
|
// We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
|
|
747
963
|
const isE2B = !!reply.url;
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
964
|
+
|
|
965
|
+
let runnerIp = reply.runner && reply.runner.ip;
|
|
966
|
+
let noVncPort = reply.runner && reply.runner.noVncPort;
|
|
967
|
+
let runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
752
968
|
|
|
753
969
|
// Log image version info (AMI for Windows, E2B template for Linux)
|
|
754
970
|
if (reply.imageVersion) {
|
|
@@ -771,12 +987,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
771
987
|
// For presence-based Windows runners (reply.runner already set), the runner
|
|
772
988
|
// is already listening so we can skip the wait.
|
|
773
989
|
var self = this;
|
|
774
|
-
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
|
|
990
|
+
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner || (reply.runner && reply.runner.os === 'windows'));
|
|
775
991
|
if (needsReadyWait) {
|
|
776
992
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
777
|
-
// E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
|
|
778
|
-
// EC2 (Windows)
|
|
779
|
-
var readyTimeout = isE2B ? 300000 :
|
|
993
|
+
// E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot.
|
|
994
|
+
// Hosted EC2 (Windows) can also take several minutes when launching/provisioning in background.
|
|
995
|
+
var readyTimeout = isE2B ? 300000 : 300000; // 5 min for E2B and EC2
|
|
780
996
|
await new Promise(function (resolve, reject) {
|
|
781
997
|
var resolved = false;
|
|
782
998
|
var waitStart = Date.now();
|
|
@@ -787,7 +1003,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
787
1003
|
clearInterval(progressTimer);
|
|
788
1004
|
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
789
1005
|
// Update runner info if provided
|
|
790
|
-
if (data && data.os) reply.runner = reply.runner || {};
|
|
1006
|
+
if (data && (data.os || data.ip)) reply.runner = reply.runner || {};
|
|
791
1007
|
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
792
1008
|
if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
|
|
793
1009
|
if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
|
|
@@ -853,11 +1069,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
853
1069
|
// Also check channel history in case runner.ready was published
|
|
854
1070
|
// before we subscribed (race condition on fast-booting agents).
|
|
855
1071
|
try {
|
|
856
|
-
self._sessionChannel.history({ limit: 50 }
|
|
857
|
-
if (err) {
|
|
858
|
-
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
1072
|
+
self._sessionChannel.history({ limit: 50 }).then(function (page) {
|
|
861
1073
|
if (page && page.items) {
|
|
862
1074
|
for (var i = 0; i < page.items.length; i++) {
|
|
863
1075
|
var item = page.items[i];
|
|
@@ -868,12 +1080,21 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
868
1080
|
}
|
|
869
1081
|
}
|
|
870
1082
|
}
|
|
1083
|
+
}).catch(function (err) {
|
|
1084
|
+
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
871
1085
|
});
|
|
872
1086
|
} catch (histErr) {
|
|
873
1087
|
logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
|
|
874
1088
|
}
|
|
875
1089
|
});
|
|
876
1090
|
}
|
|
1091
|
+
|
|
1092
|
+
// Refresh runner metadata after runner.ready wait because the wait handler
|
|
1093
|
+
// can populate reply.runner fields from control messages.
|
|
1094
|
+
runnerIp = reply.runner && reply.runner.ip;
|
|
1095
|
+
noVncPort = reply.runner && reply.runner.noVncPort;
|
|
1096
|
+
runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
1097
|
+
|
|
877
1098
|
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
878
1099
|
// For E2B sandboxes, use the url from the API reply.
|
|
879
1100
|
// Fall back to constructing from ip + noVncPort for older runners.
|
|
@@ -1005,11 +1226,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1005
1226
|
// Also check channel history in case runner.ready was published
|
|
1006
1227
|
// before we subscribed (race condition on fast-booting agents).
|
|
1007
1228
|
try {
|
|
1008
|
-
self._sessionChannel.history({ limit: 50 }
|
|
1009
|
-
if (err) {
|
|
1010
|
-
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1229
|
+
self._sessionChannel.history({ limit: 50 }).then(function (page) {
|
|
1013
1230
|
if (page && page.items) {
|
|
1014
1231
|
for (var i = 0; i < page.items.length; i++) {
|
|
1015
1232
|
var item = page.items[i];
|
|
@@ -1020,6 +1237,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1020
1237
|
}
|
|
1021
1238
|
}
|
|
1022
1239
|
}
|
|
1240
|
+
}).catch(function (err) {
|
|
1241
|
+
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
1023
1242
|
});
|
|
1024
1243
|
} catch (histErr) {
|
|
1025
1244
|
logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
|
|
@@ -1204,9 +1423,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1204
1423
|
startTime: Date.now(),
|
|
1205
1424
|
};
|
|
1206
1425
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1426
|
+
// Prevent unhandled rejections from late Ably responses that arrive
|
|
1427
|
+
// after the caller has moved on (e.g. test retry, cleanup). The await
|
|
1428
|
+
// chain still receives the rejection; this just marks the promise as
|
|
1429
|
+
// "handled" so Node does not emit unhandledRejection.
|
|
1430
|
+
p.catch(function () { });
|
|
1210
1431
|
|
|
1211
1432
|
this._throttledPublish(this._sessionChannel, "command", message)
|
|
1212
1433
|
.then(function () {
|
|
@@ -1255,12 +1476,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1255
1476
|
var rate = (this._publishCount / windowElapsed) * 1000;
|
|
1256
1477
|
var rateStr = rate.toFixed(1);
|
|
1257
1478
|
|
|
1258
|
-
// Log rate - warning if approaching limit, debug otherwise
|
|
1259
|
-
if (rate > 45) {
|
|
1260
|
-
|
|
1261
|
-
} else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
1262
|
-
|
|
1263
|
-
}
|
|
1479
|
+
// // Log rate - warning if approaching limit, debug otherwise
|
|
1480
|
+
// if (rate > 45) {
|
|
1481
|
+
// logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
|
|
1482
|
+
// } else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
1483
|
+
// logger.log("Ably publish rate: " + rateStr + " msg/sec");
|
|
1484
|
+
// }
|
|
1264
1485
|
|
|
1265
1486
|
// Reset window
|
|
1266
1487
|
this._publishCount = 0;
|
|
@@ -1441,6 +1662,18 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1441
1662
|
this.authenticated = false;
|
|
1442
1663
|
this.instance = null;
|
|
1443
1664
|
this._lastConnectParams = null;
|
|
1665
|
+
|
|
1666
|
+
// Reject all pending promises so active callers get a clear error
|
|
1667
|
+
// instead of hanging forever. The safety .catch() in _sendAbly()
|
|
1668
|
+
// ensures these rejections never surface as unhandled.
|
|
1669
|
+
var pendingIds = Object.keys(this.ps);
|
|
1670
|
+
var closedError = new Error('Sandbox connection closed');
|
|
1671
|
+
for (var i = 0; i < pendingIds.length; i++) {
|
|
1672
|
+
var entry = this.ps[pendingIds[i]];
|
|
1673
|
+
if (entry && typeof entry.reject === 'function') {
|
|
1674
|
+
try { entry.reject(closedError); } catch (_) { /* already settled */ }
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1444
1677
|
this.ps = {};
|
|
1445
1678
|
}
|
|
1446
1679
|
|
package/agent/lib/sdk.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { events } = require("../events");
|
|
2
2
|
const { getSentryTraceHeaders } = require("./http");
|
|
3
|
+
const sentry = require("../../lib/sentry");
|
|
3
4
|
|
|
4
5
|
// get the version from package.json
|
|
5
6
|
const { version } = require("../../package.json");
|
|
@@ -20,6 +21,7 @@ const DEFAULT_RETRY_CONFIG = {
|
|
|
20
21
|
'ENOTFOUND',
|
|
21
22
|
'ENETUNREACH',
|
|
22
23
|
'ERR_NETWORK',
|
|
24
|
+
'ERR_BAD_RESPONSE',
|
|
23
25
|
'ECONNABORTED',
|
|
24
26
|
'EPIPE',
|
|
25
27
|
'EAI_AGAIN',
|
|
@@ -35,14 +37,7 @@ const DEFAULT_RETRY_CONFIG = {
|
|
|
35
37
|
* @returns {boolean} Whether the request should be retried
|
|
36
38
|
*/
|
|
37
39
|
function isRetryableError(error, config = DEFAULT_RETRY_CONFIG) {
|
|
38
|
-
|
|
39
|
-
if (!error.response) {
|
|
40
|
-
return config.retryableNetworkCodes.includes(error.code);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// HTTP status code based retries
|
|
44
|
-
const status = error.response?.status;
|
|
45
|
-
return config.retryableStatusCodes.includes(status);
|
|
40
|
+
return true;
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
/**
|
|
@@ -102,6 +97,18 @@ async function withRetry(fn, options = {}) {
|
|
|
102
97
|
|
|
103
98
|
const delayMs = calculateRetryDelay(attempt, error, config);
|
|
104
99
|
|
|
100
|
+
// Log retry errors to Sentry for visibility
|
|
101
|
+
sentry.captureException(error, {
|
|
102
|
+
tags: { phase: 'retry', attempt: attempt + 1 },
|
|
103
|
+
extra: {
|
|
104
|
+
maxRetries: config.maxRetries,
|
|
105
|
+
delayMs,
|
|
106
|
+
code: error.code,
|
|
107
|
+
status: error.response?.status,
|
|
108
|
+
url: error.config?.url?.replace(/X-Amz-\w+=[\w%]+/g, 'X-Amz-***=REDACTED'),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
105
112
|
// Call onRetry callback if provided
|
|
106
113
|
if (options.onRetry) {
|
|
107
114
|
options.onRetry(attempt + 1, error, delayMs);
|
|
@@ -395,10 +402,7 @@ const createSDK = (emitter, config, sessionInstance) => {
|
|
|
395
402
|
const savedKB = (data.image.length / 1024).toFixed(0);
|
|
396
403
|
delete data.image;
|
|
397
404
|
data.imageKey = imageKey;
|
|
398
|
-
|
|
399
|
-
path,
|
|
400
|
-
message: `[sdk] uploaded screenshot to S3 (saved ${savedKB}KB inline), imageKey=${imageKey}`,
|
|
401
|
-
});
|
|
405
|
+
|
|
402
406
|
}
|
|
403
407
|
} catch (uploadErr) {
|
|
404
408
|
// Non-fatal: fall back to sending base64 inline
|
package/agent/lib/system.js
CHANGED
|
@@ -35,45 +35,80 @@ const createSystem = (emitter, sandbox, config) => {
|
|
|
35
35
|
throw new Error("No downloadUrl in response: " + JSON.stringify(response.data));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Step 2: Download the image from S3
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
// Step 2: Download the image from S3 (with retry)
|
|
39
|
+
// Short timeout + many retries: fail fast on stuck connections,
|
|
40
|
+
// and give Tigris time to replicate the object between attempts
|
|
41
|
+
const imageResponse = await withRetry(
|
|
42
|
+
() => axios({
|
|
43
|
+
method: "get",
|
|
44
|
+
url: downloadUrl,
|
|
45
|
+
responseType: "arraybuffer",
|
|
46
|
+
timeout: 10000,
|
|
47
|
+
}),
|
|
48
|
+
{
|
|
49
|
+
retryConfig: { maxRetries: 3, baseDelayMs: 1000 },
|
|
50
|
+
},
|
|
51
|
+
);
|
|
45
52
|
|
|
46
53
|
return Buffer.from(imageResponse.data).toString("base64");
|
|
47
54
|
};
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
// Capture a screenshot from the runner. Returns the raw runner response,
|
|
57
|
+
// which is one of:
|
|
58
|
+
// { s3Key, width, height } — runner uploaded to S3 (Ably 64KB limit)
|
|
59
|
+
// { base64 } — direct/local connection, bytes inline
|
|
60
|
+
const captureRaw = async () => {
|
|
61
|
+
return await sandbox.send({
|
|
51
62
|
type: "system.screenshot",
|
|
52
63
|
});
|
|
64
|
+
};
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
const screenshot = async (options, rawResponse) => {
|
|
67
|
+
const MAX_RETRIES = 3;
|
|
68
|
+
let lastError;
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
// Reuse a response captured by the caller (so the key path and the
|
|
73
|
+
// base64 path don't each trigger a separate runner capture); otherwise
|
|
74
|
+
// capture fresh.
|
|
75
|
+
let response = attempt === 0 && rawResponse
|
|
76
|
+
? rawResponse
|
|
77
|
+
: await captureRaw();
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
let base64;
|
|
80
|
+
|
|
81
|
+
// Runner returns { s3Key } for Ably (screenshots too large for 64KB limit)
|
|
82
|
+
// Runner returns { base64 } for direct/local connections
|
|
83
|
+
if (response.s3Key) {
|
|
84
|
+
base64 = await downloadFromS3(response.s3Key);
|
|
85
|
+
} else {
|
|
86
|
+
base64 = response.base64;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!base64) {
|
|
90
|
+
throw new Error("Failed to take screenshot: sandbox returned empty data");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let image = Buffer.from(base64, "base64");
|
|
94
|
+
|
|
95
|
+
// Verify we got actual image data (PNG header starts with these bytes)
|
|
96
|
+
if (image.length < 100) {
|
|
97
|
+
throw new Error(`Failed to take screenshot: received only ${image.length} bytes`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fs.writeFileSync(options.filename, image);
|
|
101
|
+
return { filename: options.filename };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
lastError = error;
|
|
104
|
+
if (attempt < MAX_RETRIES) {
|
|
105
|
+
const delay = 1000 * Math.pow(2, attempt);
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
73
109
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return { filename: options.filename };
|
|
110
|
+
|
|
111
|
+
throw lastError;
|
|
77
112
|
};
|
|
78
113
|
|
|
79
114
|
let primaryDisplay = null;
|
|
@@ -90,7 +125,7 @@ const createSystem = (emitter, sandbox, config) => {
|
|
|
90
125
|
return path.join(os.tmpdir(), `td-${Date.now()}-${randomUUID().slice(0, 8)}-${countImages}.png`);
|
|
91
126
|
};
|
|
92
127
|
|
|
93
|
-
const captureAndResize = async (scale = 1, silent = false, mouse = false) => {
|
|
128
|
+
const captureAndResize = async (scale = 1, silent = false, mouse = false, rawResponse = null) => {
|
|
94
129
|
try {
|
|
95
130
|
if (!silent) {
|
|
96
131
|
emitter.emit(events.screenCapture.start, {
|
|
@@ -103,7 +138,7 @@ const createSystem = (emitter, sandbox, config) => {
|
|
|
103
138
|
let step1 = tmpFilename();
|
|
104
139
|
let step2 = tmpFilename();
|
|
105
140
|
|
|
106
|
-
await screenshot({ filename: step1, format: "png" });
|
|
141
|
+
await screenshot({ filename: step1, format: "png" }, rawResponse);
|
|
107
142
|
|
|
108
143
|
// Load the screenshot image with Jimp
|
|
109
144
|
let image = await Jimp.read(step1);
|
|
@@ -165,6 +200,46 @@ const createSystem = (emitter, sandbox, config) => {
|
|
|
165
200
|
return await captureAndResize(scale, silent, mouse);
|
|
166
201
|
};
|
|
167
202
|
|
|
203
|
+
// Build the image payload to send to the API for a command (find/assert/etc).
|
|
204
|
+
//
|
|
205
|
+
// Fast path: when the runner uploaded the screenshot to S3 and it was already
|
|
206
|
+
// captured at the requested resolution, return { imageKey } so the API reads
|
|
207
|
+
// the bytes straight from S3 by key. This skips the redundant round-trip the
|
|
208
|
+
// base64 path pays per command — SDK download from S3, Jimp re-encode, then a
|
|
209
|
+
// re-upload on the API side.
|
|
210
|
+
//
|
|
211
|
+
// Slow path (fallback): when bytes are inline (local/direct connection), when
|
|
212
|
+
// a mouse cursor must be composited, when scale != 1, or when the captured
|
|
213
|
+
// size differs from TD_RESOLUTION (so a resize is actually required), fall
|
|
214
|
+
// back to capturing + resizing locally and return { image } (base64).
|
|
215
|
+
const captureScreenImage = async (scale = 1, silent = false, mouse = false) => {
|
|
216
|
+
const raw = await captureRaw();
|
|
217
|
+
|
|
218
|
+
const [targetW, targetH] = config.TD_RESOLUTION || [];
|
|
219
|
+
const canUseKey =
|
|
220
|
+
raw &&
|
|
221
|
+
raw.s3Key &&
|
|
222
|
+
!mouse &&
|
|
223
|
+
scale === 1 &&
|
|
224
|
+
typeof raw.width === "number" &&
|
|
225
|
+
typeof raw.height === "number" &&
|
|
226
|
+
raw.width === targetW &&
|
|
227
|
+
raw.height === targetH;
|
|
228
|
+
|
|
229
|
+
if (canUseKey) {
|
|
230
|
+
if (!silent) {
|
|
231
|
+
emitter.emit(events.screenCapture.start, { scale, silent, display: primaryDisplay });
|
|
232
|
+
emitter.emit(events.screenCapture.end, { scale, silent, display: primaryDisplay });
|
|
233
|
+
}
|
|
234
|
+
return { imageKey: raw.s3Key };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Fallback: download/resize locally and send base64. Pass the already
|
|
238
|
+
// captured runner response through so we don't capture the screen twice.
|
|
239
|
+
const step2 = await captureAndResize(scale, silent, mouse, raw);
|
|
240
|
+
return { image: fs.readFileSync(step2, "base64") };
|
|
241
|
+
};
|
|
242
|
+
|
|
168
243
|
const platform = () => {
|
|
169
244
|
return "windows";
|
|
170
245
|
};
|
|
@@ -191,6 +266,7 @@ const createSystem = (emitter, sandbox, config) => {
|
|
|
191
266
|
return {
|
|
192
267
|
captureScreenBase64,
|
|
193
268
|
captureScreenPNG,
|
|
269
|
+
captureScreenImage,
|
|
194
270
|
getMousePosition,
|
|
195
271
|
primaryDisplay,
|
|
196
272
|
activeWin,
|