@testdriverai/agent 7.8.0-canary.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/.env.example +4 -0
- package/.prettierignore +4 -0
- package/.prettierrc +1 -0
- package/CHANGELOG.md +953 -0
- package/README.md +81 -0
- package/agent/events.js +135 -0
- package/agent/index.js +2450 -0
- package/agent/interface.js +35 -0
- package/agent/lib/analytics.js +22 -0
- package/agent/lib/censorship.js +75 -0
- package/agent/lib/commander.js +246 -0
- package/agent/lib/commands.js +1684 -0
- package/agent/lib/config.js +60 -0
- package/agent/lib/generator.js +91 -0
- package/agent/lib/http.js +144 -0
- package/agent/lib/logger.js +56 -0
- package/agent/lib/outputs.js +29 -0
- package/agent/lib/parser.js +209 -0
- package/agent/lib/redraw.js +386 -0
- package/agent/lib/resources/cursor-2.png +0 -0
- package/agent/lib/sandbox.js +1104 -0
- package/agent/lib/sdk.js +633 -0
- package/agent/lib/session.js +25 -0
- package/agent/lib/source-mapper.js +342 -0
- package/agent/lib/subimage/index.js +77 -0
- package/agent/lib/subimage/opencv.js +69 -0
- package/agent/lib/system.js +204 -0
- package/agent/lib/theme.js +14 -0
- package/agent/lib/valid-version.js +21 -0
- package/agent/lib/validation.js +169 -0
- package/ai/.claude-plugin/plugin.json +9 -0
- package/ai/agents/testdriver.md +638 -0
- 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-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 +602 -0
- package/ai/skills/testdriver-click/SKILL.md +286 -0
- package/ai/skills/testdriver-client/SKILL.md +477 -0
- package/ai/skills/testdriver-cloud/SKILL.md +119 -0
- package/ai/skills/testdriver-customizing-devices/SKILL.md +319 -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-errors/SKILL.md +246 -0
- package/ai/skills/testdriver-events/SKILL.md +356 -0
- package/ai/skills/testdriver-examples/SKILL.md +7 -0
- package/ai/skills/testdriver-exec/SKILL.md +317 -0
- package/ai/skills/testdriver-find/SKILL.md +829 -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/SKILL.md +7 -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-parse/SKILL.md +236 -0
- package/ai/skills/testdriver-performing-actions/SKILL.md +54 -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 +144 -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/ai/skills/testdriver-running-tests/SKILL.md +185 -0
- 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 +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 +448 -0
- package/ai/skills/testdriver-testdriver/SKILL.md +628 -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-wait/SKILL.md +50 -0
- package/ai/skills/testdriver-waiting-for-elements/SKILL.md +90 -0
- package/ai/skills/testdriver-what-is-testdriver/SKILL.md +54 -0
- package/bin/testdriverai.js +22 -0
- package/debugger/bg.png +0 -0
- package/debugger/icon.png +0 -0
- package/debugger/index.html +469 -0
- package/debugger/td.png +0 -0
- package/debugger/tray-buffered.png +0 -0
- package/debugger/tray.png +0 -0
- package/docs/GITHUB_COMMENTS.md +330 -0
- package/docs/GITHUB_COMMENTS_ANNOUNCEMENT.md +167 -0
- package/docs/QUICK-START-GITHUB-COMMENTS.md +84 -0
- package/docs/TEST-GITHUB-COMMENTS.md +129 -0
- package/docs/_data/examples-manifest.json +177 -0
- package/docs/_data/examples-manifest.schema.json +41 -0
- package/docs/_scripts/extract-example-urls.js +165 -0
- package/docs/_scripts/generate-examples.js +560 -0
- package/docs/_scripts/generate-skills.js +154 -0
- package/docs/_scripts/link-replacer.js +164 -0
- package/docs/_scripts/upload-docs-to-openai.js +284 -0
- package/docs/changelog.mdx +161 -0
- package/docs/claude-mcp-plugin.mdx +160 -0
- package/docs/docs.json +442 -0
- package/docs/github-integration-setup.md +266 -0
- package/docs/guide/best-practices-polling.mdx +174 -0
- package/docs/images/content/account/newprojectsettings.png +0 -0
- package/docs/images/content/account/projectpage.png +0 -0
- package/docs/images/content/account/projectreplays.png +0 -0
- package/docs/images/content/account/team-manage.png +0 -0
- package/docs/images/content/account/teampage.png +0 -0
- package/docs/images/content/extension/cursor.svg +1 -0
- package/docs/images/content/extension/vscode.svg +57 -0
- package/docs/images/content/extension/windsurf.svg +3 -0
- package/docs/images/content/parse/output.png +0 -0
- package/docs/images/content/self-hosted/launchtemplateid.png +0 -0
- package/docs/images/content/side-by-side.png +0 -0
- package/docs/images/content/vscode/ide-full.png +0 -0
- package/docs/images/content/vscode/running.png +0 -0
- package/docs/images/content/vscode/v7-chat.png +0 -0
- package/docs/images/content/vscode/v7-choose-agent.png +0 -0
- package/docs/images/content/vscode/v7-full.png +0 -0
- package/docs/images/content/vscode/v7-onboarding.png +0 -0
- package/docs/images/content/vscode/vscode-2-assert.png +0 -0
- package/docs/images/content/vscode/vscode-agent-preview.png +0 -0
- package/docs/images/content/vscode/vscode-copilot-ask.png +0 -0
- package/docs/images/content/vscode/vscode-file-creation.png +0 -0
- package/docs/images/content/vscode/vscode-install.png +0 -0
- package/docs/images/content/vscode/vscode-overview.png +0 -0
- package/docs/images/content/vscode/vscode-setup-walkthrough.png +0 -0
- package/docs/images/content/vscode/vscode-stopchat.png +0 -0
- package/docs/images/content/vscode/vscode-stoptest.png +0 -0
- package/docs/images/content/vscode/vscode-tdservice.png +0 -0
- package/docs/images/content/vscode/vscode-test-output.png +0 -0
- package/docs/images/content/vscode/vscode-testhistory.png +0 -0
- package/docs/images/content/vscode/vscode-testpane-runtests.png +0 -0
- package/docs/images/content/vscode/vscode-testpane.png +0 -0
- package/docs/images/template/dark.png +0 -0
- package/docs/images/template/icon.png +0 -0
- package/docs/images/template/light.png +0 -0
- package/docs/snippets/calendar-link.mdx +4 -0
- package/docs/snippets/gitignore-warning.mdx +7 -0
- package/docs/snippets/lifecycle-warning.mdx +6 -0
- package/docs/snippets/test-prereqs.mdx +12 -0
- package/docs/snippets/tests/assert-replay.mdx +7 -0
- package/docs/snippets/tests/assert-yaml.mdx +8 -0
- package/docs/snippets/tests/exec-js-replay.mdx +7 -0
- package/docs/snippets/tests/exec-js-yaml.mdx +32 -0
- package/docs/snippets/tests/exec-shell-replay.mdx +7 -0
- package/docs/snippets/tests/exec-shell-yaml.mdx +15 -0
- package/docs/snippets/tests/hover-image-replay.mdx +7 -0
- package/docs/snippets/tests/hover-image-yaml.mdx +17 -0
- package/docs/snippets/tests/hover-text-replay.mdx +7 -0
- package/docs/snippets/tests/hover-text-with-description-replay.mdx +7 -0
- package/docs/snippets/tests/hover-text-with-description-yaml.mdx +24 -0
- package/docs/snippets/tests/hover-text-yaml.mdx +14 -0
- package/docs/snippets/tests/match-image-replay.mdx +7 -0
- package/docs/snippets/tests/match-image-yaml.mdx +17 -0
- package/docs/snippets/tests/press-keys-replay.mdx +7 -0
- package/docs/snippets/tests/press-keys-yaml.mdx +36 -0
- package/docs/snippets/tests/remember-replay.mdx +7 -0
- package/docs/snippets/tests/remember-yaml.mdx +28 -0
- package/docs/snippets/tests/scroll-replay.mdx +7 -0
- package/docs/snippets/tests/scroll-until-image-replay.mdx +7 -0
- package/docs/snippets/tests/scroll-until-image-yaml.mdx +14 -0
- package/docs/snippets/tests/scroll-until-text-replay.mdx +7 -0
- package/docs/snippets/tests/scroll-until-text-yaml.mdx +17 -0
- package/docs/snippets/tests/scroll-yaml.mdx +30 -0
- package/docs/snippets/tests/type-repeated-replay.mdx +7 -0
- package/docs/snippets/tests/type-repeated-yaml.mdx +22 -0
- package/docs/snippets/tests/type-replay.mdx +7 -0
- package/docs/snippets/tests/type-yaml.mdx +28 -0
- package/docs/snippets/tests/wait-for-image-replay.mdx +7 -0
- package/docs/snippets/tests/wait-for-image-yaml.mdx +18 -0
- package/docs/snippets/tests/wait-for-text-replay.mdx +7 -0
- package/docs/snippets/tests/wait-for-text-yaml.mdx +18 -0
- package/docs/snippets/tests/wait-replay.mdx +7 -0
- package/docs/snippets/tests/wait-yaml.mdx +13 -0
- package/docs/styles.css +65 -0
- package/docs/v6/account/dashboard.mdx +16 -0
- package/docs/v6/account/enterprise.mdx +110 -0
- package/docs/v6/account/pricing.mdx +33 -0
- package/docs/v6/account/projects.mdx +33 -0
- package/docs/v6/account/team.mdx +35 -0
- package/docs/v6/action/ami.mdx +109 -0
- package/docs/v6/action/performance.mdx +105 -0
- package/docs/v6/action/secrets.mdx +93 -0
- package/docs/v6/apps/chrome-extensions.mdx +48 -0
- package/docs/v6/apps/desktop-apps.mdx +93 -0
- package/docs/v6/apps/mobile-apps.mdx +26 -0
- package/docs/v6/apps/static-websites.mdx +54 -0
- package/docs/v6/apps/tauri-apps.mdx +361 -0
- package/docs/v6/bugs/jira.mdx +232 -0
- package/docs/v6/cli/overview.mdx +66 -0
- package/docs/v6/commands/assert.mdx +45 -0
- package/docs/v6/commands/exec.mdx +276 -0
- package/docs/v6/commands/focus-application.mdx +44 -0
- package/docs/v6/commands/hover-image.mdx +69 -0
- package/docs/v6/commands/hover-text.mdx +47 -0
- package/docs/v6/commands/if.mdx +53 -0
- package/docs/v6/commands/match-image.mdx +67 -0
- package/docs/v6/commands/press-keys.mdx +87 -0
- package/docs/v6/commands/remember.mdx +49 -0
- package/docs/v6/commands/run.mdx +44 -0
- package/docs/v6/commands/scroll-until-image.mdx +66 -0
- package/docs/v6/commands/scroll-until-text.mdx +60 -0
- package/docs/v6/commands/scroll.mdx +69 -0
- package/docs/v6/commands/type.mdx +45 -0
- package/docs/v6/commands/wait-for-image.mdx +54 -0
- package/docs/v6/commands/wait-for-text.mdx +48 -0
- package/docs/v6/commands/wait.mdx +45 -0
- package/docs/v6/exporting/junit.mdx +218 -0
- package/docs/v6/exporting/playwright.mdx +197 -0
- package/docs/v6/features/auto-healing.mdx +144 -0
- package/docs/v6/features/generation.mdx +116 -0
- package/docs/v6/features/parallel-testing.mdx +151 -0
- package/docs/v6/features/reusable-snippets.mdx +131 -0
- package/docs/v6/features/selectorless.mdx +80 -0
- package/docs/v6/features/visual-assertions.mdx +139 -0
- package/docs/v6/getting-started/ci.mdx +146 -0
- package/docs/v6/getting-started/cli.mdx +91 -0
- package/docs/v6/getting-started/editing.mdx +100 -0
- package/docs/v6/getting-started/playwright.mdx +342 -0
- package/docs/v6/getting-started/running.mdx +48 -0
- package/docs/v6/getting-started/self-hosting.mdx +408 -0
- package/docs/v6/getting-started/vscode.mdx +88 -0
- package/docs/v6/guide/assertions.mdx +189 -0
- package/docs/v6/guide/authentication.mdx +136 -0
- package/docs/v6/guide/code.mdx +65 -0
- package/docs/v6/guide/dashcam.mdx +118 -0
- package/docs/v6/guide/environment-variables.mdx +26 -0
- package/docs/v6/guide/lifecycle.mdx +242 -0
- package/docs/v6/guide/locating.mdx +141 -0
- package/docs/v6/guide/protips.mdx +43 -0
- package/docs/v6/guide/variables.mdx +143 -0
- package/docs/v6/guide/waiting.mdx +130 -0
- package/docs/v6/importing/csv.mdx +196 -0
- package/docs/v6/importing/gherkin.mdx +143 -0
- package/docs/v6/importing/jira.mdx +164 -0
- package/docs/v6/importing/testrail.mdx +162 -0
- package/docs/v6/integrations/electron.mdx +146 -0
- package/docs/v6/integrations/netlify.mdx +100 -0
- package/docs/v6/integrations/vercel.mdx +125 -0
- package/docs/v6/interactive/explore.mdx +99 -0
- package/docs/v6/interactive/run.mdx +52 -0
- package/docs/v6/interactive/save.mdx +63 -0
- package/docs/v6/overview/comparison.mdx +101 -0
- package/docs/v6/overview/faq.mdx +162 -0
- package/docs/v6/overview/performance.mdx +52 -0
- package/docs/v6/overview/quickstart.mdx +137 -0
- package/docs/v6/overview/what-is-testdriver.mdx +85 -0
- package/docs/v6/scenarios/ai-chatbot.mdx +28 -0
- package/docs/v6/scenarios/cookie-banner.mdx +32 -0
- package/docs/v6/scenarios/file-upload.mdx +33 -0
- package/docs/v6/scenarios/form-filling.mdx +32 -0
- package/docs/v6/scenarios/log-in.mdx +75 -0
- package/docs/v6/scenarios/pdf-generation.mdx +25 -0
- package/docs/v6/scenarios/spell-check.mdx +22 -0
- package/docs/v6/security/action.mdx +84 -0
- package/docs/v6/security/agent.mdx +73 -0
- package/docs/v6/security/platform.mdx +77 -0
- package/docs/v6/tutorials/advanced-test.mdx +81 -0
- package/docs/v6/tutorials/basic-test.mdx +45 -0
- package/docs/v7/_drafts/agents.mdx +843 -0
- package/docs/v7/_drafts/architecture.mdx +399 -0
- package/docs/v7/_drafts/auto-cache-key.mdx +167 -0
- package/docs/v7/_drafts/awesome-logs-quick-ref.mdx +100 -0
- package/docs/v7/_drafts/best-practices.mdx +486 -0
- package/docs/v7/_drafts/caching-ai.mdx +215 -0
- package/docs/v7/_drafts/caching-selectors.mdx +424 -0
- package/docs/v7/_drafts/caching.mdx +366 -0
- package/docs/v7/_drafts/cli-to-sdk-migration.mdx +425 -0
- package/docs/v7/_drafts/commands/assert.mdx +45 -0
- package/docs/v7/_drafts/commands/exec.mdx +276 -0
- package/docs/v7/_drafts/commands/focus-application.mdx +44 -0
- package/docs/v7/_drafts/commands/hover-image.mdx +69 -0
- package/docs/v7/_drafts/commands/hover-text.mdx +47 -0
- package/docs/v7/_drafts/commands/if.mdx +53 -0
- package/docs/v7/_drafts/commands/match-image.mdx +67 -0
- package/docs/v7/_drafts/commands/press-keys.mdx +87 -0
- package/docs/v7/_drafts/commands/remember.mdx +49 -0
- package/docs/v7/_drafts/commands/run.mdx +44 -0
- package/docs/v7/_drafts/commands/scroll-until-image.mdx +66 -0
- package/docs/v7/_drafts/commands/scroll-until-text.mdx +60 -0
- package/docs/v7/_drafts/commands/scroll.mdx +69 -0
- package/docs/v7/_drafts/commands/type.mdx +45 -0
- package/docs/v7/_drafts/commands/wait-for-image.mdx +54 -0
- package/docs/v7/_drafts/commands/wait-for-text.mdx +48 -0
- package/docs/v7/_drafts/commands/wait.mdx +45 -0
- package/docs/v7/_drafts/configuration.mdx +378 -0
- package/docs/v7/_drafts/contributing.mdx +174 -0
- package/docs/v7/_drafts/core.mdx +458 -0
- package/docs/v7/_drafts/dashcam-title-feature.mdx +89 -0
- package/docs/v7/_drafts/debugging.mdx +349 -0
- package/docs/v7/_drafts/error-handling.mdx +501 -0
- package/docs/v7/_drafts/faq.mdx +393 -0
- package/docs/v7/_drafts/hooks.mdx +360 -0
- package/docs/v7/_drafts/init-command.mdx +95 -0
- package/docs/v7/_drafts/installation.mdx +420 -0
- package/docs/v7/_drafts/migration.mdx +562 -0
- package/docs/v7/_drafts/observable.mdx +604 -0
- package/docs/v7/_drafts/playwright.mdx +342 -0
- package/docs/v7/_drafts/plugin-migration.mdx +220 -0
- package/docs/v7/_drafts/powerful.mdx +419 -0
- package/docs/v7/_drafts/presets.mdx +210 -0
- package/docs/v7/_drafts/progressive-disclosure.mdx +230 -0
- package/docs/v7/_drafts/prompt-cache.mdx +200 -0
- package/docs/v7/_drafts/provision.mdx +390 -0
- package/docs/v7/_drafts/quick-start-test-recording.mdx +214 -0
- package/docs/v7/_drafts/readme.mdx +135 -0
- package/docs/v7/_drafts/reports.mdx +414 -0
- package/docs/v7/_drafts/scalable.mdx +763 -0
- package/docs/v7/_drafts/screenshot.mdx +155 -0
- package/docs/v7/_drafts/sdk-awesome-logs.mdx +468 -0
- package/docs/v7/_drafts/sdk-browser-rendering.mdx +167 -0
- package/docs/v7/_drafts/sdk-migration.mdx +474 -0
- package/docs/v7/_drafts/sdk-v7-complete.mdx +345 -0
- package/docs/v7/_drafts/self-hosting.mdx +369 -0
- package/docs/v7/_drafts/test-recording.mdx +382 -0
- package/docs/v7/_drafts/troubleshooting.mdx +526 -0
- package/docs/v7/_drafts/vitest-plugin.mdx +477 -0
- package/docs/v7/_drafts/vitest.mdx +535 -0
- package/docs/v7/_drafts/writing-tests.mdx +25 -0
- package/docs/v7/ai.mdx +205 -0
- package/docs/v7/assert.mdx +316 -0
- package/docs/v7/aws-setup.mdx +449 -0
- package/docs/v7/cache.mdx +223 -0
- package/docs/v7/caching.mdx +128 -0
- package/docs/v7/captcha.mdx +159 -0
- package/docs/v7/ci-cd.mdx +603 -0
- package/docs/v7/click.mdx +287 -0
- package/docs/v7/client.mdx +478 -0
- package/docs/v7/copilot/auto-healing.mdx +265 -0
- package/docs/v7/copilot/creating-tests.mdx +156 -0
- package/docs/v7/copilot/github.mdx +143 -0
- package/docs/v7/copilot/running-tests.mdx +149 -0
- package/docs/v7/copilot/setup.mdx +143 -0
- package/docs/v7/customizing-devices.mdx +319 -0
- package/docs/v7/dashcam.mdx +419 -0
- package/docs/v7/debugging-with-screenshots.mdx +402 -0
- package/docs/v7/device-config.mdx +317 -0
- package/docs/v7/double-click.mdx +102 -0
- package/docs/v7/elements.mdx +606 -0
- package/docs/v7/enterprise.mdx +9 -0
- package/docs/v7/errors.mdx +248 -0
- package/docs/v7/events.mdx +358 -0
- package/docs/v7/examples/ai.mdx +72 -0
- package/docs/v7/examples/assert.mdx +72 -0
- package/docs/v7/examples/captcha-api.mdx +92 -0
- package/docs/v7/examples/chrome-extension.mdx +132 -0
- package/docs/v7/examples/drag-and-drop.mdx +100 -0
- package/docs/v7/examples/element-not-found.mdx +67 -0
- package/docs/v7/examples/exec-output.mdx +85 -0
- package/docs/v7/examples/exec-pwsh.mdx +83 -0
- package/docs/v7/examples/focus-window.mdx +62 -0
- package/docs/v7/examples/hover-image.mdx +94 -0
- package/docs/v7/examples/hover-text.mdx +69 -0
- package/docs/v7/examples/installer.mdx +91 -0
- package/docs/v7/examples/launch-vscode-linux.mdx +101 -0
- package/docs/v7/examples/match-image.mdx +96 -0
- package/docs/v7/examples/press-keys.mdx +92 -0
- package/docs/v7/examples/scroll-keyboard.mdx +79 -0
- package/docs/v7/examples/scroll-until-image.mdx +81 -0
- package/docs/v7/examples/scroll-until-text.mdx +109 -0
- package/docs/v7/examples/scroll.mdx +81 -0
- package/docs/v7/examples/type.mdx +92 -0
- package/docs/v7/examples/windows-installer.mdx +89 -0
- package/docs/v7/exec.mdx +318 -0
- package/docs/v7/find.mdx +830 -0
- package/docs/v7/focus-application.mdx +294 -0
- package/docs/v7/generating-tests.mdx +36 -0
- package/docs/v7/hosted.mdx +158 -0
- package/docs/v7/hover.mdx +279 -0
- package/docs/v7/locating-elements.mdx +71 -0
- package/docs/v7/making-assertions.mdx +32 -0
- package/docs/v7/mcp.mdx +9 -0
- package/docs/v7/mouse-down.mdx +161 -0
- package/docs/v7/mouse-up.mdx +164 -0
- package/docs/v7/parse.mdx +237 -0
- package/docs/v7/performing-actions.mdx +54 -0
- package/docs/v7/press-keys.mdx +349 -0
- package/docs/v7/provision.mdx +333 -0
- package/docs/v7/quickstart.mdx +173 -0
- package/docs/v7/redraw.mdx +216 -0
- package/docs/v7/reusable-code.mdx +249 -0
- package/docs/v7/right-click.mdx +123 -0
- package/docs/v7/running-tests.mdx +185 -0
- package/docs/v7/screenshot.mdx +249 -0
- package/docs/v7/screenshots.mdx +186 -0
- package/docs/v7/scroll.mdx +336 -0
- package/docs/v7/secrets.mdx +115 -0
- package/docs/v7/self-hosted.mdx +149 -0
- package/docs/v7/type.mdx +358 -0
- package/docs/v7/variables.mdx +111 -0
- package/docs/v7/wait.mdx +52 -0
- package/docs/v7/waiting-for-elements.mdx +90 -0
- package/docs/v7/what-is-testdriver.mdx +54 -0
- package/eslint.config.js +67 -0
- package/examples/ai.test.mjs +31 -0
- package/examples/assert.test.mjs +47 -0
- package/examples/chrome-extension.test.mjs +97 -0
- package/examples/config.mjs +5 -0
- package/examples/element-not-found.test.mjs +27 -0
- package/examples/exec-output.test.mjs +60 -0
- package/examples/exec-pwsh.test.mjs +58 -0
- package/examples/findall-coffee-icons.test.mjs +42 -0
- package/examples/focus-window.test.mjs +37 -0
- package/examples/formatted-logging.test.mjs +27 -0
- package/examples/hover-image.test.mjs +53 -0
- package/examples/hover-text-with-description.test.mjs +57 -0
- package/examples/hover-text.test.mjs +28 -0
- package/examples/installer.test.mjs +50 -0
- package/examples/launch-vscode-linux.test.mjs +55 -0
- package/examples/match-image.test.mjs +55 -0
- package/examples/parse.test.mjs +19 -0
- package/examples/press-keys.test.mjs +44 -0
- package/examples/prompt.test.mjs +34 -0
- package/examples/scroll-keyboard.test.mjs +38 -0
- package/examples/scroll-until-image.test.mjs +40 -0
- package/examples/scroll.test.mjs +42 -0
- package/examples/type.test.mjs +46 -0
- package/examples/windows-installer.test.mjs +54 -0
- package/index.js +2 -0
- package/interfaces/cli/commands/init.js +438 -0
- package/interfaces/cli/commands/setup.js +382 -0
- package/interfaces/cli/lib/base.js +285 -0
- package/interfaces/cli.js +20 -0
- package/interfaces/junit-reporter.js +290 -0
- package/interfaces/logger.js +388 -0
- package/interfaces/readline.js +234 -0
- package/interfaces/shared-test-state.mjs +64 -0
- package/interfaces/vitest-plugin.d.ts +115 -0
- package/interfaces/vitest-plugin.mjs +1698 -0
- package/lib/captcha/solver.js +358 -0
- package/lib/core/Dashcam.js +533 -0
- package/lib/core/index.d.ts +172 -0
- package/lib/core/index.js +12 -0
- package/lib/environments.json +18 -0
- package/lib/github-comment-formatter.js +263 -0
- package/lib/github-comment.mjs +452 -0
- package/lib/init-project.js +575 -0
- package/lib/presets/index.mjs +331 -0
- package/lib/resolve-channel.js +46 -0
- package/lib/sentry.js +417 -0
- package/lib/vitest/hooks.d.ts +57 -0
- package/lib/vitest/hooks.mjs +674 -0
- package/lib/vitest/setup-aws.mjs +247 -0
- package/lib/vitest/setup-self-hosted.mjs +151 -0
- package/lib/vitest/setup.mjs +46 -0
- package/manual/captcha-api.test.mjs +51 -0
- package/manual/drag-and-drop.test.mjs +59 -0
- package/manual/flake-diffthreshold-001.test.mjs +9 -0
- package/manual/flake-diffthreshold-01.test.mjs +9 -0
- package/manual/flake-diffthreshold-05.test.mjs +9 -0
- package/manual/flake-noredraw-cache.test.mjs +9 -0
- package/manual/flake-noredraw-nocache.test.mjs +9 -0
- package/manual/flake-redraw-cache.test.mjs +9 -0
- package/manual/flake-redraw-nocache.test.mjs +9 -0
- package/manual/flake-rocket-match.test.mjs +30 -0
- package/manual/flake-shared.mjs +51 -0
- package/manual/no-provision.test.mjs +31 -0
- package/manual/packer-hover-image.test.mjs +176 -0
- package/manual/scroll-until-text.test.mjs +68 -0
- package/manual/test-init-command.js +223 -0
- package/mcp-server/README.md +322 -0
- package/mcp-server/dist/codegen.d.ts +9 -0
- package/mcp-server/dist/codegen.js +165 -0
- package/mcp-server/dist/mcp-app.html +114 -0
- package/mcp-server/dist/package.json +1 -0
- package/mcp-server/dist/provision-types.d.ts +290 -0
- package/mcp-server/dist/provision-types.js +174 -0
- package/mcp-server/dist/server.d.ts +6 -0
- package/mcp-server/dist/server.mjs +1925 -0
- package/mcp-server/dist/session.d.ts +85 -0
- package/mcp-server/dist/session.js +152 -0
- package/mcp-server/mcp-app.html +28 -0
- package/mcp-server/mcp-config.example.json +19 -0
- package/mcp-server/package-lock.json +4027 -0
- package/mcp-server/package.json +31 -0
- package/mcp-server/src/codegen.ts +189 -0
- package/mcp-server/src/mcp-app.css +360 -0
- package/mcp-server/src/mcp-app.ts +547 -0
- package/mcp-server/src/provision-types.ts +209 -0
- package/mcp-server/src/server.ts +2391 -0
- package/mcp-server/src/session.ts +194 -0
- package/mcp-server/tsconfig.json +16 -0
- package/mcp-server/vite.config.ts +23 -0
- package/package.json +158 -0
- package/schema.json +1046 -0
- package/scripts/generate-skills.js +94 -0
- package/sdk-log-formatter.js +1157 -0
- package/sdk.d.ts +1486 -0
- package/sdk.js +4336 -0
- package/setup/aws/cloudformation.yaml +463 -0
- package/setup/aws/disable-defender.sh +42 -0
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +289 -0
- package/test/captcha-solver.test.mjs +152 -0
- package/test/chrome-remote-debugging.test.mjs +66 -0
- package/test/duckduckgo/experiment.test.mjs +28 -0
- package/test/duckduckgo/setup.test.mjs +29 -0
- package/test/manual/debug-locate-response.js +82 -0
- package/test/manual/reconnect-provision.test.mjs +49 -0
- package/test/manual/test-console-logs.test.mjs +42 -0
- package/test/manual/test-find-api.js +73 -0
- package/test/manual/test-init.sh +54 -0
- package/test/manual/test-prompt-cache.js +97 -0
- package/test/manual/test-provision-auth.mjs +22 -0
- package/test/manual/test-sandbox-render.js +29 -0
- package/test/manual/test-sdk-methods.js +15 -0
- package/test/manual/test-sdk-refactor.js +53 -0
- package/test/manual/test-stack-trace.mjs +57 -0
- package/test/manual/verify-element-api.js +89 -0
- package/test/manual/verify-types.js +0 -0
- package/test/manual-unawaited-promise.test.mjs +31 -0
- package/vitest.config.mjs +58 -0
- package/vitest.runner.config.mjs +33 -0
- package/vscode-extension/.vscodeignore +12 -0
- package/vscode-extension/README.md +94 -0
- package/vscode-extension/media/icon.png +0 -0
- package/vscode-extension/package-lock.json +4126 -0
- package/vscode-extension/package.json +86 -0
- package/vscode-extension/src/extension.ts +829 -0
- package/vscode-extension/testdriverai-0.1.0.vsix +0 -0
- package/vscode-extension/tsconfig.json +16 -0
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
const Ably = require("ably");
|
|
2
|
+
const axios = require("axios");
|
|
3
|
+
const { events } = require("../events");
|
|
4
|
+
const logger = require("./logger");
|
|
5
|
+
const { version } = require("../../package.json");
|
|
6
|
+
const { withRetry, getSentryTraceHeaders } = require("./sdk");
|
|
7
|
+
const sentry = require("../../lib/sentry");
|
|
8
|
+
|
|
9
|
+
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
10
|
+
class Sandbox {
|
|
11
|
+
constructor() {
|
|
12
|
+
this._ably = null;
|
|
13
|
+
this._sessionChannel = null;
|
|
14
|
+
this._channelName = null;
|
|
15
|
+
this.ps = {};
|
|
16
|
+
this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
|
|
17
|
+
this.heartbeat = null;
|
|
18
|
+
this.apiSocketConnected = false;
|
|
19
|
+
this.instanceSocketConnected = false;
|
|
20
|
+
this.authenticated = false;
|
|
21
|
+
this.instance = null;
|
|
22
|
+
this.messageId = 0;
|
|
23
|
+
this.uniqueId = Math.random().toString(36).substring(7);
|
|
24
|
+
this.os = null;
|
|
25
|
+
this.sessionInstance = sessionInstance;
|
|
26
|
+
this.traceId = null;
|
|
27
|
+
this.apiRoot = null;
|
|
28
|
+
this.apiKey = null;
|
|
29
|
+
this._lastConnectParams = null;
|
|
30
|
+
this._teamId = null;
|
|
31
|
+
this._sandboxId = null;
|
|
32
|
+
|
|
33
|
+
// Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
|
|
34
|
+
this._publishLastTime = 0;
|
|
35
|
+
this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
|
|
36
|
+
this._publishCount = 0;
|
|
37
|
+
this._publishWindowStart = Date.now();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getTraceId() {
|
|
41
|
+
return this.traceId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getTraceUrl() {
|
|
45
|
+
if (!this.traceId) return null;
|
|
46
|
+
return (
|
|
47
|
+
"https://testdriver.sentry.io/explore/traces/trace/" + this.traceId
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async _initAbly(ablyToken, channelName) {
|
|
52
|
+
if (this._ably) {
|
|
53
|
+
try {
|
|
54
|
+
this._ably.close();
|
|
55
|
+
} catch (e) {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this._channelName = channelName;
|
|
60
|
+
var self = this;
|
|
61
|
+
|
|
62
|
+
this._ably = new Ably.Realtime({
|
|
63
|
+
authCallback: function (tokenParams, callback) {
|
|
64
|
+
callback(null, ablyToken);
|
|
65
|
+
},
|
|
66
|
+
clientId: "sdk-" + this._sandboxId,
|
|
67
|
+
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
68
|
+
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
logger.log(`[ably] Connecting as sdk-${this._sandboxId}...`);
|
|
72
|
+
|
|
73
|
+
await new Promise(function (resolve, reject) {
|
|
74
|
+
self._ably.connection.on("connected", resolve);
|
|
75
|
+
self._ably.connection.on("failed", function () {
|
|
76
|
+
reject(new Error("Ably connection failed"));
|
|
77
|
+
});
|
|
78
|
+
setTimeout(function () {
|
|
79
|
+
reject(new Error("Ably connection timeout"));
|
|
80
|
+
}, 30000);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this._sessionChannel = this._ably.channels.get(channelName);
|
|
84
|
+
|
|
85
|
+
logger.log(`[ably] Channel initialized: ${channelName}`);
|
|
86
|
+
|
|
87
|
+
// Enter presence on the session channel so the API can count connected SDK clients
|
|
88
|
+
try {
|
|
89
|
+
await this._sessionChannel.presence.enter({
|
|
90
|
+
sandboxId: this._sandboxId,
|
|
91
|
+
connectedAt: Date.now(),
|
|
92
|
+
});
|
|
93
|
+
logger.log(`[ably] Entered presence on session channel (sandbox=${this._sandboxId})`);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Non-fatal — presence is used for concurrency counting, not critical path
|
|
96
|
+
logger.warn("Failed to enter presence on session channel: " + (e.message || e));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._sessionChannel.subscribe("response", function (msg) {
|
|
100
|
+
var message = msg.data;
|
|
101
|
+
if (!message) return;
|
|
102
|
+
|
|
103
|
+
logger.log(`[ably] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
104
|
+
|
|
105
|
+
if (message.type === "sandbox.progress") {
|
|
106
|
+
emitter.emit(events.sandbox.progress, {
|
|
107
|
+
step: message.step,
|
|
108
|
+
message: message.message,
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
message.type === "before.file" ||
|
|
115
|
+
message.type === "after.file" ||
|
|
116
|
+
message.type === "screenshot.file"
|
|
117
|
+
) {
|
|
118
|
+
emitter.emit(events.sandbox.file, message);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Streaming exec output chunks — accumulate per requestId so the
|
|
123
|
+
// full stdout can be reconstructed when the final response arrives.
|
|
124
|
+
// (The runner streams stdout in ~16KB chunks and omits it from the
|
|
125
|
+
// final response to stay under Ably's 64KB message limit.)
|
|
126
|
+
if (message.type === "exec.output") {
|
|
127
|
+
if (message.requestId) {
|
|
128
|
+
if (!self._execBuffers[message.requestId]) {
|
|
129
|
+
self._execBuffers[message.requestId] = '';
|
|
130
|
+
}
|
|
131
|
+
self._execBuffers[message.requestId] += (message.chunk || '');
|
|
132
|
+
}
|
|
133
|
+
emitter.emit(events.exec.output, { chunk: message.chunk, requestId: message.requestId });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Runner debug logs — only received when debug mode is enabled
|
|
138
|
+
if (message.type === "runner.log") {
|
|
139
|
+
var logLevel = message.level || "info";
|
|
140
|
+
var logMsg = "[runner] " + (message.message || "");
|
|
141
|
+
if (logLevel === "error") {
|
|
142
|
+
logger.error(logMsg);
|
|
143
|
+
} else {
|
|
144
|
+
logger.log(logMsg);
|
|
145
|
+
}
|
|
146
|
+
emitter.emit(events.runner.log, {
|
|
147
|
+
level: logLevel,
|
|
148
|
+
message: message.message,
|
|
149
|
+
timestamp: message.timestamp,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!message.requestId || !self.ps[message.requestId]) {
|
|
155
|
+
var debugMode =
|
|
156
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
157
|
+
if (debugMode) {
|
|
158
|
+
console.warn(
|
|
159
|
+
"No pending promise found for requestId:",
|
|
160
|
+
message.requestId,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (message.error) {
|
|
167
|
+
var pendingMessage =
|
|
168
|
+
self.ps[message.requestId] &&
|
|
169
|
+
self.ps[message.requestId].message;
|
|
170
|
+
if (!pendingMessage || pendingMessage.type !== "output") {
|
|
171
|
+
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
172
|
+
}
|
|
173
|
+
var error = new Error(message.errorMessage || "Sandbox error");
|
|
174
|
+
error.responseData = message;
|
|
175
|
+
delete self._execBuffers[message.requestId];
|
|
176
|
+
self.ps[message.requestId].reject(error);
|
|
177
|
+
} else {
|
|
178
|
+
emitter.emit(events.sandbox.received);
|
|
179
|
+
if (self.ps[message.requestId]) {
|
|
180
|
+
// Unwrap the result from the Ably response envelope
|
|
181
|
+
// The runner sends { requestId, type, result, success }
|
|
182
|
+
// But SDK commands expect just the result object
|
|
183
|
+
var resolvedValue = message.result !== undefined ? message.result : message;
|
|
184
|
+
|
|
185
|
+
// For exec (commands.run): the runner streams stdout via exec.output
|
|
186
|
+
// chunks and sends only returncode+stderr in the final response.
|
|
187
|
+
// Reconstruct stdout from the accumulated buffer.
|
|
188
|
+
var streamedStdout = self._execBuffers[message.requestId];
|
|
189
|
+
if (streamedStdout !== undefined && resolvedValue && resolvedValue.out) {
|
|
190
|
+
resolvedValue.out.stdout = streamedStdout;
|
|
191
|
+
}
|
|
192
|
+
delete self._execBuffers[message.requestId];
|
|
193
|
+
|
|
194
|
+
self.ps[message.requestId].resolve(resolvedValue);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
delete self.ps[message.requestId];
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
this._sessionChannel.subscribe("file", function (msg) {
|
|
201
|
+
var message = msg.data;
|
|
202
|
+
if (!message) return;
|
|
203
|
+
logger.log(`[ably] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
204
|
+
if (message.requestId && self.ps[message.requestId]) {
|
|
205
|
+
emitter.emit(events.sandbox.received);
|
|
206
|
+
self.ps[message.requestId].resolve(message);
|
|
207
|
+
delete self.ps[message.requestId];
|
|
208
|
+
}
|
|
209
|
+
emitter.emit(events.sandbox.file, message);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
this.heartbeat = setInterval(function () {}, 5000);
|
|
213
|
+
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
214
|
+
|
|
215
|
+
// ─── Periodic stats logging ────────────────────────────────────────
|
|
216
|
+
this._statsInterval = setInterval(() => {
|
|
217
|
+
const connState = this._ably ? this._ably.connection.state : 'no-client';
|
|
218
|
+
const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
|
|
219
|
+
const pending = Object.keys(this.ps).length;
|
|
220
|
+
logger.log(`[ably][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
|
|
221
|
+
}, 10000);
|
|
222
|
+
if (this._statsInterval.unref) this._statsInterval.unref();
|
|
223
|
+
|
|
224
|
+
this._ably.connection.on("disconnected", function () {
|
|
225
|
+
logger.log("[ably] Connection: disconnected - will auto-reconnect");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
this._ably.connection.on("connected", function () {
|
|
229
|
+
// Log reconnection so the user knows the blip was recovered
|
|
230
|
+
logger.log("[ably] Connection: reconnected");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
this._ably.connection.on("suspended", function () {
|
|
234
|
+
logger.warn("[ably] Connection: suspended - connection lost for extended period, will keep retrying");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
this._ably.connection.on("failed", function () {
|
|
238
|
+
logger.error("[ably] Connection: failed");
|
|
239
|
+
self.apiSocketConnected = false;
|
|
240
|
+
self.instanceSocketConnected = false;
|
|
241
|
+
emitter.emit(events.error.sandbox, "Ably connection failed");
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* POST to the API with retry for transient network errors (via withRetry)
|
|
247
|
+
* and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
|
|
248
|
+
* testTimeout kills the test).
|
|
249
|
+
*/
|
|
250
|
+
async _httpPostWithConcurrencyRetry(path, body, timeout) {
|
|
251
|
+
var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
|
|
252
|
+
var startTime = Date.now();
|
|
253
|
+
var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
|
|
254
|
+
|
|
255
|
+
var self = this;
|
|
256
|
+
var makeRequest = function () {
|
|
257
|
+
return axios({
|
|
258
|
+
method: "post",
|
|
259
|
+
url: self.apiRoot + path,
|
|
260
|
+
data: body,
|
|
261
|
+
headers: {
|
|
262
|
+
"Content-Type": "application/json",
|
|
263
|
+
"User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
|
|
264
|
+
...getSentryTraceHeaders(sessionId),
|
|
265
|
+
},
|
|
266
|
+
timeout: timeout || 120000,
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
while (true) {
|
|
271
|
+
try {
|
|
272
|
+
var response = await withRetry(makeRequest, {
|
|
273
|
+
retryConfig: {
|
|
274
|
+
maxRetries: 3,
|
|
275
|
+
baseDelayMs: 2000,
|
|
276
|
+
retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
|
|
277
|
+
},
|
|
278
|
+
onRetry: function (attempt, error, delayMs) {
|
|
279
|
+
var elapsed = Date.now() - startTime;
|
|
280
|
+
logger.warn(
|
|
281
|
+
"Transient network error: " + (error.message || error.code) +
|
|
282
|
+
" — POST " + path +
|
|
283
|
+
" — retry " + attempt + "/3" +
|
|
284
|
+
" in " + (delayMs / 1000).toFixed(1) + "s" +
|
|
285
|
+
" (" + Math.round(elapsed / 1000) + "s elapsed)...",
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
return response.data;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
// Concurrency limit — poll forever until a slot opens
|
|
292
|
+
var responseData = err.response && err.response.data;
|
|
293
|
+
if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
|
|
294
|
+
var elapsed = Date.now() - startTime;
|
|
295
|
+
logger.log(
|
|
296
|
+
"Concurrency limit reached — waiting " +
|
|
297
|
+
concurrencyRetryInterval / 1000 +
|
|
298
|
+
"s for a slot to become available (" +
|
|
299
|
+
Math.round(elapsed / 1000) +
|
|
300
|
+
"s elapsed)...",
|
|
301
|
+
);
|
|
302
|
+
await new Promise(function (resolve) {
|
|
303
|
+
var t = setTimeout(resolve, concurrencyRetryInterval);
|
|
304
|
+
if (t.unref) t.unref();
|
|
305
|
+
});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Non-retryable HTTP error — preserve responseData for callers
|
|
310
|
+
if (responseData) {
|
|
311
|
+
var httpErr = new Error(
|
|
312
|
+
responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
|
|
313
|
+
);
|
|
314
|
+
httpErr.responseData = responseData;
|
|
315
|
+
throw httpErr;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
send(message, timeout) {
|
|
324
|
+
if (timeout === undefined) timeout = 300000;
|
|
325
|
+
if (message.type === "create" || message.type === "direct") {
|
|
326
|
+
return this._sendHttp(message, timeout);
|
|
327
|
+
}
|
|
328
|
+
return this._sendAbly(message, timeout);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async _sendHttp(message, timeout) {
|
|
332
|
+
var sessionId = this.sessionInstance
|
|
333
|
+
? this.sessionInstance.get()
|
|
334
|
+
: null;
|
|
335
|
+
var body = {
|
|
336
|
+
apiKey: this.apiKey,
|
|
337
|
+
version: version,
|
|
338
|
+
os: message.os || this.os,
|
|
339
|
+
session: sessionId,
|
|
340
|
+
apiRoot: this.apiRoot,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (message.type === "create") {
|
|
344
|
+
body.os = message.os || this.os || "linux";
|
|
345
|
+
body.resolution = message.resolution;
|
|
346
|
+
body.ci = message.ci;
|
|
347
|
+
if (message.ami) body.ami = message.ami;
|
|
348
|
+
if (message.instanceType) body.instanceType = message.instanceType;
|
|
349
|
+
if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (message.type === "direct") {
|
|
353
|
+
body.ip = message.ip;
|
|
354
|
+
body.resolution = message.resolution;
|
|
355
|
+
body.ci = message.ci;
|
|
356
|
+
if (message.instanceId) body.instanceId = message.instanceId;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
var reply = await this._httpPostWithConcurrencyRetry(
|
|
360
|
+
"/api/v7/sandbox/authenticate",
|
|
361
|
+
body,
|
|
362
|
+
timeout,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (!reply.success) {
|
|
366
|
+
var err = new Error(
|
|
367
|
+
reply.errorMessage || "Failed to allocate sandbox",
|
|
368
|
+
);
|
|
369
|
+
err.responseData = reply;
|
|
370
|
+
throw err;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this._sandboxId = reply.sandboxId;
|
|
374
|
+
this._teamId = reply.teamId;
|
|
375
|
+
|
|
376
|
+
if (reply.ably && reply.ably.token) {
|
|
377
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
378
|
+
this.instanceSocketConnected = true;
|
|
379
|
+
|
|
380
|
+
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
381
|
+
var debugMode =
|
|
382
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
383
|
+
if (debugMode && this._sessionChannel) {
|
|
384
|
+
this._sessionChannel.publish("control", {
|
|
385
|
+
type: "debug",
|
|
386
|
+
enabled: true,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (message.type === "create") {
|
|
392
|
+
// E2B (Linux) sandboxes: the API proxies commands and returns a url directly.
|
|
393
|
+
// No runner agent involved — skip runner.ready wait.
|
|
394
|
+
if (reply.url) {
|
|
395
|
+
logger.log(`E2B sandbox ready — url=${reply.url}`);
|
|
396
|
+
return {
|
|
397
|
+
success: true,
|
|
398
|
+
sandbox: {
|
|
399
|
+
sandboxId: reply.sandboxId,
|
|
400
|
+
instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
|
|
401
|
+
os: body.os || 'linux',
|
|
402
|
+
url: reply.url,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const runnerIp = reply.runner && reply.runner.ip;
|
|
408
|
+
const noVncPort = reply.runner && reply.runner.noVncPort;
|
|
409
|
+
const runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
410
|
+
|
|
411
|
+
logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
|
|
412
|
+
|
|
413
|
+
// For cloud Windows sandboxes (no runner in reply), wait for the
|
|
414
|
+
// agent to signal readiness before sending commands. Without this
|
|
415
|
+
// gate, commands published before the agent subscribes are lost.
|
|
416
|
+
var self = this;
|
|
417
|
+
if (!reply.runner && this._sessionChannel) {
|
|
418
|
+
logger.log('Waiting for runner agent to signal readiness...');
|
|
419
|
+
var readyTimeout = 120000; // 120s — allows for EC2 boot + agent startup
|
|
420
|
+
await new Promise(function (resolve, reject) {
|
|
421
|
+
var resolved = false;
|
|
422
|
+
function finish(data) {
|
|
423
|
+
if (resolved) return;
|
|
424
|
+
resolved = true;
|
|
425
|
+
clearTimeout(timer);
|
|
426
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
427
|
+
// Update runner info if provided
|
|
428
|
+
if (data && data.os) reply.runner = reply.runner || {};
|
|
429
|
+
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
430
|
+
if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
|
|
431
|
+
if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
|
|
432
|
+
logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
433
|
+
if (data && data.update) {
|
|
434
|
+
var u = data.update;
|
|
435
|
+
if (u.status === 'up-to-date') {
|
|
436
|
+
logger.log('Runner is up to date (v' + u.localVersion + ')');
|
|
437
|
+
} else if (u.status === 'updated') {
|
|
438
|
+
logger.log('Runner was auto-updated: v' + u.localVersion + ' \u2192 v' + u.remoteVersion);
|
|
439
|
+
} else if (u.status === 'available:major') {
|
|
440
|
+
logger.warn('Runner update available but not auto-installed (major/minor): v' + u.localVersion + ' \u2192 v' + u.remoteVersion);
|
|
441
|
+
} else if (u.status && u.status.startsWith('error:')) {
|
|
442
|
+
logger.warn('Runner update check failed: ' + u.status.slice(6));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
resolve();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
var timer = setTimeout(function () {
|
|
449
|
+
if (!resolved) {
|
|
450
|
+
resolved = true;
|
|
451
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
452
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
|
|
453
|
+
sentry.captureException(err, {
|
|
454
|
+
tags: { phase: 'runner_ready', connection_type: 'create' },
|
|
455
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
|
|
456
|
+
});
|
|
457
|
+
reject(err);
|
|
458
|
+
}
|
|
459
|
+
}, readyTimeout);
|
|
460
|
+
if (timer.unref) timer.unref();
|
|
461
|
+
|
|
462
|
+
// Listen for live runner.ready messages
|
|
463
|
+
var onCtrl;
|
|
464
|
+
onCtrl = function (msg) {
|
|
465
|
+
var data = msg.data;
|
|
466
|
+
if (data && data.type === 'runner.ready') {
|
|
467
|
+
finish(data);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
471
|
+
|
|
472
|
+
// Also check channel history in case runner.ready was published
|
|
473
|
+
// before we subscribed (race condition on fast-booting agents).
|
|
474
|
+
try {
|
|
475
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
476
|
+
if (err) {
|
|
477
|
+
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (page && page.items) {
|
|
481
|
+
for (var i = 0; i < page.items.length; i++) {
|
|
482
|
+
var item = page.items[i];
|
|
483
|
+
if (item.name === 'control' && item.data && item.data.type === 'runner.ready') {
|
|
484
|
+
logger.log('Found runner.ready in channel history');
|
|
485
|
+
finish(item.data);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
} catch (histErr) {
|
|
492
|
+
logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
497
|
+
// Fall back to constructing from ip + noVncPort for older runners.
|
|
498
|
+
let url;
|
|
499
|
+
if (runnerVncUrl) {
|
|
500
|
+
url = runnerVncUrl;
|
|
501
|
+
logger.log(`Using runner-provided vncUrl: ${url}`);
|
|
502
|
+
} else if (runnerIp && noVncPort) {
|
|
503
|
+
url = `http://${runnerIp}:${noVncPort}/vnc_lite.html?token=V3b8wG9`;
|
|
504
|
+
logger.log(`noVNC URL constructed from runner ip+port: ${url}`);
|
|
505
|
+
} else if (runnerIp) {
|
|
506
|
+
url = "http://" + runnerIp;
|
|
507
|
+
logger.warn(`Runner did not report noVNC port — using bare IP: ${url}`);
|
|
508
|
+
} else {
|
|
509
|
+
logger.warn('Runner has no IP — preview will not be available');
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
success: true,
|
|
513
|
+
sandbox: {
|
|
514
|
+
sandboxId: reply.sandboxId,
|
|
515
|
+
instanceId: reply.sandboxId,
|
|
516
|
+
os: reply.runner?.os || body.os,
|
|
517
|
+
ip: runnerIp,
|
|
518
|
+
url: url,
|
|
519
|
+
vncPort: noVncPort || undefined,
|
|
520
|
+
runner: reply.runner,
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (message.type === "direct") {
|
|
526
|
+
// If the API returned agent config and we have an instanceId,
|
|
527
|
+
// provision the config to the instance via SSM (client-side).
|
|
528
|
+
// This runs from the user's infrastructure where AWS permissions exist,
|
|
529
|
+
// rather than from the API server.
|
|
530
|
+
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
531
|
+
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
532
|
+
var instanceId = message.instanceId;
|
|
533
|
+
if (reply.agentConfig && instanceId) {
|
|
534
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
|
|
535
|
+
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
536
|
+
logger.log('Agent config provisioned successfully.');
|
|
537
|
+
} else if (reply.agentConfig && !instanceId) {
|
|
538
|
+
logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// If the API returned agent credentials (reply.agent present),
|
|
542
|
+
// wait for the runner agent to signal readiness before sending commands.
|
|
543
|
+
// Without this gate, commands published before the agent subscribes are lost.
|
|
544
|
+
var self = this;
|
|
545
|
+
if (reply.agent && this._sessionChannel) {
|
|
546
|
+
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
547
|
+
var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
|
|
548
|
+
await new Promise(function (resolve, reject) {
|
|
549
|
+
var resolved = false;
|
|
550
|
+
function finish(data) {
|
|
551
|
+
if (resolved) return;
|
|
552
|
+
resolved = true;
|
|
553
|
+
clearTimeout(timer);
|
|
554
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
555
|
+
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
556
|
+
if (data && data.update) {
|
|
557
|
+
var u = data.update;
|
|
558
|
+
if (u.status === 'up-to-date') {
|
|
559
|
+
logger.log('Runner is up to date (v' + u.localVersion + ')');
|
|
560
|
+
} else if (u.status === 'updated') {
|
|
561
|
+
logger.log('Runner was auto-updated: v' + u.localVersion + ' \u2192 v' + u.remoteVersion);
|
|
562
|
+
} else if (u.status === 'available:major') {
|
|
563
|
+
logger.warn('Runner update available but not auto-installed (major/minor): v' + u.localVersion + ' \u2192 v' + u.remoteVersion);
|
|
564
|
+
} else if (u.status && u.status.startsWith('error:')) {
|
|
565
|
+
logger.warn('Runner update check failed: ' + u.status.slice(6));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
resolve();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
var timer = setTimeout(function () {
|
|
572
|
+
if (!resolved) {
|
|
573
|
+
resolved = true;
|
|
574
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
575
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
|
|
576
|
+
sentry.captureException(err, {
|
|
577
|
+
tags: { phase: 'runner_ready', connection_type: 'direct' },
|
|
578
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
|
|
579
|
+
});
|
|
580
|
+
reject(err);
|
|
581
|
+
}
|
|
582
|
+
}, readyTimeout);
|
|
583
|
+
if (timer.unref) timer.unref();
|
|
584
|
+
|
|
585
|
+
// Listen for live runner.ready messages
|
|
586
|
+
var onCtrl;
|
|
587
|
+
onCtrl = function (msg) {
|
|
588
|
+
var data = msg.data;
|
|
589
|
+
if (data && data.type === 'runner.ready') {
|
|
590
|
+
finish(data);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
594
|
+
|
|
595
|
+
// Also check channel history in case runner.ready was published
|
|
596
|
+
// before we subscribed (race condition on fast-booting agents).
|
|
597
|
+
try {
|
|
598
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
599
|
+
if (err) {
|
|
600
|
+
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (page && page.items) {
|
|
604
|
+
for (var i = 0; i < page.items.length; i++) {
|
|
605
|
+
var item = page.items[i];
|
|
606
|
+
if (item.name === 'control' && item.data && item.data.type === 'runner.ready') {
|
|
607
|
+
logger.log('Found runner.ready in channel history (direct)');
|
|
608
|
+
finish(item.data);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
} catch (histErr) {
|
|
615
|
+
logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Construct VNC URL — use port 8080 (nginx noVNC proxy) for Windows instances
|
|
621
|
+
var directUrl = message.ip ? "http://" + message.ip + ":8080/vnc_lite.html?token=V3b8wG9" : undefined;
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
success: true,
|
|
625
|
+
instance: {
|
|
626
|
+
instanceId: reply.sandboxId,
|
|
627
|
+
sandboxId: reply.sandboxId,
|
|
628
|
+
ip: message.ip,
|
|
629
|
+
url: directUrl || "http://" + message.ip,
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return reply;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
_sendAbly(message, timeout) {
|
|
638
|
+
if (timeout === undefined) timeout = 300000;
|
|
639
|
+
|
|
640
|
+
if (!this._sessionChannel || !this._ably) {
|
|
641
|
+
return Promise.reject(
|
|
642
|
+
new Error("Sandbox not connected (no Ably client)"),
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// If temporarily disconnected, wait up to 30s for reconnection
|
|
647
|
+
// instead of failing immediately (dashcam uploads can cause brief blips)
|
|
648
|
+
var self = this;
|
|
649
|
+
var connState = this._ably.connection.state;
|
|
650
|
+
if (connState !== "connected") {
|
|
651
|
+
if (connState === "disconnected" || connState === "connecting" || connState === "suspended") {
|
|
652
|
+
logger.log("Ably is " + connState + ", waiting for reconnect before sending...");
|
|
653
|
+
var waitForConnect = new Promise(function (resolve, reject) {
|
|
654
|
+
var timer = setTimeout(function () {
|
|
655
|
+
self._ably.connection.off("connected", onConnected);
|
|
656
|
+
self._ably.connection.off("failed", onFailed);
|
|
657
|
+
reject(new Error("Sandbox not connected after waiting 30s (state: " + self._ably.connection.state + ")"));
|
|
658
|
+
}, 30000);
|
|
659
|
+
if (timer.unref) timer.unref();
|
|
660
|
+
function onConnected() {
|
|
661
|
+
clearTimeout(timer);
|
|
662
|
+
self._ably.connection.off("failed", onFailed);
|
|
663
|
+
resolve();
|
|
664
|
+
}
|
|
665
|
+
function onFailed() {
|
|
666
|
+
clearTimeout(timer);
|
|
667
|
+
self._ably.connection.off("connected", onConnected);
|
|
668
|
+
reject(new Error("Ably connection failed while waiting to send"));
|
|
669
|
+
}
|
|
670
|
+
self._ably.connection.once("connected", onConnected);
|
|
671
|
+
self._ably.connection.once("failed", onFailed);
|
|
672
|
+
});
|
|
673
|
+
return waitForConnect.then(function () {
|
|
674
|
+
return self._sendAbly(message, timeout);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return Promise.reject(
|
|
678
|
+
new Error("Sandbox not connected (state: " + connState + ")"),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
this.messageId++;
|
|
683
|
+
message.requestId = this.uniqueId + "-" + this.messageId;
|
|
684
|
+
|
|
685
|
+
if (message.os) this.os = message.os;
|
|
686
|
+
if (this.os && !message.os) message.os = this.os;
|
|
687
|
+
|
|
688
|
+
if (this.sessionInstance && !message.session) {
|
|
689
|
+
var sessionId = this.sessionInstance.get();
|
|
690
|
+
if (sessionId) message.session = sessionId;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (
|
|
694
|
+
this._lastConnectParams &&
|
|
695
|
+
this._lastConnectParams.sandboxId &&
|
|
696
|
+
!message.sandboxId
|
|
697
|
+
) {
|
|
698
|
+
var id = this._lastConnectParams.sandboxId;
|
|
699
|
+
if (id && !/^\d+\.\d+\.\d+\.\d+$/.test(id)) {
|
|
700
|
+
message.sandboxId = id;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Attach Sentry distributed trace headers for runner-side tracing
|
|
705
|
+
var traceSessionId = this.sessionInstance
|
|
706
|
+
? this.sessionInstance.get()
|
|
707
|
+
: message.session;
|
|
708
|
+
if (traceSessionId) {
|
|
709
|
+
var traceHeaders = getSentryTraceHeaders(traceSessionId);
|
|
710
|
+
if (traceHeaders["sentry-trace"]) {
|
|
711
|
+
message.sentryTrace = traceHeaders["sentry-trace"];
|
|
712
|
+
message.baggage = traceHeaders.baggage;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
var resolvePromise, rejectPromise;
|
|
717
|
+
var self = this;
|
|
718
|
+
|
|
719
|
+
var p = new Promise(function (resolve, reject) {
|
|
720
|
+
resolvePromise = resolve;
|
|
721
|
+
rejectPromise = reject;
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
var requestId = message.requestId;
|
|
725
|
+
|
|
726
|
+
var timeoutId = setTimeout(function () {
|
|
727
|
+
if (self.ps[requestId]) {
|
|
728
|
+
delete self.ps[requestId];
|
|
729
|
+
delete self._execBuffers[requestId];
|
|
730
|
+
rejectPromise(
|
|
731
|
+
new Error(
|
|
732
|
+
"Sandbox message '" +
|
|
733
|
+
message.type +
|
|
734
|
+
"' timed out after " +
|
|
735
|
+
timeout +
|
|
736
|
+
"ms",
|
|
737
|
+
),
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}, timeout);
|
|
741
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
742
|
+
|
|
743
|
+
this.ps[requestId] = {
|
|
744
|
+
promise: p,
|
|
745
|
+
resolve: function (result) {
|
|
746
|
+
clearTimeout(timeoutId);
|
|
747
|
+
resolvePromise(result);
|
|
748
|
+
},
|
|
749
|
+
reject: function (error) {
|
|
750
|
+
clearTimeout(timeoutId);
|
|
751
|
+
rejectPromise(error);
|
|
752
|
+
},
|
|
753
|
+
message: message,
|
|
754
|
+
startTime: Date.now(),
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
if (message.type === "output") {
|
|
758
|
+
p.catch(function () {});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
this._throttledPublish(this._sessionChannel, "command", message)
|
|
762
|
+
.then(function () {
|
|
763
|
+
emitter.emit(events.sandbox.sent, message);
|
|
764
|
+
})
|
|
765
|
+
.catch(function (err) {
|
|
766
|
+
if (self.ps[requestId]) {
|
|
767
|
+
clearTimeout(timeoutId);
|
|
768
|
+
delete self.ps[requestId];
|
|
769
|
+
rejectPromise(
|
|
770
|
+
new Error("Failed to send message: " + err.message),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
return p;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
|
|
780
|
+
* Also tracks and logs the current publish rate for debugging.
|
|
781
|
+
* @param {Object} channel - Ably channel to publish on
|
|
782
|
+
* @param {string} eventName - Event name for the publish
|
|
783
|
+
* @param {Object} message - Message payload
|
|
784
|
+
* @returns {Promise} - Resolves when publish completes
|
|
785
|
+
*/
|
|
786
|
+
async _throttledPublish(channel, eventName, message) {
|
|
787
|
+
var self = this;
|
|
788
|
+
var now = Date.now();
|
|
789
|
+
|
|
790
|
+
// Rate limiting: wait if too soon since last publish
|
|
791
|
+
var elapsed = now - this._publishLastTime;
|
|
792
|
+
if (elapsed < this._publishMinIntervalMs) {
|
|
793
|
+
var waitMs = this._publishMinIntervalMs - elapsed;
|
|
794
|
+
await new Promise(function (resolve) {
|
|
795
|
+
var timer = setTimeout(resolve, waitMs);
|
|
796
|
+
if (timer.unref) timer.unref();
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
this._publishLastTime = Date.now();
|
|
800
|
+
|
|
801
|
+
// Metrics: track messages per second
|
|
802
|
+
this._publishCount++;
|
|
803
|
+
var windowElapsed = Date.now() - this._publishWindowStart;
|
|
804
|
+
if (windowElapsed >= 1000) {
|
|
805
|
+
var rate = (this._publishCount / windowElapsed) * 1000;
|
|
806
|
+
var rateStr = rate.toFixed(1);
|
|
807
|
+
|
|
808
|
+
// Log rate - warning if approaching limit, debug otherwise
|
|
809
|
+
if (rate > 45) {
|
|
810
|
+
logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
|
|
811
|
+
} else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
812
|
+
logger.log("Ably publish rate: " + rateStr + " msg/sec");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Reset window
|
|
816
|
+
this._publishCount = 0;
|
|
817
|
+
this._publishWindowStart = Date.now();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return channel.publish(eventName, message).then(function () {
|
|
821
|
+
logger.log(`[ably] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async auth(apiKey) {
|
|
826
|
+
this.apiKey = apiKey;
|
|
827
|
+
var sessionId = this.sessionInstance
|
|
828
|
+
? this.sessionInstance.get()
|
|
829
|
+
: null;
|
|
830
|
+
|
|
831
|
+
var reply = await this._httpPostWithConcurrencyRetry(
|
|
832
|
+
"/api/v7/sandbox/authenticate",
|
|
833
|
+
{
|
|
834
|
+
apiKey: apiKey,
|
|
835
|
+
version: version,
|
|
836
|
+
session: sessionId,
|
|
837
|
+
},
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
if (reply.success) {
|
|
841
|
+
this.authenticated = true;
|
|
842
|
+
this.apiSocketConnected = true;
|
|
843
|
+
this._teamId = reply.teamId;
|
|
844
|
+
|
|
845
|
+
if (reply.traceId) {
|
|
846
|
+
this.traceId = reply.traceId;
|
|
847
|
+
logger.log("");
|
|
848
|
+
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
849
|
+
logger.log(
|
|
850
|
+
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
851
|
+
reply.traceId,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
emitter.emit(events.sandbox.authenticated, {
|
|
856
|
+
traceId: reply.traceId,
|
|
857
|
+
});
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
setConnectionParams(params) {
|
|
865
|
+
this._lastConnectParams = params ? Object.assign({}, params) : null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async connect(sandboxId, persist, keepAlive) {
|
|
869
|
+
if (persist === undefined) persist = false;
|
|
870
|
+
if (keepAlive === undefined) keepAlive = null;
|
|
871
|
+
var sessionId = this.sessionInstance
|
|
872
|
+
? this.sessionInstance.get()
|
|
873
|
+
: null;
|
|
874
|
+
|
|
875
|
+
var reply = await this._httpPostWithConcurrencyRetry(
|
|
876
|
+
"/api/v7/sandbox/authenticate",
|
|
877
|
+
{
|
|
878
|
+
apiKey: this.apiKey,
|
|
879
|
+
version: version,
|
|
880
|
+
sandboxId: sandboxId,
|
|
881
|
+
session: sessionId,
|
|
882
|
+
keepAlive: keepAlive || undefined,
|
|
883
|
+
},
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
if (!reply.success) {
|
|
887
|
+
this.setConnectionParams(null);
|
|
888
|
+
throw new Error(reply.errorMessage || "Failed to connect to sandbox");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
this._sandboxId = reply.sandboxId;
|
|
892
|
+
|
|
893
|
+
if (reply.ably && reply.ably.token) {
|
|
894
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
this.setConnectionParams({
|
|
898
|
+
sandboxId: sandboxId,
|
|
899
|
+
persist: persist,
|
|
900
|
+
keepAlive: keepAlive,
|
|
901
|
+
});
|
|
902
|
+
this.instanceSocketConnected = true;
|
|
903
|
+
emitter.emit(events.sandbox.connected);
|
|
904
|
+
|
|
905
|
+
// Prefer runner-provided vncUrl, fall back to ip+port, then bare IP
|
|
906
|
+
const reconnectRunner = reply.runner || {};
|
|
907
|
+
const reconnectVncUrl = reconnectRunner.vncUrl;
|
|
908
|
+
const reconnectNoVncPort = reconnectRunner.noVncPort;
|
|
909
|
+
const reconnectIp = reconnectRunner.ip;
|
|
910
|
+
let reconnectUrl;
|
|
911
|
+
if (reconnectVncUrl) {
|
|
912
|
+
reconnectUrl = reconnectVncUrl;
|
|
913
|
+
} else if (reconnectIp && reconnectNoVncPort) {
|
|
914
|
+
reconnectUrl = `http://${reconnectIp}:${reconnectNoVncPort}/vnc_lite.html`;
|
|
915
|
+
} else if (reconnectIp) {
|
|
916
|
+
reconnectUrl = "http://" + reconnectIp;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
success: true,
|
|
921
|
+
url: reconnectUrl,
|
|
922
|
+
sandbox: {
|
|
923
|
+
sandboxId: reply.sandboxId,
|
|
924
|
+
instanceId: reply.sandboxId,
|
|
925
|
+
os: reconnectRunner.os || undefined,
|
|
926
|
+
ip: reconnectIp || undefined,
|
|
927
|
+
url: reconnectUrl,
|
|
928
|
+
vncPort: reconnectNoVncPort || undefined,
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async boot(apiRoot) {
|
|
934
|
+
if (apiRoot) this.apiRoot = apiRoot;
|
|
935
|
+
return this;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async close() {
|
|
939
|
+
if (this.heartbeat) {
|
|
940
|
+
clearInterval(this.heartbeat);
|
|
941
|
+
this.heartbeat = null;
|
|
942
|
+
}
|
|
943
|
+
if (this._statsInterval) {
|
|
944
|
+
clearInterval(this._statsInterval);
|
|
945
|
+
this._statsInterval = null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Send end-session control message to runner before disconnecting
|
|
949
|
+
if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
|
|
950
|
+
try {
|
|
951
|
+
logger.log('[ably] Publishing control: type=end-session');
|
|
952
|
+
await this._sessionChannel.publish('control', { type: 'end-session' });
|
|
953
|
+
} catch (e) {
|
|
954
|
+
// Ignore - best effort
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Leave presence on session channel
|
|
959
|
+
if (this._sessionChannel) {
|
|
960
|
+
try {
|
|
961
|
+
logger.log('[ably] Leaving presence on session channel');
|
|
962
|
+
await this._sessionChannel.presence.leave();
|
|
963
|
+
} catch (e) {
|
|
964
|
+
// ignore - best effort, Ably will auto-leave on disconnect
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
logger.log('[ably] Detaching session channel');
|
|
970
|
+
if (this._sessionChannel) {
|
|
971
|
+
await this._sessionChannel.detach();
|
|
972
|
+
}
|
|
973
|
+
} catch (e) {
|
|
974
|
+
/* ignore */
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (this._ably) {
|
|
978
|
+
try {
|
|
979
|
+
logger.log('[ably] Closing Ably connection');
|
|
980
|
+
this._ably.close();
|
|
981
|
+
} catch (e) {
|
|
982
|
+
/* ignore */
|
|
983
|
+
}
|
|
984
|
+
this._ably = null;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
this._sessionChannel = null;
|
|
988
|
+
this._channelName = null;
|
|
989
|
+
this.apiSocketConnected = false;
|
|
990
|
+
this.instanceSocketConnected = false;
|
|
991
|
+
this.authenticated = false;
|
|
992
|
+
this.instance = null;
|
|
993
|
+
this._lastConnectParams = null;
|
|
994
|
+
this.ps = {};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Write the agent config JSON to an EC2 instance via AWS SSM.
|
|
999
|
+
* Runs client-side so the API doesn't need AWS permissions on user infra.
|
|
1000
|
+
*/
|
|
1001
|
+
async _provisionAgentConfig(instanceId, agentConfig) {
|
|
1002
|
+
const { execSync } = require('child_process');
|
|
1003
|
+
const { writeFileSync, unlinkSync } = require('fs');
|
|
1004
|
+
const { join } = require('path');
|
|
1005
|
+
const { tmpdir } = require('os');
|
|
1006
|
+
|
|
1007
|
+
const configJson = JSON.stringify(agentConfig);
|
|
1008
|
+
const region = process.env.AWS_REGION || 'us-east-2';
|
|
1009
|
+
|
|
1010
|
+
// Write SSM parameters to a temp file to avoid shell quoting issues
|
|
1011
|
+
// Log key config details for debugging
|
|
1012
|
+
logger.log('Agent config being provisioned:');
|
|
1013
|
+
logger.log(' sandboxId: ' + agentConfig.sandboxId);
|
|
1014
|
+
logger.log(' apiRoot: ' + agentConfig.apiRoot);
|
|
1015
|
+
logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
|
|
1016
|
+
logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
|
|
1017
|
+
|
|
1018
|
+
const paramsJson = JSON.stringify({
|
|
1019
|
+
commands: [
|
|
1020
|
+
// Debug: show existing state
|
|
1021
|
+
"Write-Host '=== Checking existing state ==='",
|
|
1022
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1023
|
+
"if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
|
|
1024
|
+
"if (Test-Path 'C:\\Windows\\Temp\\testdriver-agent.json') { Write-Host 'Old config:'; Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | Write-Host } else { Write-Host 'Config file does NOT exist yet' }",
|
|
1025
|
+
// Stop any running runner
|
|
1026
|
+
"Write-Host '=== Stopping runner ==='",
|
|
1027
|
+
"Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
|
|
1028
|
+
"Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1029
|
+
// Write config
|
|
1030
|
+
"Write-Host '=== Writing config ==='",
|
|
1031
|
+
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
1032
|
+
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
1033
|
+
"Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
|
|
1034
|
+
// Show what was written (redact token)
|
|
1035
|
+
"Write-Host '=== New config (token redacted) ==='",
|
|
1036
|
+
"$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
|
|
1037
|
+
"Write-Host \"sandboxId: $($cfg.sandboxId)\"",
|
|
1038
|
+
"Write-Host \"apiRoot: $($cfg.apiRoot)\"",
|
|
1039
|
+
"Write-Host \"channel: $($cfg.ably.channel)\"",
|
|
1040
|
+
"Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
|
|
1041
|
+
// Start the runner
|
|
1042
|
+
"Write-Host '=== Starting runner ==='",
|
|
1043
|
+
"Start-Sleep -Seconds 1",
|
|
1044
|
+
"Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
|
|
1045
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
|
|
1046
|
+
"Write-Host \"Task state after start: $($task.State)\"",
|
|
1047
|
+
// Check if node process started
|
|
1048
|
+
"Start-Sleep -Seconds 3",
|
|
1049
|
+
"Write-Host '=== Checking runner process ==='",
|
|
1050
|
+
"$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
|
|
1051
|
+
"if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
|
|
1052
|
+
// Check runner logs
|
|
1053
|
+
"Write-Host '=== Runner log (last 30 lines) ==='",
|
|
1054
|
+
"if (Test-Path 'C:\\testdriver\\logs\\sandbox-agent.log') { Get-Content 'C:\\testdriver\\logs\\sandbox-agent.log' -Tail 30 | Write-Host } else { Write-Host 'No log file found' }",
|
|
1055
|
+
"Write-Host '=== Done ==='",
|
|
1056
|
+
],
|
|
1057
|
+
});
|
|
1058
|
+
const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
|
|
1059
|
+
writeFileSync(tmpFile, paramsJson);
|
|
1060
|
+
|
|
1061
|
+
try {
|
|
1062
|
+
const output = execSync(
|
|
1063
|
+
'aws ssm send-command --region "' + region + '" --instance-ids "' + instanceId + '" ' +
|
|
1064
|
+
'--document-name "AWS-RunPowerShellScript" ' +
|
|
1065
|
+
'--parameters file://' + tmpFile + ' --output json',
|
|
1066
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1067
|
+
);
|
|
1068
|
+
const cmdId = JSON.parse(output).Command.CommandId;
|
|
1069
|
+
logger.log('SSM command sent: ' + cmdId);
|
|
1070
|
+
|
|
1071
|
+
// Wait for the command to complete
|
|
1072
|
+
execSync(
|
|
1073
|
+
'aws ssm wait command-executed --region "' + region + '" ' +
|
|
1074
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
1075
|
+
{ encoding: 'utf-8', timeout: 60000 }
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
// Get the command output for debugging
|
|
1079
|
+
try {
|
|
1080
|
+
const invocationOutput = execSync(
|
|
1081
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1082
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1083
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1084
|
+
);
|
|
1085
|
+
const invocation = JSON.parse(invocationOutput);
|
|
1086
|
+
if (invocation.StandardOutputContent) {
|
|
1087
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1088
|
+
}
|
|
1089
|
+
if (invocation.StandardErrorContent) {
|
|
1090
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1091
|
+
}
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1094
|
+
}
|
|
1095
|
+
} finally {
|
|
1096
|
+
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return new Sandbox();
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
module.exports = { createSandbox };
|